Time Series and Forecasting

Multivariate forecasting

Using other series to forecast yours only works when you will actually know those other series in advance, which is a much harder condition than it first sounds.

On this page 10
  1. The short answer
  2. The analogy you have already lived
  3. Two different jobs
  4. The circular problem in job one
  5. Three honest ways out
  6. The other trap: things that move together mean nothing
  7. "It predicts it" is not "it causes it"
  8. What is honestly hard here
  9. Remember this
  10. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Bringing in a second series helps only if you will know that second series before the day you are forecasting.

The analogy you have already lived

You are deciding whether to carry an umbrella tomorrow.

Rain tomorrow would settle it instantly. But you do not have tomorrow's rain. You have a weather forecast of tomorrow's rain, and that forecast is sometimes wrong.

So your umbrella decision is never as good as it would be with perfect knowledge. It is only as good as the weather forecast you fed it.

That gap — between the ideal input and the input you can actually obtain — is the entire subject of this lesson.

Two different jobs

Job one: forecast one series using others. You care about electricity demand. Temperature helps enormously. Temperature is the helper, demand is the target.

Job two: forecast several series that push each other around. Ad spend and sales. More spend lifts sales, and good sales free up budget for more spend. Neither is purely a helper. You want to forecast both together.

These need different tools, and confusing them wastes a lot of time.

The circular problem in job one

Here is the trap, and it catches good engineers.

You want to predict tomorrow's electricity demand. You add tomorrow's temperature as an input. Your model becomes wonderfully accurate on historical data, because in history you have every day's temperature.

Then you go live, and it is Tuesday evening, and you do not have Wednesday's temperature. Nobody does. Wednesday has not happened.

   to forecast tomorrow's DEMAND
      you need tomorrow's TEMPERATURE
         which you must forecast
            and that forecast has its own error
               which flows into your demand forecast

You have not removed a forecasting problem. You have added one.

Three honest ways out

Use a weather forecast, and accept its error. Real weather forecasts are good, so this often works. In the developer section, perfect temperature knowledge cuts the error by three quarters. A realistic weather forecast, with the errors real forecasts have, delivers about half of that gain. Half is still a lot.

Use a lagged helper. Yesterday's value of the other series is a fact, not a forecast. Safe, and often weaker.

Find a true leading indicator. Something that moves first, by enough time. Orders placed today ship in a week. Wedding bookings made in March tell you about catering demand in November. These are the gold standard, because the information genuinely arrives early.

The other trap: things that move together mean nothing

Two series that both climb over five years will look strongly related. They will look related even if one is your sales and the other is unrelated rainfall in another country.

This is not a small effect. In stationarity there is an experiment where forty percent of pairs of completely unrelated wandering series showed a strong apparent relationship.

Before you believe any pair of series is connected, compare their changes rather than their levels. Most apparent relationships vanish at that point, and the ones that survive are worth investigating.

"It predicts it" is not "it causes it"

There is a statistical test that asks whether one series helps predict another. It is useful and its name is misleading, because it contains the word "causality".

It cannot detect causation. It detects that one series arrives earlier and carries useful information. Ice cream sales predict drowning deaths, and neither causes the other — summer causes both.

Use it to decide what to feed a model. Never use it to argue that A causes B.

What is honestly hard here

Every extra series you add costs you.

More inputs means more numbers to estimate from the same amount of history. With two series and a few steps of memory, you already have more than a dozen quantities to learn. With five series it grows fast, and there is rarely enough data.

There is also a hard ceiling that surprises people. Look far enough into the future and a well-behaved multivariate model gives up. It settles down to predicting the long-run average of each series. The developer section shows this happening. At a long horizon, a correct model matched a forecast that ignored everything and used the historical average.

That is not a failure of the model. It is honest. The information genuinely runs out.

Remember this

  • A helper series is worth adding only if you will know it in advance.
  • A realistic forecast of the helper delivers roughly half of the ideal benefit.
  • Compare changes, not levels, before believing two series are connected.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy pandas scikit-learn statsmodels

Measuring the cost of not knowing the future

This experiment prices the whole idea. Same target, same model, four different degrees of knowledge about temperature.

knowing_the_helper.py
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error

rng = np.random.default_rng(19)
n, cut = 900, 750
days = pd.date_range("2023-01-01", periods=n, freq="D")

# Daily max temperature in a city: a yearly swing plus weather wobble.
temp = 30 + 8 * np.sin(2 * np.pi * (days.dayofyear.to_numpy() - 110) / 365.25) + rng.normal(0, 2.5, n)
# Electricity units for a colony: cooling load switches on above 28 degrees.
demand = (4000 + 300 * np.maximum(temp - 28, 0)
          + np.where(days.dayofweek >= 5, 300, 0) + rng.normal(0, 150, n))

cool = lambda t: np.maximum(t - 28, 0)      # "cooling degrees": heat above the comfort point

d = pd.DataFrame({"demand": demand}, index=days)
d["dow"] = d.index.dayofweek
d["demand_lag1"] = d.demand.shift(1)
d["demand_lag7"] = d.demand.shift(7)
d["cool_tomorrow"] = cool(temp)                                   # NOT knowable in advance
d["cool_yesterday"] = pd.Series(cool(temp), index=days).shift(1)   # knowable
d["cool_forecast"] = cool(temp + rng.normal(0, 1.5, n))            # a weather forecast, with error

base = ["dow", "demand_lag1", "demand_lag7"]
sets = {
    "no temperature at all      ": base,
    "tomorrow's REAL temperature": base + ["cool_tomorrow"],
    "yesterday's temperature    ": base + ["cool_yesterday"],
    "a weather FORECAST of it   ": base + ["cool_forecast"],
}
ok = d.notna().all(axis=1)
dd, y = d[ok], d.demand[ok]
for name, cols in sets.items():
    m = Ridge(alpha=1.0).fit(dd[cols][:cut], y[:cut])
    print(f"{name} MAE: {mean_absolute_error(y[cut:], m.predict(dd[cols][cut:])):7.1f}")
print("noise floor, roughly        MAE:", round(0.8 * 150, 1))
Output
no temperature at all       MAE:   471.1
tomorrow's REAL temperature MAE:   119.5
yesterday's temperature     MAE:   469.8
a weather FORECAST of it    MAE:   265.5
noise floor, roughly        MAE: 120.0

Four numbers, four separate lessons.

Perfect knowledge scored 119.5, which is the noise floor. Temperature explains almost everything about this demand series. If you had tomorrow's temperature you would have a near-perfect forecast. This is the number a careless backtest reports, and it is unobtainable.

Yesterday's temperature bought almost nothing, 469.8 against 471.1. Day-to-day weather wobble is close to random, so yesterday's reading carries little beyond the seasonal signal that dow and the demand lags already hold. A lagged helper is safe and often useless.

A realistic weather forecast scored 265.5. Giving the model temperature with an error of about 1.5 degrees recovered a little over half of the available benefit. That is the honest number to plan around, and it is still a large improvement over having nothing.

cool = max(temp - 28, 0) is doing real work here. Demand does not respond to temperature below the comfort point at all, and rises steeply above it. Feeding raw temperature to a linear model would force one straight line through both regimes. This transformation is called cooling degree days in the energy industry, and inventing the right one for your domain usually beats swapping models.

When two series push each other

For job two — several series that influence one another — the classical tool is a vector autoregression. Each series is regressed on the recent past of every series, including itself.

var_model.py
import numpy as np
import pandas as pd
from statsmodels.tsa.api import VAR

rng = np.random.default_rng(27)
n = 600
ads = np.zeros(n)      # daily ad spend, in thousands
sales = np.zeros(n)    # daily sales, in thousands
for t in range(2, n):
    # spend reacts to yesterday's sales; sales react to spend from TWO days ago
    ads[t] = 0.6 * ads[t-1] + 0.10 * sales[t-1] + rng.normal(0, 1.0)
    sales[t] = 0.5 * sales[t-1] + 0.80 * ads[t-2] + rng.normal(0, 1.0)

df = pd.DataFrame({"ads": ads, "sales": sales}).iloc[50:].reset_index(drop=True)
train, test = df.iloc[:-200], df.iloc[-200:]
res = VAR(train).fit(maxlags=3, ic="aic")
print("lags chosen by AIC:", res.k_ar)
print("\nfitted sales equation (true: 0.5*sales_lag1 + 0.80*ads_lag2):")
print(res.params["sales"].round(3).to_string())

full = df.values
k = res.k_ar
one_step = np.array([res.forecast(full[i-k:i], steps=1)[0] for i in range(len(train), len(df))])
print("\none-step-ahead over the last 200 days")
print("  VAR            MAE  ads %.2f   sales %.2f"
      % (np.abs(test.ads.to_numpy()-one_step[:,0]).mean(), np.abs(test.sales.to_numpy()-one_step[:,1]).mean()))
print("  repeat yesterday MAE ads %.2f   sales %.2f"
      % (np.abs(test.ads.to_numpy()-full[len(train)-1:-1,0]).mean(),
         np.abs(test.sales.to_numpy()-full[len(train)-1:-1,1]).mean()))

fc20 = res.forecast(train.values[-k:], steps=200)
print("\ntwo-hundred-step-ahead from one origin")
print("  VAR            MAE  ads %.2f   sales %.2f"
      % (np.abs(test.ads.to_numpy()-fc20[:,0]).mean(), np.abs(test.sales.to_numpy()-fc20[:,1]).mean()))
print("  training mean  MAE  ads %.2f   sales %.2f"
      % (np.abs(test.ads.to_numpy()-train.ads.mean()).mean(), np.abs(test.sales.to_numpy()-train.sales.mean()).mean()))
Output
lags chosen by AIC: 2

fitted sales equation (true: 0.5*sales_lag1 + 0.80*ads_lag2):
const       0.038
L1.ads     -0.060
L1.sales    0.499
L2.ads      0.851
L2.sales   -0.007

one-step-ahead over the last 200 days
  VAR            MAE  ads 0.87   sales 0.72
  repeat yesterday MAE ads 0.94   sales 1.06

two-hundred-step-ahead from one origin
  VAR            MAE  ads 1.18   sales 1.61
  training mean  MAE  ads 1.17   sales 1.60

The model recovered the structure it was never told about. L1.sales came out at 0.499 against a true 0.5. L2.ads came out at 0.851 against a true 0.80. The two coefficients that should be zero, L1.ads and L2.sales, came out at -0.060 and -0.007. AIC also chose two lags, which is correct.

At one step ahead, the VAR beats the naive baseline on both series. Sales improved most, from 1.06 to 0.72, because sales genuinely depend on ad spend two days earlier — information the naive forecast cannot use.

At two hundred steps ahead, the VAR matched the training mean to two decimal places. This is not a bug and it is the most important line in the output. A stationary VAR's forecast converges to the unconditional mean as the horizon grows. Beyond a few steps, every coefficient you carefully estimated stops mattering.

That has a direct planning consequence. Before building a multivariate model, ask how far ahead you need to forecast. If the answer is "six months" and your series are mean-reverting, the honest answer may be the historical average with a wide interval, and no model will improve on it.

Practical checks before you build

python
# 1. Are the levels spuriously related? Compare CHANGES.
print(df.corr().round(3))
print(df.diff().corr().round(3))

# 2. Is each series stationary? VAR requires it.
from statsmodels.tsa.stattools import adfuller
for c in df:
    print(c, round(adfuller(df[c])[1], 4))

# 3. Does one help predict the other, beyond its own past?
from statsmodels.tsa.stattools import grangercausalitytests
grangercausalitytests(df[["sales", "ads"]], maxlag=3)

Check one first, always. If the level correlation is high and the change correlation is near zero, the relationship is an artefact of both series drifting.

Check two is a hard requirement for VAR. Difference any non-stationary series before fitting. The exception is cointegration — two series that drift together and never separate. Differencing those destroys the long-run link, and a vector error correction model is the right tool.

Check three names itself badly. grangercausalitytests tests whether adding the past of one series significantly reduces the prediction error for another. Its null hypothesis is "it does not help", so a small p-value means it does. That is evidence about predictive usefulness, not about cause. A third variable driving both produces the same result.

Common mistakes

Feeding the same-day value of a helper. This is the leak from the top of this lesson, and it is the most common error in the entire field. Ask of every column: will this value exist at the moment the model runs?

Fitting VAR on non-stationary data. You get coefficients near one, huge standard errors, and forecasts that wander off. Difference first, or use VECM for cointegrated series.

Too many series. A VAR with n series and p lags estimates n * (n*p + 1) coefficients. Five series and four lags is 105 coefficients. With three years of monthly data you have 36 rows. Use fewer series, fewer lags, or a regularised alternative.

Forgetting the helper needs its own forecast. Budget for it. A demand model that depends on a temperature forecast has two failure modes, not one.

Reading Granger causality as causality. Say "helps predict". Colleagues will quote you.

Ignoring alignment when merging. Two datasets with different timezones, different day boundaries or different holiday calendars will silently misalign by one row, which destroys the relationship you are trying to find. Merge on a datetime index and check the row count before and after.

Try it yourself

In the first script, change the weather forecast error from rng.normal(0, 1.5, n) to rng.normal(0, 0.5, n), representing a much better weather service.

Predict where the MAE lands between 119.5 and 265.5 before you run it. Then try rng.normal(0, 4.0, n), a poor forecast, and find the error level at which the temperature column stops being worth having at all. That crossing point is the specification you should hand to whoever supplies your helper data.

What to learn next

Researcher — Mathematics and papers.

The VAR model

A VAR($p$) for a $K$-dimensional vector $\mathbf{y}_t$ is

$$ \mathbf{y}_t = \mathbf{c} + A_1 \mathbf{y}_{t-1} + \dots + A_p \mathbf{y}_{t-p} + \mathbf{u}_t, \qquad \mathbf{u}_t \sim \mathrm{WN}(\mathbf{0}, \Sigma_u) $$

  • $\mathbf{y}_t \in \mathbb{R}^K$ — all $K$ series stacked at time $t$.
  • $A_i \in \mathbb{R}^{K \times K}$ — the coefficient matrix at lag $i$.
  • $\Sigma_u$ — the contemporaneous covariance of the innovations, generally not diagonal.

Stability requires all roots of $\det(I_K - A_1 z - \dots - A_p z^p) = 0$ to lie outside the unit circle. Under stability the process has a Wold representation $\mathbf{y}t = \boldsymbol{\mu} + \sum{i=0}^{\infty}\Phi_i \mathbf{u}{t-i}$ and the $h$-step forecast satisfies $\hat{\mathbf{y}}{T+h|T} \to \boldsymbol{\mu}$ as $h\to\infty$. That limit is exactly what the developer output demonstrates numerically.

Because every equation has identical regressors, equation-by-equation OLS is efficient — seemingly-unrelated-regressions machinery buys nothing here. Parameter count is $K(Kp+1)$, growing quadratically in $K$, which is the binding constraint in practice.

Order selection and the curse of dimensionality

Lag order is chosen by AIC, HQ, SC/BIC or FPE. AIC over-selects asymptotically but often forecasts better; BIC is consistent. With $K=5$ and monthly data, moving from $p=2$ to $p=4$ adds 50 coefficients per equation.

Standard remedies:

  • Bayesian VAR with a Minnesota prior (Litterman, 1986; Doan, Litterman and Sims, 1984), which shrinks each equation toward a random walk with prior variance decaying in lag length. Bańbura, Giannone and Reichlin (2010) show large BVARs with appropriately tightened shrinkage forecast well with over 100 variables.
  • Factor models. Extract a few common factors by principal components, then run a VAR on the factors. Stock and Watson (2002) established the diffusion-index approach.
  • Reduced-rank and sparse VARs, imposing structure on ${A_i}$ directly.

Cointegration and the VECM

If each component is $I(1)$ but some linear combination $\boldsymbol{\beta}'\mathbf{y}_t$ is $I(0)$, the series are cointegrated. Differencing then discards the long-run relationship. The correct form is the vector error correction model

$$ \Delta \mathbf{y}t = \boldsymbol{\alpha}\boldsymbol{\beta}'\mathbf{y}{t-1} + \sum_{i=1}^{p-1}\Gamma_i \Delta\mathbf{y}_{t-i} + \mathbf{u}_t $$

  • $\boldsymbol{\beta} \in \mathbb{R}^{K \times r}$ — the cointegrating vectors; $r$ is the cointegration rank.
  • $\boldsymbol{\alpha} \in \mathbb{R}^{K \times r}$ — the adjustment speeds toward equilibrium.

Johansen's trace and maximum-eigenvalue tests determine $r$ from the eigenvalues of a reduced-rank regression. statsmodels.tsa.vector_ar.vecm implements both the test and the estimator. Granger's representation theorem establishes the equivalence between cointegration and the existence of an error-correction form.

Granger causality, stated correctly

$x$ Granger-causes $y$ if

$$ \mathrm{MSE}!\left(\mathbb{E}[y_{t+1} \mid \mathcal{F}t]\right) < \mathrm{MSE}!\left(\mathbb{E}[y{t+1} \mid \mathcal{F}t \setminus {x{1:t}}]\right) $$

The test is an $F$ or Wald test of the joint restriction that the lag coefficients on $x$ are zero in the $y$ equation. Three conditions are required and routinely violated:

  1. Both series stationary. On $I(1)$ data the test over-rejects severely. Toda and Yamamoto (1995) give a lag-augmentation procedure that is valid regardless of the integration order.
  2. No omitted common driver. If $z$ drives both with different lags, $x$ will Granger-cause $y$ with no causal link between them.
  3. Sampling frequency at least as fine as the causal mechanism. Aggregating a daily mechanism to monthly data can reverse the apparent direction. Marcellino (1999) analyses temporal aggregation effects.

Granger himself framed the concept as predictive precedence, not intervention. Causal claims need the interventional machinery of Pearl (2009) or a design that supports it.

Structural identification

The reduced-form innovations $\mathbf{u}_t$ are correlated across equations, so an impulse response to "a shock in series 1" is not defined without further assumptions. A structural VAR writes $\mathbf{u}_t = B\boldsymbol{\varepsilon}_t$ with $\boldsymbol{\varepsilon}_t$ orthogonal, and $B$ must be identified. Common schemes:

  • Cholesky (recursive) ordering. Identification comes entirely from the assumed ordering, and results can change materially when it is permuted. Report robustness across orderings or the analysis is uninformative.
  • Long-run restrictions (Blanchard and Quah, 1989).
  • Sign restrictions (Uhlig, 2005), which give set rather than point identification.
  • External instruments / proxy SVAR (Stock and Watson, 2012; Mertens and Ravn, 2013).

Sims (1980), Macroeconomics and Reality, is the paper that introduced VARs as an alternative to incredible identifying restrictions in large structural models, and the identification debate has run ever since.

Modern alternatives

Global deep models handle the many-series case where classical VARs cannot. DeepAR (Salinas et al., 2020) conditions on covariates and pools across series. Temporal Fusion Transformers (Lim et al., 2021) separate static covariates, known-future inputs and observed-past inputs explicitly — a taxonomy worth adopting regardless of model, because it forces the availability question this lesson opens with. Gradient-boosted trees over engineered cross-series features remain the pragmatic default, as covered in feature engineering for time series.

The availability constraint is model-independent. No architecture removes the need to know a covariate's future value, and the developer block's 265.5 against 119.5 is the general shape of that cost.

Reading

  • Lütkepohl (2005), New Introduction to Multiple Time Series Analysis, Springer — the standard reference for VAR and VECM.
  • Sims (1980), Macroeconomics and Reality, Econometrica 48(1).
  • Granger (1969), Investigating causal relations by econometric models and cross-spectral methods, Econometrica 37(3).
  • Johansen (1991), Estimation and hypothesis testing of cointegration vectors in Gaussian vector autoregressive models, Econometrica 59(6).
  • Toda and Yamamoto (1995), Statistical inference in vector autoregressions with possibly integrated processes, Journal of Econometrics 66.
  • Bańbura, Giannone and Reichlin (2010), Large Bayesian vector auto regressions, Journal of Applied Econometrics 25(1).
  • Lim, Arık, Loeff and Pfister (2021), Temporal Fusion Transformers, IJF 37(4) — arxiv.org/abs/1912.09363.

What to learn next

What to learn next

These follow on from what you just read.

  • Recommender Systems

    What is a recommender system?

    A recommender system watches what people do and puts a short, personal shortlist in front of each one, because nobody can search a catalogue of a million items.

  • Recommender Systems

    Collaborative filtering

    Collaborative filtering recommends things by finding people who behaved like you and looking at what they liked next, without knowing anything about the items themselves.

  • Recommender Systems

    Content-based filtering

    Content-based filtering recommends items that look like the ones you already liked, using the item's own description instead of other people's behaviour.