A hands-on time series engineering study on Kaggle’s Store Sales benchmark: unmasking the Root Mean Squared Logarithmic Error metric with null probes, collapsing 87% of baseline error with single-day persistence, unlocking day-of-week seasonality, resolving multi-week sample variance, uncovering Ecuador’s 1,178% back-to-school retail surge, and securing Rank #183 globally (Top 32.9%) on the live public leaderboard before writing a single line of machine learning code.
In our ongoing Pocket Data Science series (Titanic, Spaceship Titanic, House Prices, MNIST Digit Recognizer, and Disaster Tweets NLP), we demonstrated that competitive machine learning is entirely achievable on battery-powered edge hardware using Google Antigravity CLI (agy) inside Termux PRoot on Android.
In this sixth installment, we tackle tabular time series forecasting on Kaggle’s Store Sales – Time Series Forecasting benchmark (54 stores × 33 product families across Ecuador). Across 5 progressive non-ML baseline experiments, our submissions climbed from an uncalibrated null baseline of 4.37970 down to 0.43150, securing Rank #183 out of 553 teams globally (Top 32.9%)—beating 67.1% of competitors through progressive baseline heuristics.
When data scientists approach tabular time series forecasting competitions, the immediate instinct is to jump straight into complex machine learning architectures:
- Building massive multi-lag feature matrices across hundreds of rolling windows.
- Fine-tuning multi-layer LightGBM, CatBoost, or XGBoost gradient boosting decision trees.
- Implementing deep autoregressive architectures (N-BEATS, PatchTST, Temporal Fusion Transformers).
Yet in competitive forecasting and production supply chains alike, jumping straight to gradient boosting without establishing rigorous ground-truth baselines is an expensive trap. Complex models frequently overfit local noise, memorize unrepresentative trends, or fail silently on structural calendar shocks.
The Store Sales – Time Series Forecasting benchmark challenges data scientists to predict unit sales for grocery products sold across Corporación Favorita, the largest supermarket chain in Ecuador.
Accurate demand forecasting in grocery retail is uniquely unforgiving:
- Perishables Spoilage vs Stockouts: Under-forecasting perishables (produce, meats, dairy) leads to costly stockouts and lost revenue, while over-forecasting causes inventory spoilage and margin write-downs.
- Structural Non-Stationarity: Retail demand is subject to sharp semi-monthly payday surges (“quincenas”), regional school calendars, promotional campaigns, and macroeconomic shocks (such as global crude oil fluctuations affecting the Ecuadorian economy).
Kaggle provides six interconnected input files spanning primary transaction series, retail metadata, and macroeconomic indicators:
| Input File | Primary Schema | Role in Forecasting |
|---|---|---|
train.csv |
id, date, store_nbr, family, sales, onpromotion |
Historical sales from 2013-01-01 to 2017-08-15 (>3M rows). Target is sales. |
test.csv |
id, date, store_nbr, family, onpromotion |
Future 16-day forecast horizon (2017-08-16 to 2017-08-31; 28,512 rows). Target withheld. |
stores.csv |
store_nbr, city, state, type, cluster |
Metadata for 54 store branches across 17 cities, grouped into 17 commercial clusters. |
oil.csv |
date, dcoilwtico |
Daily WTI crude oil price; key exogenous macro driver of Ecuadorian purchasing power. |
holidays_events.csv |
date, type, locale, locale_name, description, transferred |
Calendar of national, regional, and municipal holidays, bridge days, and transfers. |
transactions.csv |
date, store_nbr, transactions |
Daily store foot-traffic/receipt counts (train period only; withheld in test). |
Structure of the Primary Series
The forecasting task is structured across two primary categorical hierarchies:
- 54 Physical Stores: Ranging from sprawling multi-department hypermarkets (Type A in Quito and Guayaquil) to small regional convenience outlets (Types D and E).
- 33 Product Families: Encompassing high-velocity grocery staples (
GROCERY I,BEVERAGES,DAIRY), perishables (MEATS,POULTRY,PRODUCE), and seasonal discretionary goods (BOOKS,HOME APPLIANCES,SCHOOL AND OFFICE SUPPLIES).
Cross-multiplying 54 stores by 33 families yields exactly 1,782 simultaneous time series. Across the 16-day test horizon (2017-08-16 to 2017-08-31), the model must predict 1,782 × 16 = 28,512 test rows.
The Submission Format (sample_submission.csv)
The required competition output is a single CSV file with exactly 28,512 rows plus a header, matching the row order and IDs in test.csv:
| Column | Data Type | Constraint & Description |
|---|---|---|
id |
Integer | Unique identifier matching test.csv (range 3000888 to 3029399). |
sales |
Float (float64) |
Predicted non-negative unit sales (≥ 0) for that (date, store_nbr, family) combination. |
The Evaluation Metric: RMSLE
Submissions are evaluated on Root Mean Squared Logarithmic Error (RMSLE):
RMSLE = sqrt( (1 / N) * sum( ( ln(1 + y_hat_i) - ln(1 + y_i) )^2 ) )
Why RMSLE Changes Modeling Strategy: Under standard RMSE, missing 500 units on a 10,000-unit staple incurs a squared penalty of 250,000, while missing 5 units on a 5-unit specialty incurs a penalty of only 25. Under RMSLE, proportional percentage error matters equally across all series. Cross-sectional volume calibration across every store and family is therefore paramount.
All data engineering, historical slicing, statistical aggregations, and Kaggle submissions were executed strictly on edge mobile hardware:
- Host Environment: Android running Termux with a Debian userspace via PRoot Distro on 64-bit ARM (
aarch64). - AI Pair Programmer: Google Antigravity CLI (
agy) executing in autonomous shell sessions. - Stack: Python 3.12, NumPy, Pandas, and the official
kaggleCLI. - Compute Footprint: 100% CPU execution. All aggregations run in under 2 seconds per experiment, requiring less than 250 MB of memory.
Below is the chronological progression of our 5 non-ML baseline submissions evaluated on Kaggle’s live public leaderboard:
| Experiment & Architecture | Kaggle Public RMSLE | Score Delta & Status |
|---|---|---|
| Exp 1: All-Zero Null Baseline | 4.37970 |
Uncalibrated Floor |
| Exp 2: Naive Lag-1 Persistence | 0.56127 |
-3.81843 (87% Error Dropped) |
| Exp 3: Seasonal Naive (Lag-7) | 0.52245 |
-0.03882 (Day-of-Week Seasonality) |
| Exp 4: 4-Week Grouped Mean | 0.44491 |
-0.07754 (Noise Variance Reduction) |
| Exp 5: Domain-Aware Heuristic | 0.43150 |
-0.01341 • Rank #183 / 553 (Top 32.9%) |
In any regression or forecasting challenge, the first disciplined step is submitting an uncalibrated zero probe:
import pandas as pd
sub = pd.read_csv("sample_submission.csv")
sub["sales"] = 0.0
sub.to_csv("submission_null_zeros.csv", index=False)
Mathematical Reverse-Engineering
Submitting all zeros returned a public leaderboard score of 4.37970. Because every predicted value y_hat_i = 0, the log prediction collapses to ln(1 + 0) = 0. The RMSLE equation simplifies directly to:
RMSLE_zero = sqrt( (1 / N) * sum( ln(1 + y_i)^2 ) ) = 4.37970
This single probe mathematically unmasked the exact root mean square norm of the log ground truth across the 28,512 test observations: sqrt(E[ln(1 + y)^2]) = 4.37970. Only 14.5% of store-family series have true zero demand in recent history; forcing the remaining 85.5% active volume to zero established our benchmark floor.
The simplest intuitive non-zero baseline is static persistence: carry forward the sales of the final known training day (2017-08-15, Tuesday) uniformly across all 16 forecast days:
import pandas as pd
train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")
# Extract final training day (2017-08-15)
last_day = train[train["date"] == "2017-08-15"][["store_nbr", "family", "sales"]]
sub = test[["id", "store_nbr", "family"]].merge(last_day, on=["store_nbr", "family"], how="left")
sub["sales"] = sub["sales"].fillna(0.0)
sub[["id", "sales"]].to_csv("submission_lag1.csv", index=False)
The Result: A 3.818-Point Plunge
Submitting submission_lag1.csv scored 0.56127 on the live leaderboard—collapsing 87.2% of total metric error.
The Insight: In multi-series retail forecasting, the largest component of variance across 1,782 series is the cross-sectional scale disparity between store formats and product categories. Calibrating volume scale per (store_nbr, family) eliminates nearly all baseline penalty. However, treating weekends identically to Tuesdays introduces severe structural error.
Retail grocery shopping exhibits strong intra-week periodicity. Weekend grocery baskets surge, while Tuesdays and Wednesdays experience structural lulls. The Seasonal Naive (Lag-7) baseline carries forward the final week of training (2017-08-09 to 2017-08-15), matching each test day of the week to the same day from the preceding week:
import pandas as pd
train = pd.read_csv("train.csv", parse_dates=["date"])
test = pd.read_csv("test.csv", parse_dates=["date"])
last_week = train[(train["date"] >= "2017-08-09") & (train["date"] <= "2017-08-15")].copy()
last_week["dayofweek"] = last_week["date"].dt.dayofweek
test["dayofweek"] = test["date"].dt.dayofweek
sub = test.merge(
last_week[["store_nbr", "family", "dayofweek", "sales"]],
on=["store_nbr", "family", "dayofweek"],
how="left"
)
sub["sales"] = sub["sales"].fillna(0.0)
sub[["id", "sales"]].to_csv("submission_seasonal_naive.csv", index=False)
The Result: Capturing Weekly Consumer Rhythms
Scored 0.52245 on the live leaderboard (an improvement of -0.03882 over Lag-1 Persistence).
The Flaw: Relying on a sample size of N=1 per day of week leaves the model vulnerable to localized noise, delivery delays, and one-off anomalies during the final training week.
To eliminate single-week noise, we expanded our historical estimation window to 28 days (2017-07-19 to 2017-08-15) and computed the grouped arithmetic mean by (store_nbr, family, dayofweek):
import numpy as np
import pandas as pd
train = pd.read_csv("train.csv", parse_dates=["date"])
test = pd.read_csv("test.csv", parse_dates=["date"])
train_4w = train[(train["date"] >= "2017-07-19") & (train["date"] <= "2017-08-15")].copy()
train_4w["dayofweek"] = train_4w["date"].dt.dayofweek
mean_4w = train_4w.groupby(["store_nbr", "family", "dayofweek"])["sales"].mean().reset_index()
mean_4w.rename(columns={"sales": "pred_sales"}, inplace=True)
test["dayofweek"] = test["date"].dt.dayofweek
sub = test.merge(mean_4w, on=["store_nbr", "family", "dayofweek"], how="left")
sub["sales"] = np.clip(sub["pred_sales"].fillna(0.0), 0, None)
sub[["id", "sales"]].to_csv("submission_grouped_mean_4w.csv", index=False)
The Result: Noise Reduction at Scale
Public score dropped from 0.52245 to 0.44491 (a massive plunge of -0.07754 RMSLE points).
Why Arithmetic Mean Beat Geometric/Log Mean: In local CV sweeps, geometric averaging in log space scored 0.53977 (inferior to arithmetic mean’s 0.53113). Because retail grocery volume is right-skewed, geometric averaging drags predictions down whenever sporadic zero-demand days occur, failing to preserve replenishment volume.
Can non-ML baselines climb further? We audited the training data for structural domain anomalies across three dimensions:
1. Optimal Window Duration
Cross-validation across 3 temporal validation folds demonstrated that a 42-day window (6 weeks) minimizes CV error (0.45989 vs 0.46611 on 28 days), stabilizing day-of-week averages without incorporating outdated seasonal patterns.
2. The 1,178% “Regreso a Clases” Anomaly
When auditing category shifts from July to August, 32 families remained steady (0.99x to 1.21x). However, SCHOOL AND OFFICE SUPPLIES exploded by 1,178% (from 5.25 units/store in July to 61.94 units/store in August). In Ecuador’s Sierra region, the academic year begins in late August/early September (“Regreso a Clases”). A multi-week window including July severely dilutes this spike. We anchored SCHOOL AND OFFICE SUPPLIES strictly to the active August season (2017-08-01 to 2017-08-15).
3. The Promotion-Guarded Dead Catalog Mask
In the final 14 days of training, 144 series had strictly 0 sales. However, 33 of these dead series had active promotions (onpromotion > 0) in the test set. We formulated a promotion-guarded zero mask: only series with 0 sales in the last 14 days and 0 promotions in the test window were masked to 0.0 (pruning 2,278 predictions safely).
import numpy as np
import pandas as pd
train = pd.read_csv("train.csv", parse_dates=["date"])
test = pd.read_csv("test.csv", parse_dates=["date"])
test["dayofweek"] = test["date"].dt.dayofweek
# 1. 42-day grouped mean for baseline families (2017-07-05 to 2017-08-15)
tr_42d = train[(train["date"] >= "2017-07-05") & (train["date"] <= "2017-08-15")].copy()
tr_42d["dayofweek"] = tr_42d["date"].dt.dayofweek
mean_42d = tr_42d.groupby(["store_nbr", "family", "dayofweek"])["sales"].mean().reset_index()
mean_42d.rename(columns={"sales": "pred_sales"}, inplace=True)
# 2. August window for SCHOOL AND OFFICE SUPPLIES (2017-08-01 to 2017-08-15)
tr_school = train[(train["date"] >= "2017-08-01") & (train["date"] <= "2017-08-15") & (train["family"] == "SCHOOL AND OFFICE SUPPLIES")].copy()
tr_school["dayofweek"] = tr_school["date"].dt.dayofweek
mean_school = tr_school.groupby(["store_nbr", "family", "dayofweek"])["sales"].mean().reset_index()
mean_school.rename(columns={"sales": "pred_school"}, inplace=True)
# Merge base predictions and override school supplies
sub = test.merge(mean_42d, on=["store_nbr", "family", "dayofweek"], how="left")
sub = sub.merge(mean_school, on=["store_nbr", "family", "dayofweek"], how="left")
sub["pred_sales"] = sub["pred_school"].combine_first(sub["pred_sales"])
# 3. Promotion-guarded dead catalog mask (last 14 days)
tr_14d = train[train["date"] >= "2017-08-02"].groupby(["store_nbr", "family"])["sales"].sum().reset_index()
dead_series = tr_14d[tr_14d["sales"] == 0][["store_nbr", "family"]]
dead_series["is_dead"] = True
sub = sub.merge(dead_series, on=["store_nbr", "family"], how="left")
dead_mask = (sub["is_dead"] == True) & (sub["onpromotion"] == 0)
sub.loc[dead_mask, "pred_sales"] = 0.0
sub["sales"] = np.clip(sub["pred_sales"].fillna(0.0), 0, None)
sub[["id", "sales"]].to_csv("submission_domain_heuristic.csv", index=False)
Leaderboard Result: 0.43150 RMSLE
Submitting submission_domain_heuristic.csv scored 0.43150, securing Rank #183 out of 553 teams globally (Top 32.9%)—beating 67.1% of competitors before introducing machine learning models.
- Probe the loss metric directly: An all-zero null probe reverse-engineers the root-mean-square norm of unseen ground truth (
4.37970), establishing the definitive mathematical ceiling. - Persistence establishes scale: Cross-sectional volume disparities represent the single largest component of forecast variance. Copying the latest observed day eliminates 87% of error.
- Day-of-week grouping eliminates calendar noise: Retail time series are heavily driven by weekly consumer habits. Aggregating over 4 to 6 weeks provides an optimal bias-variance trade-off.
- Audit seasonal calendar regimes before training models: A model unaware of regional academic calendars will fail during back-to-school spikes. Isolating category-specific seasonal windows delivers greater performance jumps than tuning tree hyperparameters.
- Guard zero-masks with promotion signals: Blindly setting inactive series to zero causes catastrophic errors during promotional re-launches. Combining recent demand silence with active promotional intent preserves upcoming sales spikes.
To reproduce all 5 baseline experiments inside Android Termux:
# Setup workspace & download data
mkdir -p ~/kaggle_store_sales && cd ~/kaggle_store_sales
kaggle competitions download -c store-sales-time-series-forecasting
unzip store-sales-time-series-forecasting.zip
# Run domain heuristic pipeline
python3 submission_domain_heuristic.py
# Submit to Kaggle
kaggle competitions submit -c store-sales-time-series-forecasting \
-f submission_domain_heuristic.csv \
-m "Domain Heuristic 42d Mean + School Window + Dead Mask"
Share this:
- Share on Facebook (Opens in new window) Facebook
- Email a link to a friend (Opens in new window) Email
- Share on LinkedIn (Opens in new window) LinkedIn
- Share on Telegram (Opens in new window) Telegram
- Share on WhatsApp (Opens in new window) WhatsApp
- More
- Share on Bluesky (Opens in new window) Bluesky
- Print (Opens in new window) Print
- Share on Threads (Opens in new window) Threads
- Share on Reddit (Opens in new window) Reddit
- Share on Tumblr (Opens in new window) Tumblr
- Share on Pinterest (Opens in new window) Pinterest
- Share on X (Opens in new window) X
Leave a Reply