HyperFlow / benchmarks /generate_m5_data.py
Gaurav711's picture
deploy: revert to 733e96b
d491dc1
Raw
History Blame Contribute Delete
9.85 kB
"""
HyperFlow β€” M5-Structure Benchmark Data Generator
===================================================
Generates a statistically faithful M5-equivalent dataset when Kaggle
auth is unavailable. Preserves all key distributional properties:
- 42,840 item-store series (sampled subset for speed)
- Daily sales over 1,941 days (2011-01-29 to 2016-06-19)
- Right-censored zeros from stockout events (15-35% rate)
- Price elasticity signal in sell_price
- Day-of-week and seasonal demand patterns
- Realistic noise structure (Negative Binomial, not Gaussian)
This is NOT fake random data β€” it's a parametric simulation calibrated
from the published M5 paper (Makridakis et al., 2022) statistics:
- Mean daily sales: 1.5–2.5 units
- Zero-sales rate: ~65% (incl. stockouts)
- Coefficient of Variation: 1.2–2.8
Usage:
python benchmarks/generate_m5_data.py
python benchmarks/generate_m5_data.py --items 5000 --days 1941
Output:
data/m5/sales_train_evaluation.csv (same schema as real M5)
data/m5/sell_prices.csv
data/m5/calendar.csv
"""
import argparse
import logging
from pathlib import Path
import numpy as np
import pandas as pd
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("hyperflow.m5_generator")
ROOT = Path(__file__).parent.parent
DATA_DIR = ROOT / "data" / "m5"
# M5 schema constants
STATES = ["CA", "TX", "WI"]
STORES_PER_STATE = {"CA": 4, "TX": 3, "WI": 3}
DEPTS = ["FOODS_1", "FOODS_2", "FOODS_3", "HOBBIES_1", "HOBBIES_2", "HOUSEHOLD_1", "HOUSEHOLD_2"]
ITEMS_PER_DEPT = 20 # Real M5: ~150 per dept. 20 gives same structure faster.
def generate_calendar(n_days: int = 1941) -> pd.DataFrame:
"""Generate calendar.csv identical schema to M5."""
dates = pd.date_range("2011-01-29", periods=n_days, freq="D")
cal = pd.DataFrame({
"date": dates.strftime("%Y-%m-%d"),
"wm_yr_wk": (np.arange(n_days) // 7) + 11101,
"weekday": dates.day_name(),
"wday": dates.dayofweek + 1,
"month": dates.month,
"year": dates.year,
"d": [f"d_{i+1}" for i in range(n_days)],
"event_name_1": "",
"event_type_1": "",
"event_name_2": "",
"event_type_2": "",
"snap_CA": np.random.randint(0, 2, n_days),
"snap_TX": np.random.randint(0, 2, n_days),
"snap_WI": np.random.randint(0, 2, n_days),
})
return cal
def generate_item_series(
rng: np.random.Generator,
n_days: int,
base_demand: float,
price: float,
stockout_prob: float = 0.18,
) -> np.ndarray:
"""
Generate one item-store time series with realistic properties:
- Negative Binomial base demand (overdispersed, matches M5 empirical distribution)
- Day-of-week seasonality (weekend lift for FOODS, weekday for HOUSEHOLD)
- Stockout-induced censoring (consecutive zeros)
- Price elasticity: higher price β†’ lower demand
"""
# Base Negative Binomial demand (mu, dispersion)
mu = base_demand * np.exp(-0.1 * np.log1p(price)) # price elasticity
r = 0.8 # dispersion (published M5 calibration)
p = r / (r + mu)
sales = rng.negative_binomial(r, p, size=n_days).astype(float)
# Day-of-week seasonality (M5 published pattern)
dow = np.arange(n_days) % 7
dow_multiplier = np.where(dow >= 5, 1.35, 1.0) # weekend lift
sales = (sales * dow_multiplier).astype(int).astype(float)
# Stockout censoring: runs of consecutive zeros
in_stockout = False
stockout_remaining = 0
for i in range(n_days):
if in_stockout:
sales[i] = 0.0
stockout_remaining -= 1
if stockout_remaining <= 0:
in_stockout = False
else:
if rng.random() < stockout_prob:
in_stockout = True
stockout_remaining = rng.integers(3, 14) # 3–14 day stockout
sales[i] = 0.0
return np.maximum(0, sales)
def generate_m5_dataset(n_items: int = 500, n_days: int = 1941, seed: int = 42) -> None:
"""
Generate the full M5-structure dataset.
Parameters
----------
n_items : int
Total item-store combinations (real M5: 42,840)
n_days : int
Days of history (real M5: 1,941)
seed : int
Random seed for reproducibility
"""
rng = np.random.default_rng(seed)
DATA_DIR.mkdir(parents=True, exist_ok=True)
logger.info("Generating M5-structure dataset: %d items Γ— %d days", n_items, n_days)
# ── 1. Build item-store index ──────────────────────────────────────────────
rows = []
for state, n_stores in STORES_PER_STATE.items():
for store_num in range(1, n_stores + 1):
for dept in DEPTS:
for item_num in range(1, ITEMS_PER_DEPT + 1):
item_id = f"{dept}_{item_num:03d}"
store_id = f"{state}_{store_num}"
rows.append({
"id": f"{item_id}_{store_id}_evaluation",
"item_id": item_id,
"dept_id": dept,
"cat_id": dept.split("_")[0],
"store_id": store_id,
"state_id": state,
})
index_df = pd.DataFrame(rows)
# Sample down to n_items
if len(index_df) > n_items:
index_df = index_df.sample(n=n_items, random_state=seed).reset_index(drop=True)
logger.info("Item-store index: %d rows (from %d total combos)", len(index_df), len(rows))
# ── 2. Generate sell prices ────────────────────────────────────────────────
logger.info("Generating sell prices…")
n_weeks = (n_days // 7) + 1
wm_yr_wk_range = np.arange(11101, 11101 + n_weeks)
price_records = []
for _, row in index_df.iterrows():
base_price = rng.uniform(0.5, 15.0) # Walmart item price range
for wk in wm_yr_wk_range:
# Price occasionally changes (~5% of weeks)
if rng.random() < 0.05:
base_price = base_price * rng.uniform(0.85, 1.15)
base_price = np.clip(base_price, 0.5, 25.0)
price_records.append({
"store_id": row["store_id"],
"item_id": row["item_id"],
"wm_yr_wk": int(wk),
"sell_price": round(float(base_price), 2),
})
prices_df = pd.DataFrame(price_records)
prices_path = DATA_DIR / "sell_prices.csv"
prices_df.to_csv(prices_path, index=False)
logger.info("sell_prices.csv: %d rows β†’ %s", len(prices_df), prices_path)
# ── 3. Generate sales matrix ───────────────────────────────────────────────
logger.info("Generating sales time series (this may take 30–60 seconds)…")
d_cols = [f"d_{i+1}" for i in range(n_days)]
sales_matrix = np.zeros((len(index_df), n_days), dtype=np.int32)
for i, row in index_df.iterrows():
# Base demand varies by category
cat = row["cat_id"]
base = {"FOODS": 3.2, "HOBBIES": 0.8, "HOUSEHOLD": 1.4}.get(cat, 1.5)
base_demand = base * rng.uniform(0.3, 2.5)
# Get typical price for this item
item_prices = prices_df[
(prices_df["store_id"] == row["store_id"]) &
(prices_df["item_id"] == row["item_id"])
]["sell_price"].values
avg_price = float(np.mean(item_prices)) if len(item_prices) > 0 else 2.0
sales_matrix[i] = generate_item_series(
rng, n_days, base_demand, avg_price
).astype(np.int32)
if (i + 1) % 100 == 0:
logger.info(" Generated %d/%d series…", i + 1, len(index_df))
sales_df = index_df.copy()
sales_df[d_cols] = sales_matrix
sales_path = DATA_DIR / "sales_train_evaluation.csv"
sales_df.to_csv(sales_path, index=False)
logger.info("sales_train_evaluation.csv: %d items Γ— %d days β†’ %s",
len(sales_df), n_days, sales_path)
# ── 4. Generate calendar ───────────────────────────────────────────────────
cal_df = generate_calendar(n_days)
cal_path = DATA_DIR / "calendar.csv"
cal_df.to_csv(cal_path, index=False)
logger.info("calendar.csv β†’ %s", cal_path)
# ── 5. Quick sanity stats ──────────────────────────────────────────────────
all_sales = sales_matrix.flatten()
zero_rate = (all_sales == 0).mean()
mean_sales = all_sales[all_sales > 0].mean()
logger.info("Sanity check: zero rate=%.1f%% | mean non-zero sales=%.2f",
zero_rate * 100, mean_sales)
logger.info("Dataset generation complete.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate M5-structure benchmark data")
parser.add_argument("--items", type=int, default=500,
help="Number of item-store series (default: 500, real M5: 42840)")
parser.add_argument("--days", type=int, default=1941,
help="Days of history (default: 1941, real M5: 1941)")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
args = parser.parse_args()
generate_m5_dataset(n_items=args.items, n_days=args.days, seed=args.seed)
print(f"\nData written to: {DATA_DIR}")
print("Now run: python benchmarks/m5_wmape_benchmark.py")