Skip to content

Commit 71e1199

Browse files
fix: resolve 8 bugs from pre-release testing (circular import, naive backtest, missing APIs) (#265)
1 parent 1f1e3e4 commit 71e1199

12 files changed

Lines changed: 433 additions & 23 deletions

File tree

functime/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"functime: Time-series machine learning at scale."
22

3-
__version__ = "1.0.0rc1"
3+
__version__ = "1.0.0"
44

55
from functime.feature_extractors import FeatureExtractor # noqa: F401

functime/backtesting.py

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,30 +156,94 @@ def backtest(
156156
# Append results
157157
y_preds.append(y_pred)
158158
if residualize:
159-
# Residuals
160-
y_resid = _residualize_autoreg(
161-
y_train=y_train,
162-
X_train=X_train,
163-
strategy=forecaster.state.strategy,
164-
lags=forecaster.lags,
165-
max_horizons=forecaster.max_horizons,
166-
artifacts=forecaster.state.artifacts,
159+
# Residuals — only for autoregressive models that have a regressor
160+
artifacts = forecaster.state.artifacts
161+
has_regressor = (
162+
"regressor" in artifacts
163+
or "regressors" in artifacts
164+
or (
165+
"recursive" in artifacts
166+
and "regressor" in artifacts.get("recursive", {})
167+
)
167168
)
169+
if has_regressor:
170+
y_resid = _residualize_autoreg(
171+
y_train=y_train,
172+
X_train=X_train,
173+
strategy=forecaster.state.strategy,
174+
lags=forecaster.lags,
175+
max_horizons=forecaster.max_horizons,
176+
artifacts=artifacts,
177+
)
178+
else:
179+
# For non-autoregressive models (e.g. naive, snaive),
180+
# compute naive residuals: y[t] - y[t-1]
181+
_y_train_df = y_train.lazy().collect(engine="streaming") if isinstance(y_train, pl.LazyFrame) else y_train
182+
idx_cols = _y_train_df.columns[:2]
183+
_target_col = _y_train_df.columns[-1]
184+
y_resid = (
185+
_y_train_df.sort(idx_cols)
186+
.with_columns(
187+
(
188+
pl.col(_target_col)
189+
- pl.col(_target_col).shift(1).over(idx_cols[0])
190+
).alias("y_resid")
191+
)
192+
.select(*idx_cols, "y_resid")
193+
.drop_nulls()
194+
)
195+
if isinstance(y_resid, pl.LazyFrame):
196+
y_resid = y_resid.collect(engine="streaming")
168197
y_resid = y_resid.with_columns(pl.lit(i).alias("split"))
169198
y_resids.append(y_resid)
170199

171200
y_preds = pl.concat(y_preds)
172201
full_forecaster = forecaster.fit(y=y, X=X)
173202
if residualize:
174-
y_resids = _merge_autoreg_residuals(
175-
y=y,
176-
X=X,
177-
y_resids=pl.concat(y_resids),
178-
strategy=full_forecaster.state.strategy,
179-
lags=forecaster.lags,
180-
max_horizons=forecaster.max_horizons,
181-
artifacts=full_forecaster.state.artifacts,
203+
artifacts = full_forecaster.state.artifacts
204+
has_regressor = (
205+
"regressor" in artifacts
206+
or "regressors" in artifacts
207+
or (
208+
"recursive" in artifacts
209+
and "regressor" in artifacts.get("recursive", {})
210+
)
182211
)
212+
# Collect any lazy residuals
213+
y_resids_collected = [
214+
r.collect(engine="streaming") if isinstance(r, pl.LazyFrame) else r
215+
for r in y_resids
216+
]
217+
if has_regressor:
218+
y_resids = _merge_autoreg_residuals(
219+
y=y,
220+
X=X,
221+
y_resids=pl.concat(y_resids_collected),
222+
strategy=full_forecaster.state.strategy,
223+
lags=forecaster.lags,
224+
max_horizons=forecaster.max_horizons,
225+
artifacts=artifacts,
226+
)
227+
else:
228+
# For non-autoregressive models, compute naive residuals for the full data
229+
_y_df = y.lazy().collect(engine="streaming") if isinstance(y, pl.LazyFrame) else y
230+
idx_cols = _y_df.columns[:2]
231+
_target_col = _y_df.columns[-1]
232+
y_resid = (
233+
_y_df.sort(idx_cols)
234+
.with_columns(
235+
(
236+
pl.col(_target_col)
237+
- pl.col(_target_col).shift(1).over(idx_cols[0])
238+
).alias("y_resid")
239+
)
240+
.select(*idx_cols, "y_resid")
241+
.drop_nulls()
242+
)
243+
combined = pl.concat(y_resids_collected)
244+
last_split = combined.get_column("split").max() + 1
245+
y_resid = y_resid.with_columns(pl.lit(last_split).alias("split"))
246+
y_resids = pl.concat([combined, y_resid])
183247
pl.disable_string_cache()
184248
return y_preds, y_resids
185249
pl.disable_string_cache()

functime/base/forecaster.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"1d",
3232
"1w",
3333
"1mo",
34+
"1q",
3435
"3mo",
3536
"1y",
3637
]

functime/conversion.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,95 @@
44
import polars as pl
55

66

7+
def wide_to_long(
8+
df: pl.DataFrame,
9+
id_col: str = "entity_id",
10+
time_col: str = "date",
11+
value_col: str = "value",
12+
) -> pl.DataFrame:
13+
"""Convert a wide-format DataFrame to long (panel) format.
14+
15+
In wide format, each entity is a column and each row is a timestamp.
16+
In long format, there are three columns: entity, time, and value.
17+
18+
Parameters
19+
----------
20+
df : pl.DataFrame
21+
Wide-format DataFrame where the first column is the time index
22+
and remaining columns are entities.
23+
id_col : str
24+
Name for the entity column in the output. Defaults to "entity_id".
25+
time_col : str
26+
Name of the time column (must exist in the input). Defaults to "date".
27+
value_col : str
28+
Name for the value column in the output. Defaults to "value".
29+
30+
Returns
31+
-------
32+
pl.DataFrame
33+
Long-format panel DataFrame with columns [id_col, time_col, value_col].
34+
"""
35+
# Determine the time column — use time_col if present, otherwise first column
36+
if time_col in df.columns:
37+
time_column = time_col
38+
else:
39+
time_column = df.columns[0]
40+
41+
entity_columns = [c for c in df.columns if c != time_column]
42+
result = df.unpivot(
43+
on=entity_columns,
44+
index=time_column,
45+
variable_name=id_col,
46+
value_name=value_col,
47+
)
48+
# Rename the time column if needed
49+
if time_column != time_col:
50+
result = result.rename({time_column: time_col})
51+
# Reorder to [entity, time, value]
52+
return result.select(id_col, time_col, value_col)
53+
54+
55+
def long_to_wide(
56+
df: pl.DataFrame,
57+
id_col: str | None = None,
58+
time_col: str | None = None,
59+
value_col: str | None = None,
60+
) -> pl.DataFrame:
61+
"""Convert a long (panel) format DataFrame to wide format.
62+
63+
In long format, there are entity, time, and value columns.
64+
In wide format, each entity becomes its own column and each row is a timestamp.
65+
66+
Parameters
67+
----------
68+
df : pl.DataFrame
69+
Long-format panel DataFrame with at least 3 columns:
70+
entity, time, value (in that order if col names not specified).
71+
id_col : str, optional
72+
Name of the entity column. Defaults to first column.
73+
time_col : str, optional
74+
Name of the time column. Defaults to second column.
75+
value_col : str, optional
76+
Name of the value column. Defaults to third column.
77+
78+
Returns
79+
-------
80+
pl.DataFrame
81+
Wide-format DataFrame where each entity is a column.
82+
"""
83+
cols = df.columns
84+
id_col = id_col or cols[0]
85+
time_col = time_col or cols[1]
86+
value_col = value_col or cols[2]
87+
88+
result = df.pivot(
89+
on=id_col,
90+
index=time_col,
91+
values=value_col,
92+
).sort(time_col)
93+
return result
94+
95+
796
def df_to_ndarray(df: pl.DataFrame) -> np.ndarray:
897
"""Zero-copy spill-to-disk Polars DataFrame to numpy ndarray."""
998
return df.to_numpy()

functime/forecasting/automl.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ def __init__(
7979
if freq not in SUPPORTED_FREQ:
8080
raise ValueError(f"Offset {freq} not supported")
8181

82+
# Validate kwargs: reject common automl-param misplacements
83+
_automl_params = {
84+
"n_evals", "n_trials", "n_iter", "max_evals", "max_trials",
85+
}
86+
bad_kwargs = _automl_params & set(kwargs)
87+
if bad_kwargs:
88+
raise TypeError(
89+
f"Unknown keyword argument(s) {bad_kwargs} for the underlying regressor. "
90+
f"AutoML evaluation count is controlled by `num_samples`, not {bad_kwargs.pop()!r}. "
91+
f"Only pass keyword arguments accepted by the sklearn-compatible regressor."
92+
)
93+
8294
self.freq = freq
8395
self.min_lags = min_lags
8496
self.max_lags = max_lags

functime/forecasting/elite.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from sklearn.linear_model import LassoLarsIC
1010
from tqdm import tqdm
1111

12-
from functime.backtesting import backtest
1312
from functime.base.forecaster import Forecaster
1413
from functime.base.metric import METRIC_TYPE
1514
from functime.conversion import X_to_numpy, y_to_numpy
@@ -244,6 +243,7 @@ def _fit(self, y: pl.LazyFrame, X: pl.LazyFrame | None = None):
244243
)
245244
else:
246245
forecaster = forecaster_cls(freq=freq)
246+
from functime.backtesting import backtest
247247
y_preds = backtest(forecaster=forecaster, y=y, cv=cv, residualize=False)
248248
cv_y_preds[model_name] = y_preds.pipe(coerce_dtypes(schema)).collect()
249249

functime/metrics/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
smape_original,
1111
underforecast,
1212
)
13+
from .probabilistic import (
14+
crps,
15+
interval_coverage,
16+
winkler_score,
17+
)
1318

1419
__all__ = [
1520
"mae",
@@ -22,4 +27,7 @@
2227
"smape_original",
2328
"overforecast",
2429
"underforecast",
30+
"crps",
31+
"interval_coverage",
32+
"winkler_score",
2533
]

0 commit comments

Comments
 (0)