Forecast next week’s sales
Know how much I will sell next week, from my own sales history.
No generative AI needed
négligeableInstantYour data never leaves
The verdict
N1 wins on steady growth, and loses on everything else. The entry's tests give the three numbers: on steady growth, truth 1,464, N0 forecasts 1,254 (−14 %) and N1 1,482 (+1 %); on a competitor opening, truth 1,025, N0 forecasts 995 (−3 %) and N1 1,310 (+28 %); on a Christmas week, truth 2,204, N0 forecasts 2,356 (+7 %) and N1 1,527 (−31 %). Choose by measurement, not by level: hold the last weeks out of the fit and compare the two on them. Move up to N1 for the growth coefficient as well, the one you show the shopkeeper before the forecast — provided you look at it: on the shrinking business it still reads growth. On a series that changes level, a forecaster's tool is exponential smoothing with trend and seasonality, not least squares: measured here, statsmodels' `ExponentialSmoothing` is 1 % out on both series. It is not a level of this entry because these levels are written in both languages and it has no JavaScript equivalent; it is the first thing to try after this page. Neither level returns an interval: whoever places the stock order needs an order of magnitude for the error as much as the point.
Least squares on calendar features: trend and harmonics
No generative AI needed
Lightweight classic modelN1négligeableInstant
Python
"""
Forecast weekly sales with a linear model on calendar features.
Rung N1. The moving average of N0 has no notion of a trend: it can only
repeat the recent past, which is exactly what it gets wrong when the business
is growing or shrinking. Here the level, the slope and the seasonal shape are
estimated together, by least squares, over the whole history.
The features are calendar arithmetic and nothing else: a constant, the number
of cycles elapsed, and a few sine and cosine pairs whose period is the season.
Two pairs are enough to place a Christmas peak and a summer dip; a peak only
a few weeks wide needs more pairs to reach its full height.
Counting the trend in cycles rather than in weeks is not cosmetic. It keeps
the columns of the design matrix on comparable scales, and it makes the
coefficient readable on its own: it is the growth per cycle, per year with
the default 52-week season. Less than one full cycle of history cannot tell
the trend from the season, so it is refused.
`season_length` need not be a whole number: this is trigonometry, nothing is
indexed by week. A year is 365.25 / 7 = 52.18 weeks, and the ISO calendar has
a fifty-third week every five or six years, so over three years a Christmas
peak drifts by about a week. Pass 365.25 / 7 to hold it in place — which is
what Forecasting: Principles and Practice recommends for weekly data, and
which N0, indexing by `week % season_length`, cannot do.
"""
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
/**
* Forecast weekly sales with a linear model on calendar features.
*
* Rung N1. The moving average of N0 has no notion of a trend: it can only
* repeat the recent past, which is exactly what it gets wrong when the
* business is growing or shrinking. Here the level, the slope and the
* seasonal shape are estimated together, by least squares, over the whole
* history.
*
* The features are calendar arithmetic and nothing else: a constant, the
* number of cycles elapsed, and a few sine and cosine pairs whose period is
* the season.
*
* Least squares is solved through the normal equations and a Gaussian
* elimination, short enough that no dependency is worth it. Counting the
* trend in cycles rather than in weeks keeps those equations well behaved,
* and makes the coefficient readable on its own: it is the growth per cycle,
* per year with the default 52-week season. Less than one full cycle of
* history cannot tell the trend from the season, so it is refused.
*
* `seasonLength` need not be a whole number: this is trigonometry, nothing is
* indexed by week. A year is 365.25 / 7 = 52.18 weeks, and the ISO calendar has
* a fifty-third week every five or six years, so over three years a Christmas
* peak drifts by about a week. Pass 365.25 / 7 to hold it in place — which is
* what Forecasting: Principles and Practice recommends for weekly data, and
* which N0, indexing by `week % seasonLength`, cannot do.
*/
/** 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;
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
The growth coefficient is the number to read before the forecast: a model that has found a trend nobody in the business recognises is a model to distrust.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
What it returns
| Week | Sales recorded | Sales forecast |
|---|---|---|
| four weeks ago | 1171 | |
| three weeks ago | 1212 | |
| two weeks ago | 1215 | |
| last week | 1239 | |
| next week | 1275 | |
| in two weeks | 1296 | |
| in three weeks | 1312 | |
| in four weeks | 1325 |
104 weeks of history fitted. Growth read out of the series: +207 loaves a year.
Here it breaks
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- No specific scope added: the fit runs on the history you already hold, with no extra corpus to keep
- An aggregated series is not personal data; broken down by salesperson or by customer it becomes personal data, and belongs in your record of processing activities, where you keep one
Breaking point
A regime change: least squares weighs a week from three years ago exactly as much as last week. In the test a competitor opens, and N1 forecasts 1,310 where the shop takes 1,025 — 28 % too high — while N0’s moving average, which looks only at the last four weeks, forecasts 995. It even reads more than 80 units of yearly growth on a business that has lost 30 %. Second blind spot: the narrow peak. Two harmonic pairs do not draw a Christmas week, and on a bakery whose truth is 2,204, N1 forecasts 1,527 where N0 forecasts 2,356 — the week a stockout costs the most.
When to move up a level
There is no level above this one here. When the straight line is describing two regimes instead of one, what needs changing is the history, not the model family: refit on the period after the break once it covers a full cycle, then read the growth coefficient again. Before that, the snippet refuses to fit: over twenty weeks the trend and the season blur into each other, and the growth coefficient can no longer be read.
The four options
- N0Seasonal coefficients read off the history, then a moving averageFree · Instant
- RecommendedN1Least squares on calendar features: trend and harmonicsnégligeable · Instant
- N2Not worth it here
- N3Not worth it here
The other options
N0Seasonal coefficients read off the history, then a moving averageFree · Instant
Seasonal coefficients read off the history, then a moving average
Rule and classic algorithmN0FreeInstant
Python
"""
Forecast weekly sales with a moving average and seasonal coefficients.
Rung N0. Standard library only.
The idea is the oldest one in forecasting: sales are a level that moves
slowly, multiplied by a shape that repeats every year. Estimate the shape over
the whole history, divide it out, average what is left over the last few
weeks, then put the shape back.
What this deliberately does not model is a trend. A moving average only ever
looks backwards, so it follows a change of regime instead of anticipating it.
That is the limit of this rung, and the test says so out loud.
"""
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
/**
* Forecast weekly sales with a moving average and seasonal coefficients.
*
* Rung N0. No dependency.
*
* The idea is the oldest one in forecasting: sales are a level that moves
* slowly, multiplied by a shape that repeats every year. Estimate the shape
* over the whole history, divide it out, average what is left over the last
* few weeks, then put the shape back.
*
* What this deliberately does not model is a trend. A moving average only
* ever looks backwards, so it follows a change of regime instead of
* anticipating it. That is the limit of this rung, and the test says so out
* loud.
*/
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;
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the series never leaves your process
- An aggregated series is not personal data; broken down by salesperson or by customer it becomes personal data, and belongs in your record of processing activities, where you keep one
Breaking point
A trend break and a one-off promotion, for the same reason: a moving average has no slope and no notion of an event. In one test a competitor opens, the last eight weeks fall one after another, and the forecast sits far above what the shop actually takes — and stays there for as long as the decline goes on. In the other, a single promotional week enters the average as ordinary trade and lifts the following week’s forecast.
When to move up a level
Your forecasts miss in the same direction week after week while you grow: always under the actuals. Do not climb for the opposite — on a step down, N1 does worse than N0, and its own test shows it.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A deep forecasting model such as DeepAR draws its strength from training on a large number of related series. Three years of one shop’s weekly sales is a hundred and fifty-six numbers, a single series, out of which N1 already draws six coefficients you can read one by one. The model, meanwhile, would still need training, storing and retraining.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Having a language model read a hundred and fifty-six numbers to produce the hundred and fifty-seventh. By default a provider does not guarantee the same answer from one call to the next, no coefficient can be read back, and nothing says where the gap with last week comes from. A forecast you can neither replay nor explain does not hold up in front of the person placing the stock order.
Further reading
- 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
Where the figures come from
- 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)
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.