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 :

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

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

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

avec T la température moyenne du jour, pondérée par la population de 8 grandes villes :

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

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 :

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. 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.

Δ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)
SymboleVariableConnue jusqu’àMoyenne μÉcart-type σPoids w
yt−2y_{t-2}Dernière croissance publiée de la productiont−2t-20,131,61-0,061
Δet\Delta e_tÉlectricité corrigée, variation du moistt0,001,75+0,248
Δet−1\Delta e_{t-1}Électricité corrigée, variation du mois précédenttt0,011,76-0,145
ct−100c_t - 100Climat des affaires, écart à 100tt0,597,30+0,010
Δct\Delta c_tClimat des affaires, variationtt0,122,52-0,037
Δ12 nt\Delta_{12}\, n_tOuvertures de procédures collectives, sur un an (%)tt2,2921,45-0,088
st−1s_{t-1}Écart de taux France–Allemagne (pb)t−1t-153,6720,60-0,022
Δst−1\Delta s_{t-1}Écart de taux, variation (pb)t−1t-1-0,406,30-0,009

4. La régression ridge

Chaque variable est d’abord centrée et réduite, pour que les poids soient comparables :

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

L’estimation est une somme pondérée :

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

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 :

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

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
VariableContribution 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
VariableValeur xz = (x − μ) / σPoids wContribution 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édent1,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 %
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. 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.

[ 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

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 :

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}

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 :

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

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.
Variantes comparées, hors crise sanitaire (recalculé chaque jour)
VarianteErreur quadratique moyenneBon sens d’évolutionErreur, 24 derniers moisBiais de septembre depuis 2022
Modèle publié1,25560 %0,938-0,55
Saisons récentes1,29252 %0,9150,00
Jours fériés1,26157 %0,928-0,54
Les deux1,29752 %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.

Correction météo de l’électricité (étape 2) Voir features.py sur 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
Les trois modèles comparés (étapes 4 et 7) Voir model.py sur 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)),
}
Les mois utilisés pour l’apprentissage (étape 3) Voir model.py sur 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])
Le calcul détaillé de l’étape 5 Voir model.py sur 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"],
    }