8  Time series forecasting

By the end of this chapter, you’ll be able to:
  • 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.

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")
)

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: The first full week (Mon - Sun) of the hourly rental count, showing the daily cycle and day-to-day swings.

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.

Warning

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()
Figure 8.2: Median forecast and 10th-90th percentile band versus actual rental count over the forecasted week.

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.

Warning

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 year component (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_k periods, magnitude threshold, optional ACF cross-validation.

Figure 8.3 visualizes some of these features over time.

Figure 8.3: A few of the TabICLForecaster-generated features over three days: the running index (trend) and the sin/cosine encodings of hour-of-day and day-of-week.
Bug: 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.

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()
Figure 8.5: Median forecast and 10th-90th percentile band versus actual rental count over the forecasted week, using the holiday covariate. The dotted line is the median forecast without the holiday covariate.

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.