The model, formula by formula

Every number on this site comes from the formulas below. The coefficients are re-estimated every day; the ones shown here belong to the model that produced the estimate of 19 October 2026, trained on 169 months (March 2012 to August 2026, excluding March to July 2020).

1. What is estimated

The monthly change in the industrial production index (IPI, total industry, seasonally and working-day adjusted), in percent, computed as a log difference:

yt=100 ln⁡IPItIPIt−1y_t = 100 \,\ln\frac{\mathrm{IPI}_t}{\mathrm{IPI}_{t-1}}

2. Correcting electricity for the weather

Electricity use depends first of all on temperature. A regression on 5,405 days of history isolates that effect. For each day d, the log of mean consumption is explained by heating need, cooling need, the day of the week w(d) and the month of the year m(d):

ln⁡ Cd=β0+β1 HDDd+β2 CDDd+γw(d)+δm(d)+εd\ln\, C_d = \beta_0 + \beta_1\,\mathrm{HDD}_d + \beta_2\,\mathrm{CDD}_d + \gamma_{w(d)} + \delta_{m(d)} + \varepsilon_d

where T is the day’s mean temperature, weighted by the population of 8 large cities:

HDDd=max⁡(0, 15−Td),CDDd=max⁡(0, Td−20)\mathrm{HDD}_d = \max(0,\ 15 - T_d), \qquad \mathrm{CDD}_d = \max(0,\ T_d - 20)
β^1=0.0274,β^2=0.0089\hat\beta_1 = 0.0274, \qquad \hat\beta_2 = 0.0089

Estimated coefficients: consumption rises 2.8% per degree below 15 °C and 0.9% per degree above 20 °C. What remains, the residual, measures activity. It is averaged over the month, then compared with the month before:

et=1∣Dt∣∑d∈Dtε^d,Δet=100 (et−et−1)e_t = \frac{1}{|D_t|}\sum_{d \in D_t} \hat\varepsilon_d, \qquad \Delta e_t = 100\,(e_t - e_{t-1})

3. The eight inputs

Each input is used only from its actual publication date. The estimate for month t is made at the start of month t+1.

Δ12 nt=100 ln⁡NtNt−12,st=100 (rtFR−rtDE)\Delta_{12}\, n_t = 100 \,\ln\frac{N_t}{N_{t-12}}, \qquad s_t = 100\,\big(r^{\mathrm{FR}}_t - r^{\mathrm{DE}}_t\big)
SymbolInputKnown up toMean μStd. dev. σWeight w
yt−2y_{t-2}Latest published production growtht−2t-20.131.61-0.061
Δet\Delta e_tCorrected electricity, change this monthtt0.001.75+0.248
Δet−1\Delta e_{t-1}Corrected electricity, change last monthtt0.011.76-0.145
ct−100c_t - 100Business climate, gap to 100tt0.597.30+0.010
Δct\Delta c_tBusiness climate, changett0.122.52-0.037
Δ12 nt\Delta_{12}\, n_tInsolvency openings, year on year (%)tt2.2921.45-0.088
st−1s_{t-1}France–Germany yield spread (bp)t−1t-153.6720.60-0.022
Δst−1\Delta s_{t-1}Yield spread, change (bp)t−1t-1-0.406.30-0.009

4. Ridge regression

Each input is first standardised, so the weights are comparable:

zj,t=xj,t−μjσjz_{j,t} = \frac{x_{j,t} - \mu_j}{\sigma_j}

The estimate is a weighted sum:

y^t=b0+∑j=18wj zj,t\hat y_t = b_0 + \sum_{j=1}^{8} w_j\, z_{j,t}

The weights minimise the squared error over the training months, plus a penalty that pulls them towards zero and limits overfitting:

min⁡b0, w ∑t∈T(yt−y^t)2+α∑j=18wj2\min_{b_0,\, w}\ \sum_{t \in \mathcal{T}} \big(y_t - \hat y_t\big)^2 + \alpha \sum_{j=1}^{8} w_j^2

The penalty α is chosen among 30 values from 0.01 to 1,000 by leave-one-out cross-validation. Chosen value: α = 92.4. The constant b₀ is +0.045: the average monthly growth over the training months.

5. The full calculation for September 2026

Here is the estimate rebuilt input by input. The constant plus the contributions gives exactly the published figure; the program checks this at every update.

What drives the September 2026 estimate

Contribution of each input, in percentage points. Right means up.

  • Pushes up
  • Pushes down
Show the data
InputContribution w × z
Latest published production growth+0.027
Corrected electricity, change this month-0.820
Corrected electricity, change last month-0.091
Business climate, gap to 100-0.006
Business climate, change+0.022
Insolvency openings, year on year (%)+0.019
France–Germany yield spread (bp)-0.030
Yield spread, change (bp)-0.005
InputValue xz = (x − μ) / σWeight wContribution w × z
Latest published production growth-0.58-0.44-0.061+0.027
Corrected electricity, change this month-5.77-3.30+0.248-0.820
Corrected electricity, change last month1.11+0.62-0.145-0.091
Business climate, gap to 100-3.60-0.57+0.010-0.006
Business climate, change-1.40-0.60-0.037+0.022
Insolvency openings, year on year (%)-2.28-0.21-0.088+0.019
France–Germany yield spread (bp)81.50+1.35-0.022-0.030
Yield spread, change (bp)3.50+0.62-0.009-0.005
Constant b₀+0.045
Estimate-0.839 = -0.84%
y^=0.045+0.027−0.820−0.091−0.006+0.022+0.019−0.030−0.005=−0.839\hat y = 0.045 + 0.027 - 0.820 - 0.091 - 0.006 + 0.022 + 0.019 - 0.030 - 0.005 = {-0.839}

6. The uncertainty range

The 80% range comes from past errors: 80% of the test errors, excluding the pandemic, were smaller than q₈₀ = 1.68 points.

[ y^t−q80, y^t+q80 ],q80=Q0.8( ∣yt−y^t∣ )=1.68\big[\,\hat y_t - q_{80},\ \hat y_t + q_{80}\,\big], \qquad q_{80} = Q_{0.8}\big(\,|y_t - \hat y_t|\,\big) = 1.68

For the index level two months after the last release, the range is widened by a factor of √2, assuming monthly errors are independent.

7. The benchmark and the evaluation

The naive forecast carries forward the latest published growth. Its coefficients today:

y^t AR=a+b yt−2=0.051−0.044 yt−2\hat y^{\,\mathrm{AR}}_t = a + b\, y_{t-2} = 0.051 - 0.044\, y_{t-2}

Both methods are compared by root mean squared error and the Diebold–Mariano test, which checks whether the accuracy gap is larger than chance:

RMSE=1n∑t(yt−y^t)2\mathrm{RMSE} = \sqrt{\frac{1}{n}\sum_{t} \big(y_t - \hat y_t\big)^2}
dt=(etAR)2−et 2,DM=dˉsd/n,p=1−Φ(DM)d_t = \big(e^{\mathrm{AR}}_t\big)^2 - e_t^{\,2}, \qquad \mathrm{DM} = \frac{\bar d}{s_d / \sqrt{n}}, \qquad p = 1 - \Phi(\mathrm{DM})

Result: RMSE 1.26 against 1.32, p = 0.07.

Read the method and full track record

8. Why this model: the variants tested

Two improvements to the weather correction were tested with exactly the same protocol as the published model: every month since 2016, retrained only on the data available at that date.

  • Recent seasons: each year’s seasonal pattern is estimated from the three years up to it, instead of the whole history. It follows changes in habits better, such as those after the energy crisis.
  • Public holidays: an input flags the eleven French public holidays, when consumption drops as on a Sunday.
Variants compared, excluding the pandemic (recomputed every day)
VariantRoot mean squared errorRight directionError, last 24 monthsSeptember bias since 2022
Published model1.25560%0.938-0.55
Recent seasons1.29252%0.9150.00
Public holidays1.26157%0.928-0.54
Both1.29752%0.918+0.03

For comparison, the naive forecast’s error over the last 24 months is 0.926.

The trade-off

Recent seasons remove the September bias and do better over the last two years. But over the whole period they make larger errors and get the direction right less often: estimating each year from only three years makes the seasonal pattern less stable. Public holidays change almost nothing.

The decision rule

A variant replaces the published model only if it does better over the whole test period. Picking a variant because it shines over the latest months would be choosing with hindsight: 24 months are too few to tell real progress from chance. So the published model stays, and its September bias is flagged on the Method page.

Today, the published model is still the most accurate over the whole period. This comparison is recomputed at every update. It will be reviewed when the real-time track record covers twelve months, or sooner if a variant takes the lead over the whole period.

9. The code

Excerpts of the Python code that runs every day, read straight from the repository when the site is built.

Weather correction of electricity (step 2) View features.py on GitHub
def corrected_electricity(
    daily: pd.Series, temp: pd.Series, fit_until: pd.Timestamp, window_years: int | None = None, holidays: bool = False
) -> pd.Series:
    """Monthly mean of log consumption after removing temperature, weekday and calendar-month effects (D4).

    The coefficients are fitted only on days before `fit_until`, so a backtest never sees the future.
    The two options are the variants tested in D24 (not used by the published model):
    window_years: each calendar year's residuals come from a fit on the `window_years` years ending with it.
    holidays: add a French public-holiday dummy.
    """
    df = pd.DataFrame({"y": np.log(daily)}).join(temp.rename("t"), how="inner")
    idx = df.index
    X = pd.concat([
        pd.DataFrame({
            "const": 1.0,
            "hdd": (HDD_BASE - df["t"]).clip(lower=0),
            "cdd": (df["t"] - CDD_BASE).clip(lower=0),
        }, index=idx),
        pd.get_dummies(idx.dayofweek, prefix="dow", drop_first=True, dtype=float).set_index(idx),
        pd.get_dummies(idx.month, prefix="m", drop_first=True, dtype=float).set_index(idx),
        *([pd.DataFrame({"holiday": idx.isin(french_holidays(set(idx.year))).astype(float)}, index=idx)] if holidays else []),
    ], axis=1)
    fit = idx < fit_until
    if window_years is None:
        beta, *_ = np.linalg.lstsq(X[fit].to_numpy(), df["y"][fit].to_numpy(), rcond=None)
        resid = df["y"] - X.to_numpy() @ beta
    else:
        resid = pd.Series(np.nan, index=idx)
        for y in sorted(set(idx.year)):
            window = fit & (idx.year > y - window_years) & (idx.year <= y)
            if window.sum() < 200:  # too few days in the first years: use everything known so far
                window = fit & (idx.year <= y)
            beta, *_ = np.linalg.lstsq(X[window].to_numpy(), df["y"][window].to_numpy(), rcond=None)
            year = idx.year == y
            resid[year] = df["y"][year] - X[year].to_numpy() @ beta
    month = resid.groupby(idx.to_period("M"))
    out = month.mean()[month.count() >= MIN_DAYS]
    out.attrs["weather"] = {"hdd": float(beta[1]), "cdd": float(beta[2]), "days": int(fit.sum())}  # shown on the model page
    return out
The three models compared (steps 4 and 7) View model.py on GitHub
RIDGE_ALPHAS = np.logspace(-2, 3, 30)  # ridge penalties tried; RidgeCV picks one by leave-one-out error
MODELS = {
    "ar": (["y_lag2"], LinearRegression),
    "ridge": (FEATURES, lambda: make_pipeline(StandardScaler(), RidgeCV(alphas=RIDGE_ALPHAS))),
    "gbm": (FEATURES, lambda: HistGradientBoostingRegressor(max_depth=3, learning_rate=0.05, max_iter=200, random_state=0)),
}
Which months are used for training (step 3) View model.py on GitHub
def training_rows(f: pd.DataFrame, t: pd.Period) -> pd.DataFrame:
    """Months whose IPI was already published when nowcasting t (up to t-2), with complete features.

    COVID months are left out of training (still scored in the backtest): a 25% collapse and
    rebound teaches the models relationships that do not hold in normal months.
    """
    return f.loc[: t - 2].drop(COVID, errors="ignore").dropna(subset=["y", *FEATURES])
The step 5 breakdown View model.py on GitHub
def explain(raw) -> dict:
    """The published ridge model taken apart: coefficients, and each pending month's calculation step by step.

    prediction = intercept + sum_j coef_j * (x_j - mean_j) / scale_j. The assert proves the
    decomposition shown on the website is exactly what the model computes.
    """
    f, train, pending = final_sets(raw)
    model = MODELS["ridge"][1]().fit(train[FEATURES], train["y"])
    scaler, ridge = model[0], model[-1]
    z = (pending[FEATURES] - scaler.mean_) / scaler.scale_
    contrib = z * ridge.coef_
    pred = ridge.intercept_ + contrib.sum(axis=1)
    assert np.allclose(pred, model.predict(pending[FEATURES])), "decomposition differs from the model"
    ar = LinearRegression().fit(train[["y_lag2"]], train["y"])
    return {
        "alpha": float(ridge.alpha_),
        "alphas": [float(RIDGE_ALPHAS[0]), float(RIDGE_ALPHAS[-1]), len(RIDGE_ALPHAS)],
        "intercept": float(ridge.intercept_),
        "train": {"n": len(train), "from": str(train.index.min()), "to": str(train.index.max())},
        "features": [
            {"name": c, "mean": float(m), "scale": float(s), "coef": float(w)}
            for c, m, s, w in zip(FEATURES, scaler.mean_, scaler.scale_, ridge.coef_)
        ],
        "months": [
            {"m": str(t), "x": pending.loc[t, FEATURES].astype(float).tolist(), "z": z.loc[t].tolist(),
             "contrib": contrib.loc[t].tolist(), "pred": float(pred[t])}
            for t in pending.index
        ],
        "ar": {"a": float(ar.intercept_), "b": float(ar.coef_[0])},
        "weather": f.attrs["weather"],
    }