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:
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):
where T is the day’s mean temperature, weighted by the population of 8 large cities:
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:
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.
| Symbol | Input | Known up to | Mean μ | Std. dev. σ | Weight w |
|---|---|---|---|---|---|
| Latest published production growth | 0.13 | 1.61 | -0.061 | ||
| Corrected electricity, change this month | 0.00 | 1.75 | +0.248 | ||
| Corrected electricity, change last month | 0.01 | 1.76 | -0.145 | ||
| Business climate, gap to 100 | 0.59 | 7.30 | +0.010 | ||
| Business climate, change | 0.12 | 2.52 | -0.037 | ||
| Insolvency openings, year on year (%) | 2.29 | 21.45 | -0.088 | ||
| France–Germany yield spread (bp) | 53.67 | 20.60 | -0.022 | ||
| Yield spread, change (bp) | -0.40 | 6.30 | -0.009 |
4. Ridge regression
Each input is first standardised, so the weights are comparable:
The estimate is a weighted sum:
The weights minimise the squared error over the training months, plus a penalty that pulls them towards zero and limits overfitting:
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
| Input | Contribution 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 |
| Input | Value x | z = (x − μ) / σ | Weight w | Contribution 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 month | 1.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% |
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.
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:
Both methods are compared by root mean squared error and the Diebold–Mariano test, which checks whether the accuracy gap is larger than chance:
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.
| Variant | Root mean squared error | Right direction | Error, last 24 months | September bias since 2022 |
|---|---|---|---|---|
| Published model | 1.255 | 60% | 0.938 | -0.55 |
| Recent seasons | 1.292 | 52% | 0.915 | 0.00 |
| Public holidays | 1.261 | 57% | 0.928 | -0.54 |
| Both | 1.297 | 52% | 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.
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 outRIDGE_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)),
}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])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"],
}