import random, numpy as np
import pandas as pd
from ucimlrepo import fetch_ucirepo
random.seed(42)
np.random.seed(42)
df = fetch_ucirepo(id=275).data.original
df["timestamp"] = (
pd.to_datetime(df["dteday"]) + pd.to_timedelta(df["hr"], unit="h")
)8 Time series forecasting
- Frame forecasting as a regression task
- Use TabICL for single, univariate time series.
- Use TabICL for multivariate time series.
Next up, we are going to look at time series forecasting. While there are dedicated time series foundation models, we’ll use a wrapped version of the TabICL regressor. The underlying trick is reformulating forecasting as regression (Hoo et al. 2025). But first, let’s talk about the data.
The bike sharing data
We use the Bike Sharing dataset (Fanaee-T and Gama 2013) from the UCI Machine Learning Repository. The data contains hourly rental counts from the Capital Bikeshare system in Washington, D.C., between 2011 and 2012, together with weather and calendar information. Bike rentals are split into casual and registered users, which sum up to the total count cnt, and which is the target we start with.
Let’s load the data.
For simplicity, we predict the total rental count (cnt) without any features besides the timestamp. For that, we pull out just the timestamp and target from the data.
series = df[["timestamp", "cnt"]].rename(columns={"cnt": "target"})
series.head().round(1)| timestamp | target | |
|---|---|---|
| 0 | 2011-01-01 00:00:00 | 16 |
| 1 | 2011-01-01 01:00:00 | 40 |
| 2 | 2011-01-01 02:00:00 | 32 |
| 3 | 2011-01-01 03:00:00 | 13 |
| 4 | 2011-01-01 04:00:00 | 1 |
This is what the first full week from Monday to Sunday looks like:
import matplotlib.pyplot as plt
window = series.iloc[48:(48 + 24 * 7)]
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(window["timestamp"], window["target"], color="#3b6ea5", lw=1)
ax.set(xlabel="Time", ylabel="Rentals")
ax.spines[["top", "right"]].set_visible(False)
fig.autofmt_xdate()Figure 8.1 nicely illustrates patterns in the data: Most bike rentals are during the day. Workdays (Mon - Fri) show a bimodal distribution indicating rentals for morning and evening commute, and there is an overall higher usage. On weekends, we see a unimodal distribution centering around early afternoon.
Forecasting with TabICL
To forecast with TabICL, we use the class TabICLForecaster, a wrapper around TabICLRegressor. We predict the last week of data, and as context we pass all timestamps before that week: about 23.8 months of hourly data. What I call “context” here would be the training data if we were using a traditional machine learning algorithm. For tabular foundation models like TabICL, no weights will be updated, but the model attends to the context data to predict.
By default, only the most recent 4096 context rows are used. Increase max_context_length if you need more context to cover longer patterns and can bear the increased compute.
from tabicl import TabICLForecaster
horizon = 24 * 7 # one week of hourly data
context = series.iloc[:-horizon]
actual = series.iloc[-horizon:]
forecaster = TabICLForecaster(
max_context_length=len(context), tabicl_config={"n_estimators": 4}
)
pred = forecaster.predict_df(context, future_df=actual[["timestamp"]])
pred.head(3).round(0)| target | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | ||
|---|---|---|---|---|---|---|---|---|---|---|---|
| item_id | timestamp | ||||||||||
| 0 | 2012-12-24 23:00:00 | 17.0 | 5.0 | 7.0 | 9.0 | 10.0 | 12.0 | 13.0 | 16.0 | 21.0 | 34.0 |
| 2012-12-25 00:00:00 | 20.0 | 4.0 | 7.0 | 9.0 | 12.0 | 14.0 | 18.0 | 22.0 | 29.0 | 44.0 | |
| 2012-12-25 01:00:00 | 11.0 | 2.0 | 3.0 | 5.0 | 7.0 | 8.0 | 9.0 | 11.0 | 14.0 | 24.0 |
The dataframe with the predictions may look a bit weird at first, so let’s take it apart. It’s a table with a multi-index: The first index item_id tracks different time series. Since we currently only have one time series, we can ignore the item_id. If we tracked casual and registered riders as separate series, they would get different item IDs. Nested within the item_id index, we have the timestamp index, which tells us for which timestamp the prediction is. The columns contain the model predictions: target for the mean predictions, the rest are the respective quantile predictions, for example, column 0.5 contains the predicted 50% quantile, aka the median forecast. By the way: In the code example above we didn’t have to define the target. It’s because TabICLForecaster automatically uses the column we named target in the data.
Plotting prediction bands
The quantile predictions allow us to plot point-wise prediction bands in addition to the (median) forecast, which gives us a sense of uncertainty of the forecasts, see Figure 8.2.
forecast = pred.reset_index()
fig, ax = plt.subplots(figsize=(8, 4))
ax.fill_between(forecast["timestamp"], forecast[0.1], forecast[0.9],
color="lightgrey", alpha=0.6,
label="10th-90th percentile")
ax.plot(forecast["timestamp"], forecast[0.5], color="darkgrey", lw=1.5,
label="median forecast")
ax.plot(actual["timestamp"], actual["target"], color="#c0392b",
lw=1.5, linestyle="--", label="actual")
ax.set(xlabel="Time", ylabel="Rentals")
ax.legend(frameon=False)
fig.autofmt_xdate()The model seems to struggle with December 25th and especially 26th, but otherwise the predictions look good.
Evaluating the forecast
Next we compare the mean absolute error of the TabICL forecaster with a simple naive baseline that reuses last week’s values.
We only evaluate a single week here, which makes evaluation results volatile. A proper evaluation would backtest over a rolling window and average the results. We keep it to one week to stay focused.
naive_pred = context["target"].iloc[-horizon:].values
naive_mae = np.abs(naive_pred - actual["target"].values).mean()Now let’s see how TabICL’s own forecast compares:
future = actual[["timestamp"]]
p = forecaster.predict_df(
context, future_df=future, quantiles=[0.1, 0.5, 0.9]
).reset_index()
mae = (p[0.5] - actual["target"].values).abs().mean()
cov = ((actual["target"].values >= p[0.1]) &
(actual["target"].values <= p[0.9])).mean()
pd.DataFrame([
{"context": "Naive forecast (last week)",
"MAE": round(naive_mae, 1), "80% coverage": "n/a"},
{"context": "TabICLForecaster",
"MAE": round(mae, 1), "80% coverage": f"{cov:.0%}"},
])| context | MAE | 80% coverage | |
|---|---|---|---|
| 0 | Naive forecast (last week) | 98.1 | n/a |
| 1 | TabICLForecaster | 48.6 | 76% |
The TabICL forecast is a clear improvement over our simple baseline. The coverage is a bit too small. However, we shouldn’t overinterpret these specific results, since we only evaluated one week of data.
Turning forecasting into a regression task
Let’s talk about how TabICL handles the task, despite not being pretrained to handle time series forecasting. TabICLForecaster is just a wrapper: It creates a table with temporal features from the timestamps, and gives it to TabICLRegressor for in-context learning. The secret sauce is therefore the features, which include:
- An index that starts at 0 and enumerates the time series.
- A raw
yearcomponent (not cyclic). - Sin/cosine pairs for: second-of-minute, minute-of-hour, hour-of-day, day-of-week, day-of-month, day-of-year, week-of-year, month-of-year.
- Dominant seasonal periods detected automatically through fast Fourier transformation, also added as a sin/cosine feature. Configurable via
AutoPeriodicEncoder(config=PeriodicDetectionConfig(...)). Knobs: detrending method (first_diff,loess,linear,constant), Hann windowing, zero-padding factor,max_top_kperiods, magnitude threshold, optional ACF cross-validation.
Figure 8.3 visualizes some of these features over time.
day_of_week_sin/_cos collide on Sunday and Monday
TabICL’s sin/cos encoding currently (v 2.1.1) has a bug in the seasonal encodings so that the first and last of a period (hour of day, day of week, …) are mapped to the same value. See this GitHub issue. Will update this chapter once fixed.
You can control the set of features that are included with the parameter temporal_features. For example, forecaster = TabICLForecaster(temporal_features = ['index']) only would include the sequential feature. The default is forecaster = TabICLForecaster(temporal_features = ["index", "datetime", "periodic"]); The only option that is not included by default is "fourier" for Fourier basis features that help with periodicity.
Playing with the context window
With the context window, we control the data to which the model may attend for forecasting. For the bike sharing example, we used as many timestamps as we had available. Thanks to the forecasting-as-regression and the in-context learning, we are quite flexible in what we use as the context.
TabICLForecaster can handle gaps in the time series, and the timestamps don’t have to be equidistant. This freedom comes from turning time into features, instead of an autoregressive structure.
You have the same flexibility for the prediction, as you can control the timestamps for which you want the predictions, which can be an irregular grid, and it can have gaps, as visualized in Figure 8.4.
prediction_length instead of future_df
Instead of defining the timestamps for the prediction, you can also provide a prediction_length number, which tells the model how many time steps to roll the prediction forward. However, this only works when all stars are aligned: The model must be able to infer the frequency from the context data, which means context timestamps must be equidistant, and have no missing values. In addition, you can’t use features besides the automatic timestamp-derived features.
Including other features
Alright, time to include external features beyond just the timestamp and its derived features. We will add the holiday feature, a binary indicator telling the model whether the current timestamp is on a holiday.
series_cov = df[["timestamp", "cnt", "holiday"]].rename(
columns={"cnt": "target"}
)
context_cov = series_cov.iloc[:-horizon]
future_cov = series_cov.iloc[-horizon:][["timestamp", "holiday"]]
pred_cov = forecaster.predict_df(
context_cov, future_df=future_cov, quantiles=[0.1, 0.5, 0.9]
).reset_index()Let’s check whether the covariate actually improved the forecast:
mae_cov = (pred_cov[0.5] - actual["target"].values).abs().mean()
print(f"MAE with holiday covariate: {mae_cov:.2f}")MAE with holiday covariate: 46.13
The covariate seems to help a little bit, but again, just one week of evaluation, so let’s not read too much into it right now. And we can plot the forecasted week again, see Figure 8.5.
fig, ax = plt.subplots(figsize=(8, 4))
ax.fill_between(pred_cov["timestamp"], pred_cov[0.1], pred_cov[0.9],
color="lightgrey", alpha=0.6,
label="10th-90th percentile")
ax.plot(pred_cov["timestamp"], pred_cov[0.5], color="darkgrey", lw=1.5,
label="median forecast")
ax.plot(actual["timestamp"], actual["target"], color="#c0392b",
lw=1.5, linestyle="--", label="actual")
ax.plot(forecast["timestamp"], forecast[0.5], color="lightblue",
lw=1.5, linestyle=":", label="median forecast (no holidays)")
ax.set(xlabel="Time", ylabel="Rentals")
ax.legend(frameon=False)
fig.autofmt_xdate()While the model still overshoots the actual bike rentals on December 25th, it now forecasts a unimodal instead of a bimodal distribution, which is closer to the actual rental situation. The 26th (not an official holiday) remains a forecast failure.
Forecasting multiple series
So far, we only forecast a single time series. But often we have multiple time series, be it through different targets (e.g., forecasting CO2 and NOX) or multiple sites (e.g., multiple stores). In our example, our target, the number of rented bikes, is actually the sum of two types of cyclists: casual and registered. We can forecast both at once, treating each rider type as a separate series via its own item_id.
long = df.melt(id_vars="timestamp", value_vars=["casual", "registered"],
var_name="item_id", value_name="target")
cutoff = long["timestamp"].max() - pd.Timedelta(hours=horizon)
context_multi = long[long["timestamp"] <= cutoff]
actual_multi = long[long["timestamp"] > cutoff]
pred_multi = forecaster.predict_df(
context_multi,
future_df=actual_multi[["item_id", "timestamp"]],
quantiles=[0.1, 0.5, 0.9],
)
pred_multi.groupby("item_id").head(3)| target | 0.1 | 0.5 | 0.9 | ||
|---|---|---|---|---|---|
| item_id | timestamp | ||||
| casual | 2012-12-25 00:00:00 | 1.884055 | -0.272058 | 1.041634 | 4.989919 |
| 2012-12-25 01:00:00 | 1.416307 | -0.337686 | 0.615723 | 4.110498 | |
| 2012-12-25 02:00:00 | 1.112165 | -0.345399 | 0.423958 | 3.395827 | |
| registered | 2012-12-25 00:00:00 | 21.703087 | 4.456928 | 16.253983 | 47.875443 |
| 2012-12-25 01:00:00 | 6.909313 | 1.559772 | 5.567108 | 13.743504 | |
| 2012-12-25 02:00:00 | 5.866596 | 1.130043 | 4.842171 | 11.707561 |
Forecasting multiple time series at once, in this way, is basically just a convenience wrapper. A bit simpler code, and each item_id is dispatched in parallel across up to 8 worker processes via joblib, which happens automatically based on hardware. Beyond that, each series is forecasted individually, so no accounting for correlations between the series.




