Comment je prédis les ventes d'un produit qui n'a JAMAIS été vendu
Lundi matin. Un client ajoute 47 nouveaux produits à sa boutique. Pas une seule vente. Aucun historique. Et il veut savoir combien commander chez le fournisseur. Voici comment SaleCast répond — sans IA magique, juste de l'agrégation intelligente sur les bons voisins.
Cet article décortique le projetSaleCastLe problème qu'aucun cours de ML n'aborde
Tous les cours de forecasting commencent par la même phrase :
"Soit une série temporelle $y_1, y_2, ..., y_n$ avec n ≥ 30..."
Et toi, en prod, tu as un client qui te demande :
"On lance 47 nouveaux produits lundi. Combien j'en commande au fournisseur ?"
n = 0. Aucun historique. Aucun pattern. Aucune saisonnalité observable.
Et pourtant, la réponse "je ne sais pas" n'est pas acceptable. Ce client a besoin d'un nombre. S'il en commande 100 et qu'il en vend 5, il a 95 stocks morts. S'il en commande 10 et qu'il en vend 200, il rate 190 ventes.
Le cold start est le problème silencieux du forecasting. Personne n'en parle dans les blogs. Tout le monde le rencontre en production.
Ma règle dans SaleCast : 30 jours
public static class ColdStartHandler
{
public static bool IsColdStart(ProductSalesHistory history)
=> history.Length > 0 && history.Length < ForecastDefaults.ColdStartThresholdDays;
// ColdStartThresholdDays = 30
}
En dessous de 30 jours d'historique, je refuse de faire tourner Holt-Winters, LightGBM ou TiRex. Aucun de ces algorithmes n'a assez de signal. Ils vont inventer.
Et un forecast inventé qui ressemble à un vrai forecast est pire qu'un "je ne sais pas" — le client va commander dessus.
L'idée : prédire depuis les voisins
Le produit n'a pas d'historique. Mais la catégorie dans laquelle il rentre, oui.
Si un t-shirt "rouge taille L" n'a jamais été vendu, mais que :
- la moyenne de tous les t-shirts de la boutique est de 4,2 unités/jour,
- la catégorie "vêtements été" a un pattern saisonnier connu,
- 80 % des t-shirts sont classifiés "demande Smooth" (régulière),
…alors je peux donner une estimation honnête avec un intervalle de confiance large plutôt qu'un nombre faussement précis.
Le code de SaleCast construit, au démarrage, un profil de demande par catégorie :
public static IReadOnlyDictionary<string, CategoryDemandProfile> BuildCategoryProfiles(
IReadOnlyList<ProductSalesHistory> allProducts)
{
var result = new Dictionary<string, CategoryDemandProfile>(StringComparer.OrdinalIgnoreCase);
// On ne garde que les produits qui ont assez d'historique pour servir de référence
var byCategory = allProducts
.Where(h => h.Length >= ForecastDefaults.ColdStartThresholdDays
&& h.Points.Count > 0
&& h.Points[0].Category is not null)
.GroupBy(h => h.Points[0].Category!);
foreach (var group in byCategory)
{
var dailyDemands = group
.Select(h => h.TotalQuantity / h.Length)
.ToArray();
var mean = dailyDemands.Average();
var std = Math.Sqrt(dailyDemands.Sum(d => (d - mean) * (d - mean)) / (dailyDemands.Length - 1));
// Le type de demande majoritaire dans la catégorie
var typicalClass = group
.Select(h => DemandClassifier.Classify(h).Classification)
.GroupBy(c => c)
.OrderByDescending(g => g.Count())
.First().Key;
result[group.Key] = new CategoryDemandProfile {
MeanDailyDemand = mean,
StdDailyDemand = std,
TypicalClass = typicalClass,
};
}
return result;
}
Trois choses à noter :
a. On exclut les produits cold-start de la référence
Sinon la moyenne de catégorie est polluée par des produits sans historique → biais bas.
Le filtre Length >= 30 garantit qu'on ne se base que sur des produits mesurés.
b. On garde l'écart-type, pas juste la moyenne Donner "4,2 unités/jour" est faux. Donner "4,2 ± 2,8" est honnête. Le client peut décider lui-même s'il prend le risque haut ou bas.
c. On classe la catégorie Si 80 % des t-shirts de la catégorie sont "Smooth" (vente quotidienne régulière) et 20 % "Intermittent" (zéros fréquents), je sais que mon nouveau t-shirt sera probablement "Smooth" — et que la moyenne quotidienne a un sens.
Si 80 % étaient "Lumpy" (ventes par bouquets de 10 séparés de semaines vides), alors la moyenne ne dit rien et il faut le signaler.
La classification de la demande : ADI + CV²
C'est probablement le truc le plus utile que j'ai appris sur le forecasting de stock. Toute série temporelle de ventes appartient à une de 4 classes :
| CV² faible (régulier) | CV² élevé (volatile) | |
|---|---|---|
| ADI faible (vente fréquente) | Smooth — t-shirt classique | Erratic — produit promo |
| ADI élevé (zéros fréquents) | Intermittent — livre rare | Lumpy — guirlande Noël |
- ADI (Average Demand Interval) = temps moyen entre deux ventes.
- CV² (Coefficient de Variation au carré) = volatilité de la quantité vendue.
Le code SaleCast les calcule ainsi :
var intervals = new List<int>();
var lastNonZero = -1;
var nonZeroValues = new List<double>();
for (var i = 0; i < quantities.Length; i++)
{
if (quantities[i] > 0)
{
nonZeroValues.Add(quantities[i]);
if (lastNonZero >= 0) intervals.Add(i - lastNonZero);
lastNonZero = i;
}
}
var adi = intervals.Count > 0 ? intervals.Average() : double.PositiveInfinity;
var cv2 = nonZeroValues.Count > 1
? Math.Pow(nonZeroValues.StdDev() / nonZeroValues.Average(), 2)
: 0;
Et les seuils standard (Syntetos & Boylan, 2005) :
public const double AdiThreshold = 1.32; // au-delà : intermittent
public const double Cv2Threshold = 0.49; // au-delà : erratique
Pourquoi 1,32 et 0,49 et pas 1,5 et 0,5 ? Parce que c'est ce que 15 ans d'études sur des datasets industriels ont identifié comme le point de bascule où un algorithme optimisé pour la classe X devient meilleur qu'un algorithme optimisé pour la classe Y.
Ces deux nombres ont été plus utiles à mon projet que tous les hyperparamètres LightGBM réunis.
Ce que le client voit, lundi matin
Sur les 47 nouveaux produits, SaleCast affiche :
T-shirt Marin rouge taille L
Cold start (0 jours d'historique)
→ Estimation catégorie "vêtements été" : 4.2 ± 2.8 unités/jour
→ Profil typique catégorie : Smooth (84% des produits)
→ Commande suggérée pour 30 jours : 126 unités (low) à 210 (high)
→ Confiance : faible (mise à jour automatique à 30 jours d'historique)
Le client n'a pas un nombre précis. Il a un intervalle, une justification, et une promesse de mise à jour.
C'est ce qui sépare un produit ML qui aide les décisions d'un produit ML qui les sabote.
La leçon
Le forecasting "facile" est celui où on a 500 jours d'historique sur 10 produits. Le forecasting réel est celui où, sur 2 800 produits, 200 sont nouveaux ce mois-ci.
Les solutions techniques existent — agrégation par catégorie, hierarchical forecasting, clustering par similarité produit, transfer learning depuis foundation models comme TiRex. Mais aucune ne marche si on commence par "n'avoir aucun moyen de répondre quand n = 0".
Le bon ingénieur ML ne se contente pas de prédire bien. Il prédit quelque chose d'utile même quand c'est impossible de prédire bien. Et il sait le dire au client.
Stack & code
- DemandClassifier.cs — 63 lignes
- ColdStartHandler.cs — 110 lignes
- CategoryDemandProfile.cs — la donnée agrégée
- Seuils Syntetos-Boylan : ADI = 1.32, CV² = 0.49
- C# pur dans
SaleCast.Forecasting/Selection/etPipeline/ColdStart/
Pas de Python sidecar, pas de microservice ML, pas de dépendance externe. Du C# qui s'exécute dans le même process que le reste de l'app Blazor.
C'est le genre de code qu'on n'écrit qu'après avoir pris dans la figure le client qui demande "et pour les 47 nouveaux produits, ça donne quoi ?". Et qu'on n'oublie plus.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience