Prévoir les ventes de la semaine prochaine
Savoir combien je vendrai la semaine prochaine, à partir de mon propre historique de ventes.
Pas besoin d’IA générative
négligeableInstantanéVos données ne sortent pas
Le verdict
N1 gagne sur une croissance régulière, et perd sur tout le reste. Les tests de la fiche donnent les trois nombres : sur une croissance régulière, vérité 1 464, N0 prévoit 1 254 (−14 %) et N1 1 482 (+1 %) ; sur un concurrent qui ouvre, vérité 1 025, N0 prévoit 995 (−3 %) et N1 1 310 (+28 %) ; sur une semaine de Noël, vérité 2 204, N0 prévoit 2 356 (+7 %) et N1 1 527 (−31 %). Choisissez par la mesure, pas par le niveau : gardez les dernières semaines à l’écart de l’ajustement et comparez-y les deux. Et montez à N1 aussi pour le coefficient de croissance, qu’on montre au commerçant avant la prévision — à condition de le regarder : sur le commerce qui recule, il annonce encore une croissance. Sur une série qui change de niveau, l’outil d’un prévisionniste est un lissage exponentiel à tendance et saisonnalité, et non des moindres carrés : mesuré ici, `ExponentialSmoothing` de statsmodels se trompe de 1 % sur les deux séries. Il n’est pas un niveau de cette fiche parce que ces niveaux s’écrivent dans les deux langages, et qu’il n’a pas d’équivalent en JavaScript ; c’est la première chose à essayer après cette page. Aucun des deux niveaux ne rend d’intervalle : celui qui commande le stock a besoin d’un ordre de grandeur de l’erreur autant que du point.
Moindres carrés sur variables calendaires : tendance et harmoniques
Pas besoin d’IA générative
Modèle classique légerN1négligeableInstantané
Python
"""
Prévoir les ventes hebdomadaires avec un modèle linéaire sur des variables
calendaires.
Niveau N1. La moyenne mobile de N0 n'a aucune notion de tendance : elle ne
sait que répéter le passé proche, ce qui est précisément ce qu'elle rate
quand l'activité grandit ou recule. Ici, le niveau, la pente et la forme
saisonnière sont estimés ensemble, par moindres carrés, sur l'historique
entier.
Les variables sont de l'arithmétique de calendrier et rien d'autre : une
constante, le nombre de cycles écoulés, et quelques paires de sinus et de
cosinus dont la période est la saison. Deux paires suffisent à placer un pic
de Noël et un creux d'été ; un pic large de quelques semaines seulement
demande plus de paires pour atteindre toute sa hauteur.
Compter la tendance en cycles plutôt qu'en semaines n'est pas cosmétique.
Cela garde les colonnes de la matrice de conception à des échelles
comparables, et cela rend le coefficient lisible à lui seul : c'est la
croissance par cycle, donc par an avec la saison de 52 semaines par défaut.
Moins d'un cycle complet d'historique ne sépare pas la tendance de la
saison : il est refusé.
La saison n'a pas à être un nombre entier de semaines : c'est de la
trigonométrie, rien n'est indexé par semaine. Une année fait 365,25 / 7 =
52,18 semaines, et le calendrier ISO en compte cinquante-trois tous les cinq
ou six ans, si bien qu'un pic de Noël glisse d'environ une semaine sur trois
ans. Passez 365,25 / 7 pour le tenir en place — ce que recommande
Forecasting: Principles and Practice pour des données hebdomadaires, et ce
que N0, qui indexe par « semaine modulo saison », ne peut pas faire.
"""
import math
import numpy as np
def calendar_features(week: int, season_length: int, harmonics: int) -> list[float]:
"""The row of the design matrix for one week. This is the whole model."""
row = [1.0, week / season_length]
for k in range(1, harmonics + 1):
angle = 2 * math.pi * k * week / season_length
row += [math.sin(angle), math.cos(angle)]
return row
def fit(history: list[float], season_length: int = 52, harmonics: int = 2) -> dict:
"""
Least squares over the whole history. Returns a model you can inspect.
`coefficients[1]` is the growth per cycle, in the unit of the series. That
number is worth reading before any forecast is: a model that has found a
trend nobody in the company recognises is a model to distrust.
"""
if len(history) < 2 + 2 * harmonics:
raise ValueError("fewer weeks of history than features to estimate")
if len(history) < season_length:
raise ValueError("a trend cannot be told apart from the season on less than one full cycle")
sales = np.array(history, dtype=float) # a missing week (None) becomes NaN here
if not np.all(np.isfinite(sales)):
raise ValueError("every week of history needs a finite number")
design = [calendar_features(week, season_length, harmonics) for week in range(len(history))]
coefficients, *_ = np.linalg.lstsq(np.array(design), sales, rcond=None)
return {
"coefficients": [float(c) for c in coefficients],
"season_length": season_length,
"harmonics": harmonics,
"start": len(history),
}
def forecast(model: dict, horizon: int = 1) -> list[float]:
"""Predict the `horizon` weeks that follow the history the model was fitted on."""
weeks = []
for step in range(horizon):
row = calendar_features(model["start"] + step, model["season_length"], model["harmonics"])
weeks.append(sum(c * x for c, x in zip(model["coefficients"], row)))
return weeksJavaScript
/**
* Prévoir les ventes hebdomadaires avec un modèle linéaire sur des variables
* calendaires.
*
* Niveau N1. La moyenne mobile de N0 n'a aucune notion de tendance : elle ne
* sait que répéter le passé proche, ce qui est précisément ce qu'elle rate
* quand l'activité grandit ou recule. Ici, le niveau, la pente et la forme
* saisonnière sont estimés ensemble, par moindres carrés, sur l'historique
* entier.
*
* Les variables sont de l'arithmétique de calendrier et rien d'autre : une
* constante, le nombre de cycles écoulés, et quelques paires de sinus et de
* cosinus dont la période est la saison.
*
* Les moindres carrés sont résolus par les équations normales et une
* élimination de Gauss, assez courte pour qu'aucune dépendance ne vaille la
* peine. Compter la tendance en cycles plutôt qu'en semaines garde ces équations bien
* conditionnées, et rend le coefficient lisible à lui seul : c'est la
* croissance par cycle, donc par an avec la saison de 52 semaines par défaut.
* Moins d'un cycle complet d'historique ne sépare pas la tendance de la
* saison : il est refusé.
*
* La saison n'a pas à être un nombre entier de semaines : c'est de la
* trigonométrie, rien n'est indexé par semaine. Une année fait 365,25 / 7 =
* 52,18 semaines, et le calendrier ISO en compte cinquante-trois tous les cinq
* ou six ans, si bien qu'un pic de Noël glisse d'environ une semaine sur trois
* ans. Passez 365,25 / 7 pour le tenir en place — ce que recommande
* Forecasting: Principles and Practice pour des données hebdomadaires, et ce
* que N0, qui indexe par « semaine modulo saison », ne peut pas faire.
*/
/** The row of the design matrix for one week. This is the whole model. */
export function calendarFeatures(week, seasonLength, harmonics) {
const row = [1, week / seasonLength];
for (let k = 1; k <= harmonics; k += 1) {
const angle = (2 * Math.PI * k * week) / seasonLength;
row.push(Math.sin(angle), Math.cos(angle));
}
return row;
}
/** Solve a symmetric system by Gaussian elimination with partial pivoting. */
function solve(matrix, vector) {
const size = vector.length;
const rows = matrix.map((row, i) => [...row, vector[i]]);
for (let column = 0; column < size; column += 1) {
let pivot = column;
for (let row = column + 1; row < size; row += 1) {
if (Math.abs(rows[row][column]) > Math.abs(rows[pivot][column])) pivot = row;
}
[rows[column], rows[pivot]] = [rows[pivot], rows[column]];
for (let row = 0; row < size; row += 1) {
if (row === column) continue;
const factor = rows[row][column] / rows[column][column];
for (let k = column; k <= size; k += 1) rows[row][k] -= factor * rows[column][k];
}
}
return rows.map((row, i) => row[size] / row[i]);
}
/**
* Least squares over the whole history. Returns a model you can inspect.
*
* `coefficients[1]` is the growth per cycle, in the unit of the series. That
* number is worth reading before any forecast is: a model that has found a
* trend nobody in the company recognises is a model to distrust.
*/
export function fit(history, { seasonLength = 52, harmonics = 2 } = {}) {
const width = 2 + 2 * harmonics;
if (history.length < width) throw new RangeError('fewer weeks of history than features to estimate');
if (history.length < seasonLength) throw new RangeError('a trend cannot be told apart from the season on less than one full cycle');
// A missing week must be refused: null would be multiplied as zero, NaN would spread.
if (!history.every(Number.isFinite)) throw new TypeError('every week of history needs a finite number');
const design = history.map((_, week) => calendarFeatures(week, seasonLength, harmonics));
// Normal equations: (Xᵀ X) b = Xᵀ y.
const square = Array.from({ length: width }, (_, i) => Array.from({ length: width }, (_, j) => (
design.reduce((total, row) => total + row[i] * row[j], 0)
)));
const target = Array.from({ length: width }, (_, i) => (
design.reduce((total, row, week) => total + row[i] * history[week], 0)
));
return { coefficients: solve(square, target), seasonLength, harmonics, start: history.length };
}
/** Predict the `horizon` weeks that follow the history the model was fitted on. */
export function forecast(model, horizon = 1) {
const weeks = [];
for (let step = 0; step < horizon; step += 1) {
const row = calendarFeatures(model.start + step, model.seasonLength, model.harmonics);
weeks.push(row.reduce((total, value, i) => total + value * model.coefficients[i], 0));
}
return weeks;
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le coefficient de croissance est le nombre à lire avant la prévision : un modèle qui a trouvé une tendance que personne dans la maison ne reconnaît est un modèle dont il faut se méfier.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
Ce qu’il renvoie
| Semaine | Ventes observées | Ventes prévues |
|---|---|---|
| il y a quatre semaines | 1171 | |
| il y a trois semaines | 1212 | |
| il y a deux semaines | 1215 | |
| la semaine dernière | 1239 | |
| la semaine prochaine | 1275 | |
| dans deux semaines | 1296 | |
| dans trois semaines | 1312 | |
| dans quatre semaines | 1325 |
104 semaines d’historique ajustées. Croissance lue dans la série : +207 pains par an.
Là, ça casse
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : l’ajustement se fait sur l’historique que vous détenez déjà, sans corpus supplémentaire à conserver
- Une série agrégée n’est pas une donnée personnelle ; ventilée par vendeur ou par client, elle en devient une, et entre dans votre registre des traitements si vous en tenez un
Point de rupture
La rupture de régime : les moindres carrés pèsent une semaine d’il y a trois ans exactement comme la semaine dernière. Dans le test, un concurrent ouvre, et N1 prévoit 1 310 là où le magasin encaisse 1 025 — 28 % de trop — quand la moyenne mobile de N0, qui ne regarde que les quatre dernières semaines, prévoit 995. Il lit même encore plus de 80 unités de croissance annuelle sur un commerce qui a perdu 30 %. Second angle mort : le pic étroit. Deux paires d’harmoniques ne dessinent pas une semaine de Noël, et sur une boulangerie dont la vérité vaut 2 204, N1 prévoit 1 527 quand N0 prévoit 2 356 — la semaine où la rupture de stock coûte le plus.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus dans cette fiche. Quand la droite décrit deux régimes au lieu d’un, ce n’est pas de famille de modèle qu’il faut changer mais d’historique : réajuster sur la période qui suit la rupture dès qu’elle couvre un cycle complet, puis relire le coefficient de croissance. Plus tôt, l’extrait refuse l’ajustement : sur vingt semaines, la tendance et la saison se confondent, et le coefficient de croissance ne se relit plus.
Les quatre options
- N0Coefficients saisonniers lus dans l’historique, puis moyenne mobileGratuit · Instantané
- RecommandéN1Moindres carrés sur variables calendaires : tendance et harmoniquesnégligeable · Instantané
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N0Coefficients saisonniers lus dans l’historique, puis moyenne mobileGratuit · Instantané
Coefficients saisonniers lus dans l’historique, puis moyenne mobile
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Prévoir les ventes hebdomadaires avec une moyenne mobile et des coefficients
saisonniers.
Niveau N0. Sans dépendance.
L'idée est la plus ancienne de la prévision : les ventes sont un niveau qui
bouge lentement, multiplié par une forme qui se répète chaque année. Estimez
la forme sur l'historique entier, divisez-la, faites la moyenne de ce qui
reste sur les dernières semaines, puis remettez la forme.
Ce que ceci ne modélise délibérément pas, c'est une tendance. Une moyenne
mobile ne regarde jamais qu'en arrière : elle suit un changement de régime au
lieu de l'anticiper. C'est la limite de ce niveau, et le test le dit tout
haut.
"""
import math
from statistics import fmean
def seasonal_coefficients(history: list[float], season_length: int) -> list[float]:
"""
One multiplier per position in the cycle, averaged over the history.
A coefficient of 1.2 for week 50 means that week 50 usually sells twenty
per cent above the year's level. The coefficients are rescaled to average
one, so putting the shape back neither inflates nor deflates the forecast.
"""
level = fmean(history)
if level == 0:
return [1.0] * season_length
raw = [fmean(history[phase::season_length]) / level for phase in range(season_length)]
scale = fmean(raw)
return [coefficient / scale for coefficient in raw]
def forecast(
history: list[float], season_length: int = 52, window: int = 4, horizon: int = 1
) -> list[float]:
"""
Predict the next `horizon` weeks from `history`.
The history and the forecast share one clock: the week after the end of
the history is position `len(history)` in the cycle. The caller never has
to align the series on a January.
`window` trades reactivity for stability. A short window reacts fast and
trusts the last few weeks; a long one is steadier and slower to notice a
change.
"""
if len(history) < 2 * season_length:
raise ValueError("a seasonal coefficient needs two full cycles at the very least")
if window < 1:
raise ValueError("the moving average needs a window of at least one week")
# A missing week must be refused, not averaged into a NaN forecast.
if not all(math.isfinite(value) for value in history):
raise ValueError("every week of history needs a finite number")
coefficients = seasonal_coefficients(history, season_length)
# Divide the season out, so the average below measures the level alone.
# A week closed every year has a zero coefficient and says nothing about the level.
deseasonalised = [
value / coefficients[week % season_length]
for week, value in enumerate(history)
if coefficients[week % season_length] != 0
]
level = fmean(deseasonalised[-window:])
start = len(history)
return [level * coefficients[(start + step) % season_length] for step in range(horizon)]JavaScript
/**
* Prévoir les ventes hebdomadaires avec une moyenne mobile et des coefficients
* saisonniers.
*
* Niveau N0. Sans dépendance.
*
* L'idée est la plus ancienne de la prévision : les ventes sont un niveau qui
* bouge lentement, multiplié par une forme qui se répète chaque année. Estimez
* la forme sur l'historique entier, divisez-la, faites la moyenne de ce qui
* reste sur les dernières semaines, puis remettez la forme.
*
* Ce que ceci ne modélise délibérément pas, c'est une tendance. Une moyenne
* mobile ne regarde jamais qu'en arrière : elle suit un changement de régime au
* lieu de l'anticiper. C'est la limite de ce niveau, et le test le dit tout
* haut.
*/
function mean(values) {
return values.reduce((total, value) => total + value, 0) / values.length;
}
/**
* One multiplier per position in the cycle, averaged over the history.
*
* A coefficient of 1.2 for week 50 means that week 50 usually sells twenty
* per cent above the year's level. The coefficients are rescaled to average
* one, so putting the shape back neither inflates nor deflates the forecast.
*/
export function seasonalCoefficients(history, seasonLength) {
const level = mean(history);
if (level === 0) return new Array(seasonLength).fill(1);
const raw = [];
for (let phase = 0; phase < seasonLength; phase += 1) {
const sameWeekEveryYear = history.filter((_, week) => week % seasonLength === phase);
raw.push(mean(sameWeekEveryYear) / level);
}
const scale = mean(raw);
return raw.map((coefficient) => coefficient / scale);
}
/**
* Predict the next `horizon` weeks from `history`.
*
* The history and the forecast share one clock: the week after the end of the
* history is position `history.length` in the cycle. The caller never has to
* align the series on a January.
*
* `window` trades reactivity for stability. A short window reacts fast and
* trusts the last few weeks; a long one is steadier and slower to notice a
* change.
*/
export function forecast(history, { seasonLength = 52, window = 4, horizon = 1 } = {}) {
if (history.length < 2 * seasonLength) {
throw new RangeError('a seasonal coefficient needs two full cycles at the very least');
}
if (window < 1) throw new RangeError('the moving average needs a window of at least one week');
// A missing week must be refused, not averaged into a NaN forecast or counted as zero.
if (!history.every(Number.isFinite)) throw new TypeError('every week of history needs a finite number');
const coefficients = seasonalCoefficients(history, seasonLength);
// Divide the season out, so the average below measures the level alone.
// A week closed every year has a zero coefficient and says nothing about the level.
const deseasonalised = [];
history.forEach((value, week) => {
const coefficient = coefficients[week % seasonLength];
if (coefficient !== 0) deseasonalised.push(value / coefficient);
});
const level = mean(deseasonalised.slice(-window));
const start = history.length;
const weeks = [];
for (let step = 0; step < horizon; step += 1) {
weeks.push(level * coefficients[(start + step) % seasonLength]);
}
return weeks;
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : la série ne quitte pas votre processus
- Une série agrégée n’est pas une donnée personnelle ; ventilée par vendeur ou par client, elle en devient une, et entre dans votre registre des traitements si vous en tenez un
Point de rupture
Une rupture de tendance et une promotion exceptionnelle, pour la même raison : la moyenne mobile n’a pas de pente et pas de notion d’événement. Dans un test, un concurrent ouvre, les huit dernières semaines reculent l’une après l’autre, et la prévision reste loin au-dessus de ce que le magasin encaisse — elle y reste tant que le recul continue. Dans l’autre, une seule semaine de promotion entre dans la moyenne comme du commerce ordinaire et gonfle la prévision de la semaine suivante.
Quand passer au niveau suivant
Vos prévisions se trompent dans le même sens semaine après semaine pendant que vous grandissez : toujours sous le réalisé. Ne montez pas pour l’inverse — sur un recul par palier, N1 fait pire que N0, et son propre test le montre.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Un modèle profond de prévision comme DeepAR tire sa force de l’apprentissage sur un grand nombre de séries apparentées. Trois ans de ventes hebdomadaires d’un seul magasin font cent cinquante-six nombres, une seule série, dont N1 tire déjà six coefficients lisibles un par un. Le modèle, lui, serait à entraîner, à conserver et à réentraîner.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Faire lire cent cinquante-six nombres à un modèle de langue pour en obtenir un cent cinquante-septième. Par défaut, un fournisseur ne garantit pas la même réponse d’un appel à l’autre, aucun coefficient ne se relit, et rien ne dit d’où vient l’écart avec la semaine passée. Une prévision qu’on ne peut ni rejouer ni expliquer ne se défend pas devant la personne qui commande le stock.
Pour aller plus loin
- Forecasting: Principles and Practice, 3.4 — Classical decomposition, les coefficients saisonniers de N0
- Forecasting: Principles and Practice, 7.4 — Some useful predictors : tendance, indicatrices saisonnières et termes de Fourier, les variables de N1
- NIST/SEMATECH e-Handbook of Statistical Methods, 6.4.4.3 — Seasonality
- NumPy — numpy.linalg.lstsq, la résolution des moindres carrés employée côté Python
Source des chiffres
- statsmodels 0.14.6, `ExponentialSmoothing(trend="add", seasonal="add", seasonal_periods=52).fit()`, exécuté sur les deux séries du test : 1 486 contre une vérité de 1 464 sur la croissance régulière, 1 034 contre 1 025 sur le concurrent qui ouvre
- Hyndman et Athanasopoulos, Forecasting: Principles and Practice, chapitre 8 — lissage exponentiel à tendance et saisonnalité
- OpenAI Cookbook — Reproducible outputs with the seed parameter : « non-deterministic by default »
- Salinas et al., DeepAR: Probabilistic Forecasting with Autoregressive Recurrent Networks (arXiv:1704.04110)
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.