Le modèle, formule par formule
Chaque chiffre du site sort des formules ci-dessous. Les coefficients sont réestimés chaque jour ; ceux affichés ici sont ceux du modèle qui a produit l’estimation du 19 octobre 2026, entraîné sur 169 mois (mars 2012 à août 2026, hors mars à juillet 2020).
1. La variable à estimer
La variation mensuelle de l’indice de la production industrielle (IPI, ensemble de l’industrie, corrigé des variations saisonnières et des jours ouvrables), en pour cent, calculée en logarithme :
2. Corriger l’électricité de la météo
La consommation d’électricité dépend d’abord de la température. Une régression sur les 5 405 jours d’historique isole cet effet. Pour chaque jour d, on explique le logarithme de la consommation moyenne par le besoin de chauffage, le besoin de climatisation, le jour de la semaine w(d) et le mois de l’année m(d) :
avec T la température moyenne du jour, pondérée par la population de 8 grandes villes :
Coefficients estimés : la consommation augmente de 2,8 % par degré sous 15 °C et de 0,9 % par degré au-dessus de 20 °C. Ce qui reste, le résidu, mesure l’activité. On en fait la moyenne sur le mois, puis la variation d’un mois à l’autre :
3. Les huit variables explicatives
Chaque variable n’est utilisée qu’à partir de sa date de publication réelle. L’estimation du mois t est faite au début du mois t+1.
| Symbole | Variable | Connue jusqu’à | Moyenne μ | Écart-type σ | Poids w |
|---|---|---|---|---|---|
| Dernière croissance publiée de la production | 0,13 | 1,61 | -0,061 | ||
| Électricité corrigée, variation du mois | 0,00 | 1,75 | +0,248 | ||
| Électricité corrigée, variation du mois précédent | 0,01 | 1,76 | -0,145 | ||
| Climat des affaires, écart à 100 | 0,59 | 7,30 | +0,010 | ||
| Climat des affaires, variation | 0,12 | 2,52 | -0,037 | ||
| Ouvertures de procédures collectives, sur un an (%) | 2,29 | 21,45 | -0,088 | ||
| Écart de taux France–Allemagne (pb) | 53,67 | 20,60 | -0,022 | ||
| Écart de taux, variation (pb) | -0,40 | 6,30 | -0,009 |
4. La régression ridge
Chaque variable est d’abord centrée et réduite, pour que les poids soient comparables :
L’estimation est une somme pondérée :
Les poids minimisent l’erreur au carré sur les mois d’apprentissage, plus une pénalité qui les rapproche de zéro et limite le surapprentissage :
La pénalité α est choisie parmi 30 valeurs de 0,01 à 1 000 par validation croisée « leave-one-out ». Valeur retenue : α = 92,4. La constante b₀ vaut +0,045 : c’est la croissance mensuelle moyenne de l’apprentissage.
5. Le calcul complet pour septembre 2026
Voici l’estimation reconstituée variable par variable. La somme de la constante et des contributions donne exactement le chiffre publié ; le programme le vérifie à chaque mise à jour.
Ce qui pousse l’estimation de septembre 2026
Contribution de chaque variable, en points de pourcentage. Vers la droite : vers le haut.
- Pousse vers le haut
- Pousse vers le bas
Afficher les données
| Variable | Contribution w × z |
|---|---|
| Dernière croissance publiée de la production | +0,027 |
| Électricité corrigée, variation du mois | -0,820 |
| Électricité corrigée, variation du mois précédent | -0,091 |
| Climat des affaires, écart à 100 | -0,006 |
| Climat des affaires, variation | +0,022 |
| Ouvertures de procédures collectives, sur un an (%) | +0,019 |
| Écart de taux France–Allemagne (pb) | -0,030 |
| Écart de taux, variation (pb) | -0,005 |
| Variable | Valeur x | z = (x − μ) / σ | Poids w | Contribution w × z |
|---|---|---|---|---|
| Dernière croissance publiée de la production | -0,58 | -0,44 | -0,061 | +0,027 |
| Électricité corrigée, variation du mois | -5,77 | -3,30 | +0,248 | -0,820 |
| Électricité corrigée, variation du mois précédent | 1,11 | +0,62 | -0,145 | -0,091 |
| Climat des affaires, écart à 100 | -3,60 | -0,57 | +0,010 | -0,006 |
| Climat des affaires, variation | -1,40 | -0,60 | -0,037 | +0,022 |
| Ouvertures de procédures collectives, sur un an (%) | -2,28 | -0,21 | -0,088 | +0,019 |
| Écart de taux France–Allemagne (pb) | 81,50 | +1,35 | -0,022 | -0,030 |
| Écart de taux, variation (pb) | 3,50 | +0,62 | -0,009 | -0,005 |
| Constante b₀ | +0,045 | |||
| Estimation | -0,839 = -0,84 % |
6. L’intervalle d’incertitude
L’intervalle à 80 % vient des erreurs passées : 80 % des erreurs du test, hors crise sanitaire, étaient inférieures à q₈₀ = 1,68 point.
Pour le niveau de l’indice deux mois après la dernière publication, l’intervalle est élargi d’un facteur √2, en supposant les erreurs mensuelles indépendantes.
7. La référence et l’évaluation
La prévision naïve prolonge la dernière croissance publiée. Ses coefficients du jour :
Les deux méthodes sont comparées par l’erreur quadratique moyenne et le test de Diebold-Mariano, qui vérifie si l’écart de précision dépasse le hasard :
Résultat : RMSE de 1,26 contre 1,32, p = 0,07.
Lire la méthode et le bilan complet
8. Pourquoi ce modèle : les variantes testées
Deux améliorations de la correction météo ont été testées avec exactement le même protocole que le modèle publié : chaque mois depuis 2016, réentraînement sur les seules données disponibles à cette date.
- Saisons récentes : le profil saisonnier de chaque année est estimé sur les trois années qui la précèdent, au lieu de tout l’historique. Il suit mieux les changements d’habitudes, comme ceux de l’après-crise énergétique.
- Jours fériés : une variable signale les onze jours fériés, où la consommation baisse comme un dimanche.
| Variante | Erreur quadratique moyenne | Bon sens d’évolution | Erreur, 24 derniers mois | Biais de septembre depuis 2022 |
|---|---|---|---|---|
| Modèle publié | 1,255 | 60 % | 0,938 | -0,55 |
| Saisons récentes | 1,292 | 52 % | 0,915 | 0,00 |
| Jours fériés | 1,261 | 57 % | 0,928 | -0,54 |
| Les deux | 1,297 | 52 % | 0,918 | +0,03 |
Pour comparaison, la prévision naïve a une erreur de 0,926 sur les 24 derniers mois.
Le compromis
Les saisons récentes corrigent le biais de septembre et font mieux sur les deux dernières années. Mais sur toute la période, elles se trompent davantage et trouvent moins souvent le bon sens d’évolution : estimer chaque année sur trois ans seulement rend le profil saisonnier plus instable. Les jours fériés, eux, ne changent presque rien.
La règle de décision
Une variante ne remplace le modèle publié que si elle fait mieux sur toute la période de test. Choisir une variante parce qu’elle brille sur les derniers mois reviendrait à choisir avec le recul : 24 mois sont trop peu pour distinguer un vrai progrès du hasard. Le modèle publié reste donc en place, et son biais de septembre est signalé sur la page Méthode.
Aujourd’hui, le modèle publié reste le plus précis sur toute la période. Cette comparaison est recalculée à chaque mise à jour. Elle sera réexaminée quand le bilan en temps réel couvrira douze mois, ou plus tôt si une variante prend l’avantage sur toute la période.
9. Le code
Extraits du code Python qui tourne chaque jour, lus directement dans le dépôt au moment de la construction du site.
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"],
}