Skip to content

VN2 Features

Published:
•20 min read

VN2 is an inventory planning competition that ran in 2025. Its winner, Bartosz Szabłowski, published his solution in “One Global Model, Many Behaviors: Stockout-Aware Feature Engineering and Dynamic Scaling for Multi-Horizon Retail Demand Forecasting with a Cost-Aware Ordering Policy (VN2 Winner Report)” (arXiv:2601.18919, January 2026). This post is about the VN2 features I implemented from that report in our store forecast, a global LightGBM model, and their backtest against production. The background section first summarises the competition and the report.

Background: the VN2 competition and the winner’s report

The VN2 competition

VN2 was run by Nicolas Vandeput (SupChains) on DataSource.ai, from 29 September to 10 November 2025, with an €18,000 prize pool. Its predecessor VN1 was a forecasting competition: forecast 13 weeks, scored on MAE% + |Bias%|. VN2 asked for integer order quantities and scored the resulting inventory cost, because the only way to rank inventory policies is to run them and see which one costs less.

Every participant received the weekly sales history per series, a binary in-stock flag per series-week (the paper’s first part is built on this column), static master data (store, product), and an opening inventory snapshot (on-hand and in-transit).

Shortage and holding costs

Running out costs 5 times what holding stock costs. There are no backorders, no obsolescence, no holding cost on in-transit units, and supply is unlimited.

Lead time

Periodic weekly review, 2-week lead time. An order placed at the end of week t is on the shelf at the start of week t+3.

A timeline of five weeks. An order placed at the end of week t is in transit during weeks t+1 and t+2, with no holding cost, and is available to sell from the start of week t+3.

Sales are capped by the stock on the shelf, St = min(It, Dt), and unmet demand is lost, so every sales history we train on is censored demand. The score is the sum over weeks and series of 1.0 × lost sales + 0.2 × end-of-week stock.

Results

The official benchmark was a 13-week moving average with global seasonal factors, plus a flat 4-week stock target, in 55 lines of Python. It scored 4,334.0 €; the winner scored 3,763.2 €, 13.2% less. Vandeput, in his post-mortem: “The top 5 competitors beat the benchmark by an average of 13%. But only 25 participants (out of 180!) could beat it.”

Retuning the benchmark’s coverage from 4 weeks to about 3.5 weeks, changing one number and touching no model, would have scored about 3,900 € and ranked around 6th. Vandeput adds that the benchmark’s seasonal moving average turned out to be an accurate forecast, while its 4-week coverage was not optimised and was too long.

#EntrantApproachScore
1Bartosz SzabłowskiGlobal CatBoost, direct multi-horizon, cost-aware order-up-to3,763.2 €
2Matias AlvoDeep reinforcement learning, the only participant to use it3,764.6 €
3Philip Stubbs and Jakub Figura3,791.6 €
4Carlo Cavalieri3,794.4 €
5Ruben van de Geer, Diederik Perdok and Simon Grest3,824.4 €
26Official benchmark13-week seasonal moving average, 4-week stock target4,334.0 €

Matias Alvo finished second, 1.4 € (0.04%) behind the winner.

Dot chart of the VN2 scores, in euros, weeks 3 to 8: the top five between 3,763.2 and 3,824.4, the benchmark retuned to 3.5 weeks of coverage at about 3,900, and the official benchmark at 4,334.0.

The forecasting model

The winner trains one global CatBoost model jointly across all 599 series, with time series cast as tabular regression. It is direct multi-horizon: three independent models for h = 1, 2 and 3, each with the target shifted forward h weeks. It is trained with RMSE on the scaled target, with hyperparameters selected on MAE in original units. It is not recursive and does not use a Tweedie or Poisson loss, despite about 43% zero weeks: intermittency is handled by features.

CatBoost was compared with Random Forest and LightGBM on the same features and a comparable tuning budget, and the report states that performance is “tightly coupled to the feature engineering design”. Vandeput’s own tier list puts LightGBM in A-tier and CatBoost in B-tier.

Stockout-aware targets

A zero in a sales column means one of two different things: nobody wanted it, or there was none to sell. Train on both as if they were the same and the model learns that demand is lower than it is, then orders too little, which causes the next stockout. The paper masks those weeks:

y_eff_t = y_t    if in_stock_t = True
y_eff_t = NaN    otherwise          masked, not zero, not imputed to zero

Every target and every target-derived feature (lags, rolling means, medians, exponentially weighted means, std, IQR, momentum, slope) is computed on y_eff, not on raw sales.

Toy example: a weekly series with three out-of-stock weeks recorded as zero sales. The 13-week rolling mean computed on raw sales drops below the true demand level after the stockouts; the same mean computed on y_eff, with the stockout weeks masked, stays close to it.

Masking leaves missing values, which the paper fills with the per-series median of scaled values, then the global one, rather than CatBoost’s native NaN handling, so that the model does not learn “out of stock” as a feature. The in-stock flag is only a likely shortage signal: Vandeput cautions that zero inventory at the end of a week does not prove lost sales.

Dynamic scaling

In a global model with a squared-error loss, high-volume series dominate the loss. The model then learns the level of each series more than its shape. VN2 series range from about 0.3 to about 99 units a week.

scale_t = max( 53 · mean_non-missing( y_eff_(t-52 ... t) ), 1 )     an annualised demand level

The model is trained on y_eff / scale and its predictions are multiplied back by the scale. The 53-week mean needs at least 45 in-stock weeks; before that, the expanding mean is used. The scale is rolling, so it follows trend and lifecycle; it is computed on y_eff, so stockout weeks do not deflate it; clipping at 1 stops ultra-sparse series from dividing by about 0. It is applied to the target and to all target-based numeric features.

Toy example: two series with the same seasonal shape, one selling about 90 units a week and one about 2.5. On raw units the small one is a flat line near zero; after dividing each by its own scale factor the two curves lie on top of each other.

Recency weights

There is no differencing of the target. Drift is absorbed by dynamic scaling, change-based features (momentum, slope) and time-decayed sample weights: history is cut into blocks of about 53 weeks, the most recent block weighted 1.0, each older block halved. The decay factor is a hyperparameter; 0.5 is the value the report uses.

Sample weight by week of history: 1.0 for the most recent 53 weeks, 0.5 for the year before, 0.25 for the year before that.

Features that describe each series

One model has to serve smooth high-volume items and lumpy long-tail items at the same time. The paper does not cluster them or learn embeddings. It uses categorical identity (store, product, series id, week of year), behavioural statistics (seasonality_strength, nonzero_rate, rolling std and IQR, momentum, time_since_spike), and dynamic scaling. The report notes that raw series-ID categoricals improve the leaderboard but do not generalise to a new store or product; in production it prefers stable parent-level categories.

Feature importance in the paper

The paper's Table 1, CatBoost LossFunctionChange importance as a within-horizon percentage for h = 1, 2 and 3. Week of year leads at 22.48, 36.15 and 35.19 percent. Every other listed feature is under 10 percent: fourier_sin_2 reaches 8.84 and 9.02 at h = 2 and 3, Store 8.45 at h = 2.

Calendar seasonality dominates, and its share grows from 22% at h=1 to 36% and 35% at h=2 and h=3, as recent lags lose predictive power.

The ordering policy

The order arrives after two more weeks of demand and two scheduled deliveries, so the policy first projects the stock to the delivery week from the forecasts. It then orders up to a target B = D̂t+3 + zq · φ · √D̂t+3, where zq = 0.9674 comes from the critical fractile q* = cs / (cs + ch) = 0.8333, and φ is calibrated by minimising the simulated total cost. Vandeput’s conclusion is that the top competitors stood out by their evaluation framework: they tested and tuned their policies by simulation.

VN2 features implementation

This was part of a review of our model’s base features, with the winner’s report as the source: arXiv:2601.18919.

Feature definition & implementation

Rolling level and dispersion features:

FeatureProduction definition
rolling_mean_sales_3mean(xt−3, xt−2, xt−1)
rolling_mean_sales_5mean(xt−5, …, xt−1)
rolling_mean_sales_13mean(xt−13, …, xt−1)
rolling_median_sales_3Approximate 50th percentile of xt−3…xt−1.
rolling_median_sales_5Approximate 50th percentile of xt−5…xt−1.
rolling_median_sales_13Approximate 50th percentile of xt−13…xt−1.
rolling_std_sales_8Spark STDDEV over xt−8…xt−1.
rolling_iqr_sales_13Approximate P75(xt−13…xt−1) − P25(xt−13…xt−1).

Exponentially weighted means: an exponentially weighted mean is a weighted average of recent sales. The most recent week gets the largest weight; each older week gets a smaller weight.

EWM_ρ(t) = [ Σ_(k=1…10) 1(x_(t−k) exists) × x_(t−k) × ρ^(k−1) ] / [ Σ_(k=1…10) 1(x_(t−k) exists) × ρ^(k−1) ]
FeatureDecay used in the code
ewm_sales_5ρ = 0.6666666667 ≈ 2/3
ewm_sales_10ρ = 0.8181818182 ≈ 9/11

The two decays are the usual span convention, ρ = 1 − 2/(span + 1), the one pandas uses for spans 5 and 10.

Weight of each of the last ten weeks in the two exponentially weighted means, normalised to sum to 1. With ρ = 2/3 last week carries about a third of the weight and week ten almost none; with ρ = 9/11 the weights decline more slowly.

Trend and demand-frequency features:

FeatureDefinitionWhat it is trying to describe
momentum_sales_1xt−1 − xt−2Whether demand rose or fell from two weeks ago to last week.
momentum_sales_5xt−1 − xt−6Whether last week was stronger or weaker than five weeks earlier.
slope_sales_4(xt−1 − xt−5) / 4Average weekly direction of change during the previous four-week interval.
nonzero_rate_sales_12Σk=1…12 1(xt−k > 0) / Σk=1…12 1(xt−k exists)Actual behavior is the share of observed recent weeks that were above the series’ historical mean.

Robust spike features

For the current week t:

robust_z_t = (last week's normalized sales − median) / MAD

weeks_since_spike(t) = [ date(t) − latest feature-row date(s) with robust_z(s) ≥ 3 ] / 7

Toy example: a weekly series with two spikes, its robust z-score, which crosses the threshold of 3 on the spike weeks, and weeks_since_spike, which resets to 0 after each spike and then counts up by one a week.

Intermittency score: rolling_mad_proxy / (rolling_median + 1)

Allows estimating how large the typical variation is compared with typical sales.

Seasonality strength

For each row r, define ar = xr−1 and br = xr−53. Then:

seasonality_strength_t = corr({a_r}, {b_r})

where the production time window spans the preceding 364 days through the current row.

Fourier calendar features: coefficients and periods

x = weekofyear / 52
fourier_sin_k = sin(2π × k × weekofyear / 52)
fourier_cos_k = cos(2π × k × weekofyear / 52)

The six Fourier features by week of year, one panel per harmonic: k = 1 completes one cycle per 52-week year (period 52 weeks), k = 2 two cycles (26 weeks), k = 3 three cycles (17.3 weeks). Week 53 is drawn too and takes the same values as week 1.

Because the code divides by 52, week 53 of an ISO year with 53 weeks gets the same values as week 1.

Feature correlation

Features are very correlated linearly.

Pearson correlation matrix of the baseline numeric inputs and all VN2 candidates on the training data. A large dark red block covers the recent sales level features: average of last month, lags 1 and 2, rolling means and medians, and the two exponentially weighted means, mostly above 0.8 with each other. Annual lags and last-year means form a smaller correlated block. Month and week of year correlate at about 0.9, and fourier_sin_1 at about −0.8 with both. seasonality_strength and robust_z_score are close to 0 with everything.

To effectively analyze the features and select only the relevant ones, we need to group them into meaningful categories:

GroupNature and membersCorrelation interpretation
A, Recent sales levelavg_last_month_sales, avg_two_month_ago_sales, lags 1/2/3, rolling means 3/5/13, rolling medians 3/5/13, EWM-5, and EWM-10.Directly comparable recent-demand summaries. This is the only group with super-correlated pairs.
B, Annual reference and seasonalitybaseline_quantity, last-year centered means, annual lags 49/51/52/53, canonical H4 annual lag, and seasonality_strength.Annual demand level and annual-repeat signal.
C, Calendar phasemonth, weekofyear, Fourier sin/cos terms 1/2/3.Functionally related calendar encodings. Pearson does not fully capture the nonlinear cyclic relationship between week number and sine/cosine.
D, Trend and momentumMomentum 1/5 and slope 4.Recent direction of demand change.
E, Variability and spikesRolling standard deviation, IQR, robust-z, and weeks since sales spike.Demand volatility and abnormal-demand behavior.
F, Intermittencynonzero_rate_sales_12.Recent frequency of nonzero sales.
G, Static hierarchy and holidayStore, country, three product hierarchy levels, and distance-to-holiday fields.Categorical IDs are excluded from Pearson-correlation conclusions.

Experiment

In development, on one product scope. Time to train models:

Training time of three feature sets for horizons 1, 2 and 4, indexed to the baseline features = 100. With all VN2 features training takes about 1.8 to 2.3 times as long; with the top 12 VN2 features about 1.4 times as long.

Selected features:

SHAP analysis

SHAP feature ranking for three models at horizons 1, 2 and 4: baseline features plus all VN2 features, baseline plus the selected VN2 features, and baseline features only. Each list shows the top 12 features with VN2 features highlighted. With all VN2 features, ewm_5, rolling_mean_3 and fourier_cos_2 enter the top 3 at horizon 1. In the selected model, ewm_5 is first at horizons 1 and 2 and second at horizon 4, and robust_z_score is fourth at every horizon. In the baseline, lag_1 leads at horizons 1 and 2 and weekofyear at horizon 4.

In the paper they kept woy and sin/cos (see the paper’s importance table in the background section).

Feature analysis

High seasonality should mean: if the last-year feature is high, the SHAP contribution of that feature is high. Meaning: pink points should be farther from the horizontal zero line than blue points, at comparable x-values.

SHAP dependence of last_year_mean_3weeks_centered_quantity_h1 in the selected model at horizon 1, points coloured by seasonality strength from low (blue) to high (pink). The points rise along a line from about −0.1 at the left to about 0.1 near x = 1; pink and blue points are mixed along the same band.

SHAP dependence of weeks_since_sales_spike in the selected model at horizon 1, coloured by seasonality strength. Contributions are slightly positive right after a spike, turn negative from about 25 weeks, rise to their highest values between about 45 and 52 weeks, and are mostly negative beyond 60 weeks.

The highest contributions of weeks_since_sales_spike sit between about 45 and 52 weeks, about one year after the last spike.

Features:

Results of VN2 features integration backtesting

Optimization

Many experiments were conducted to see the effect of each feature independently. The goal of these experiments was to improve both Bias and RelMSE on sales quantity cohorts 2+ for 3 product scopes, each at its own cutoff week. Two other cutoff weeks were also checked. (RelMSE is the model’s mean squared error divided by the naive forecast’s.)

The results are as follows:

Change testedDid it work?
Remove Fourier features onlyNo
Remove seasonality strength onlyNo
Remove Fourier and seasonality strength togetherTrade-off: better RelMSE, slightly worse absolute bias
Remove intermittency from the broad feature setNo
Replace the two-week slope with momentumNo: improved the broad parent, but not the baseline
Remove month, week, or bothNo joint improvement
Add EWM or replace the baseline input with EWMNo: improved the broad parent, but not the baseline
Add the recent-to-last-year ratio onlyYes
Add the volatility, slope, robust-score and spike-age bundleYes: tested together, not individually
Use the clean recipe without Fourier, seasonality strength or intermittencyYes: best result among the listed H1 candidates
Remove the ratio from the clean recipeNo
Other conditional momentum, intermittency and EWM variantsNo joint improvement
CandidateAbsolute bias, against the classic baselineRelMSE, against the classic baselineDid it work?
Classic baselineReferenceReferenceReference
Baseline + ratio only2.8% smaller1.4% lowerYes: modest improvement on both metrics
Baseline + clean early-feature bundle12.5% larger3.2% higherNo: both metrics worsened

We decided to remove Fourier cos & sin because they showed no improvement compared with month & weekofyear.

The final optimized set of features decided on is:

H1-H3:

H4:

FeaturePurposeHorizons
rolling_std_sales_8Sales volatility over the previous 8 weeksH1-H3
slope_sales_2Sales at t-2 minus sales at t-1; negative means rising demandH1-H3
robust_z_score_salesDetect recent sales spikes using a historical median and robust deviation; clipped to [-10, 10]H1-H3
weeks_since_sales_spikeWeeks since the latest robust score ≥ 3; null before the first spikeH1-H3
recent_to_last_year_ratio_h{h}Recent 3-week mean versus horizon-aligned last-year mean, using raw sales and +1 smoothingAll
seasonality_strengthRolling 364-day correlation between recent and last-year demand, averaged across stores per country and productAll

Backtesting

Some highlights.

WAPE of the forecast with VN2 features relative to production, for three backtests, seven sales quantity cohorts and WAPE1, WAPE4 and WAPE8. In the None cohort the VN2 forecast's WAPE is 12 to 88 percent higher than production's; in every other cohort the two are within about 3 percent of each other, and the VN2 forecast is the lower one in most cells. Both forecasts have a lower WAPE than the naive forecast in every cell but one, where the VN2 forecast ties it.

Which of the three forecasts has the bias closest to zero, per backtest, cohort and horizon. Production is closest in the None cohort everywhere. On cohorts 1-2 and above, the VN2 forecast is closest in most cells of backtests 1 and 3, and the naive forecast in several cells of backtest 2.

Beeswarm on one product scope, VN2 and baseline:

SHAP feature ranking of the backtest model with VN2 features and of the baseline model, on one product scope. Lines join the features both models share. In the VN2 model lag_1 is first and week of year second; robust_z_score is eighth and weeks_since_spike tenth. In the baseline week of year is first and lag_1 second.

Conclusion

The metrics & viz show that the overall performance is better with VN2 features added. Although on a global scale it’s hard to see a huge improvement from these features, the explainability analysis done with our explainability dashboard shows examples where these added VN2 features make a clear difference in the outcome.

Sources

Newsletter subscriptions are not currently available.