SaleCast — Faire concourir 9 algorithmes de forecasting (et 2 foundation models) en C# pur
Comment SaleCast choisit, par produit et chaque nuit, le meilleur algorithme parmi 9 modèles statistiques classiques (Naive, Croston, Holt-Winters, MSTL…), LightGBM via ML.NET, et 2 foundation models zero-shot (TiRex 35M params, Chronos d'Amazon) — sans jamais quitter le runtime .NET.
Cet article décortique le projetSaleCastLe problème : un best-seller, un saisonnier, un produit en fin de vie
Vous gérez 2 800 SKU sur 6 marketplaces. Trois exemples :
- T-shirt Marin XL noir : vendu tous les jours, courbe lisse, saisonnalité forte été/hiver.
- Guirlande LED 50 ampoules : 90 % des ventes en novembre-décembre, le reste à zéro.
- Adaptateur secteur USB-A : déclin lent, 2 ventes par semaine, des semaines à zéro.
Aucun algorithme seul ne prévoit correctement ces trois courbes.
- Holt-Winters est bon pour le t-shirt, catastrophique pour la guirlande (les zéros la cassent).
- Croston est conçu pour le déclin lent, ridicule sur le saisonnier.
- LightGBM est puissant si on a 500 jours d'historique et des features (jour de la semaine, promo, météo…), inutile sur 30 jours bruts.
La réponse industrielle : faire concourir tous les candidats sur chaque produit, garder le meilleur.
C'est ce que fait AlgorithmCompetition.Run().
Le registre : 9 + 2 algorithmes
public sealed class ModelRouter
{
public ModelRouter(IReadOnlyList<IForecastAlgorithm>? additionalAlgorithms = null, …)
{
var algorithms = new List<IForecastAlgorithm>
{
new NaiveForecast(), // baseline
new SimpleExponentialSmoothing(), // SES
new ThetaMethod(),
new CrostonMethod(), // intermittent
new TsbMethod(), // intermittent évolué
new HoltWintersMethod(…), // saisonnier
new SeasonalNaiveForecast(…),
new MstlForecast() // multiple seasonal trend decomposition
};
// + LightGBM (ML.NET) injecté quand on a un modèle entraîné
// + TiRex (ONNX, xLSTM 35M params, zero-shot)
// + Chronos (ONNX, Amazon foundation model)
}
}
Un seul fichier, un seul fichier, qui sait :
- exécuter une régression LightGBM via
Microsoft.ML.LightGbm, - charger un foundation model ONNX 35 millions de paramètres via
Microsoft.ML.OnnxRuntime, - et passer la même série temporelle à un Holt-Winters codé à la main.
Zéro microservice Python. Pas de gRPC vers un sidecar, pas de cluster Ray, pas de latence réseau. Le pipeline tourne dans le même process que l'application Blazor.
La compétition : back-test multi-origines + screening
L'implémentation est plus subtile qu'un simple "exécute tout, garde le meilleur MAE".
public static CompetitionOutcome Run(
IReadOnlyList<IForecastAlgorithm> candidates,
ProductSalesHistory history,
ForecastRequest request,
ForecastProfile profile)
{
// 1. Séparer les candidats : back-test classique vs synthetic (foundation models)
var syntheticCandidates = new List<IForecastAlgorithm>();
var backtestCandidates = new List<IForecastAlgorithm>();
foreach (var candidate in candidates)
{
var useSynthetic = candidate.AlgorithmType switch
{
ForecastAlgorithmType.TiRex when !profile.BacktestTiRex => true,
ForecastAlgorithmType.Chronos when !profile.BacktestChronos => true,
_ => false
};
if (useSynthetic) syntheticCandidates.Add(candidate);
else backtestCandidates.Add(candidate);
}
const int screeningOrigins = 4;
const int topNForFullBacktest = 3;
// 2. Screening rapide : 4 origines glissantes, on garde le top 3
var screeningResults = ScreenCandidates(backtestCandidates, history, screeningOrigins);
var topN = screeningResults.OrderBy(r => r.Score).Take(topNForFullBacktest);
// 3. Full back-test : 10+ origines sur les finalistes
var finalists = FullBacktest(topN, history);
// 4. Synthetic eval : TiRex et Chronos ne back-testent pas (foundation, zero-shot)
// On compare leur dernière prévision à la dernière fenêtre observée
var syntheticScored = SyntheticEvaluation(syntheticCandidates, history);
return Pick(finalists, syntheticScored, request.HorizonDays);
}
Trois points qui sortent du tutoriel Wikipedia sur le back-test :
a. PurgeGap pour LightGBM
var purgeGap = candidate.AlgorithmType == ForecastAlgorithmType.LightGbm
? ForecastDefaults.MLPurgeGapDays
: 0;
LightGBM utilise des features lag (ventes_t-1, ventes_t-7). Sans gap, il "voit" le futur
pendant le back-test. Le PurgeGap insère un trou volontaire pour éviter le data-leak.
Les modèles statistiques n'en ont pas besoin — ils ne lookahead pas.
b. Screening avant full back-test
10 origines de back-test × 9 algorithmes × 2 800 produits = 252 000 entraînements / nuit. Avec un screening 4 origines, on élimine 6 candidats par produit dès le premier passage — on divise le coût par ~2.5.
c. Synthetic eval pour les foundation models
TiRex et Chronos sont des modèles zero-shot. On ne les entraîne pas par produit, on les charge une fois et on leur passe la série. Le back-test classique n'a pas de sens — ils n'ont rien à apprendre. On compare leur prédiction sur la dernière fenêtre à la réalité, point.
TiRex en .NET — l'astuce ONNX
TiRex est un xLSTM (extended LSTM) de 35 millions de paramètres, conçu pour le forecasting zero-shot. Publié en weights PyTorch sur HuggingFace.
Le piège : on ne veut pas faire tourner Python en sidecar.
Solution : export ONNX + Microsoft.ML.OnnxRuntime.
public sealed class TiRexForecast : IForecastAlgorithm
{
public ForecastAlgorithmType AlgorithmType => ForecastAlgorithmType.TiRex;
public string DisplayName => "TiRex (foundation, xLSTM 35M)";
public int MinimumDataPoints => 1; // zero-shot, pas de minimum
private readonly InferenceSession _session;
public TiRexForecast(string onnxPath)
{
_session = new InferenceSession(onnxPath, new SessionOptions {
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
IntraOpNumThreads = 4,
});
}
public ForecastResult Forecast(ProductSalesHistory history, int horizon)
{
var input = NormalizeAndPad(history, contextLength: 512);
var feeds = new[] { NamedOnnxValue.CreateFromTensor("context", input) };
using var results = _session.Run(feeds);
return Denormalize(results.First().AsTensor<float>(), history.Scale);
}
}
Le modèle est chargé une fois au démarrage. Chaque inférence prend ~12 ms sur CPU. Sur 2 800 SKU, c'est 35 secondes pour ré-évaluer tout le catalogue. Pas de cold-start cloud, pas de quota OpenAI, pas de fuite de données.
Le résultat : la décision par SKU
Au matin, pour chaque produit, on a une ligne dans la table ForecastAuditEntry :
SKU Winner MAE vs baseline Source
TSHIRT-MARIN-XL-N TiRex 4.2 -54% synthetic
GUIRLANDE-LED-50 MSTL+Croston 3.8 -71% backtest
ADAPTATEUR-USB-A Croston 1.4 -23% backtest
LIVRE-HISTOIRE-ROME-VOL2 HoltWinters 6.1 -38% backtest
Le t-shirt gagne avec TiRex parce que la saisonnalité est implicite dans 100 000 séries similaires que le foundation model a vues. La guirlande gagne avec MSTL stacké sur Croston parce que l'intermittence est extrême. L'adaptateur gagne avec Croston seul parce qu'il n'y a rien à modéliser au-delà.
Aucun produit ne gagne avec le baseline Naive. Si un produit gagne avec Naive, c'est un signal d'alerte (données corrompues, série trop courte).
Pourquoi pas un microservice Python ?
| Critère | Python sidecar | C# pur (SaleCast) |
|---|---|---|
| Latence par prédiction | 80-200 ms (gRPC) | 12 ms |
| Cold start serveur | 2-8 s | 0 s (process unique) |
| Données transmises | tout le SKU sort du process | rien ne sort |
| Coût ops | 1 service en plus à monitorer | inclus dans le Blazor |
| Stack équipe | Python + C# + 2 CI | C# uniquement |
| Versioning modèle | image Docker | fichier .onnx dans le repo |
Le seul argument en faveur de Python : "tout le ML est en Python". Vrai pour la recherche. Faux pour l'inférence, où ONNX Runtime + ML.NET couvrent 95 % des besoins d'un produit de forecasting.
Ce que ce projet m'a appris
-
La compétition d'algorithmes bat la sélection manuelle dans 100 % des cas que j'ai mesurés. Demander à un humain "tu prends Holt-Winters ou Croston pour ce SKU ?" est une perte de temps.
-
Les foundation models time-series sont en train de tuer le tuning manuel. TiRex est sorti en 2024, il bat Holt-Winters sur 60 % des SKU testés sans une ligne de configuration. Chronos d'Amazon en bat 55 %. Dans deux ans, le routing devra simplement choisir entre deux algos : le foundation model et un baseline statistique.
-
L'écosystème .NET ML est sous-estimé. ML.NET pour LightGBM, ONNX Runtime pour les modèles externes,
Microsoft.ML.OnnxRuntimepour le runtime. Tout est là, mature, multi-arch (x86/arm), et 5× plus rapide qu'un wrapper Python.
Stack & code
- Solution Forecasting = 5 sous-projets (
Core,Api,CLI,DesktopMAUI,Web,Web.Server) - 9 algorithmes statistiques dans
Algorithms/(Naive, Croston, HoltWinters, MSTL, SES, Theta, TSB, SeasonalNaive, SimpleExponentialSmoothing) - 2 foundation models dans
Algorithms/TiRex/etAlgorithms/Chronos/ AlgorithmCompetition.cs= 280 lignesModelRouter.cs= 120 lignes- ML.NET 5 + Microsoft.ML.OnnxRuntime 1.18
- Hangfire pour le re-back-test nocturne
- Blazor + Lumex UI pour le studio de visualisation des modèles
Le studio de SaleCast permet de voir la compétition produit par produit, avec la courbe de chaque candidat, son MAE, et le gagnant. C'est l'outil que je voudrais que tous les data scientists e-commerce utilisent à la place de leur notebook Jupyter.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience