restockiq / pipeline /prepare_data.py
RV302001's picture
RestockIQ v1: M5 quantile forecasting + reorder decision dashboard
3d4c9ec
Raw
History Blame Contribute Delete
9.84 kB
"""Build the RestockIQ feature table from the three manually-downloaded M5 CSVs.
Reads (fixed paths, no Kaggle API):
data/raw/calendar.csv
data/raw/sell_prices.csv
data/raw/sales_train_evaluation.csv
Writes:
data/features.parquet long feature table for LightGBM training
data/sales_wide.npy (n_series, 1941) float32 sales matrix for recursive forecasting
data/series_meta.parquet one row per series: id, item_id, dept_id, cat_id, store_id, state_id
data/day_map.parquet one row per d: date, wm_yr_wk, dow, month, snap flags, event codes
The melted long table is ~59M rows; dtypes are downcast aggressively (int8/int16/float32,
categoricals) immediately after melting so the whole thing fits in RAM.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import config as cfg
MISSING_DATA_MSG = """
ERROR: missing M5 input file(s):
{missing}
These must be downloaded manually (no API token needed) — see README section
"Manual setup" (spec Section 10c):
1. Go to https://www.kaggle.com/competitions/m5-forecasting-accuracy/data
2. Log in, accept the competition rules, click "Download All".
3. Unzip and place these three files at:
data/raw/calendar.csv
data/raw/sell_prices.csv
data/raw/sales_train_evaluation.csv
"""
def check_raw_files() -> None:
missing = [str(p) for p in (cfg.RAW_CALENDAR, cfg.RAW_PRICES, cfg.RAW_SALES) if not p.exists()]
if missing:
print(MISSING_DATA_MSG.format(missing="\n".join(" - " + m for m in missing)))
sys.exit(1)
def rolling_from_cumsum(vals: np.ndarray, window: int) -> np.ndarray:
"""Mean of the `window` days strictly before each day t (t-window .. t-1), per row.
vals: (n_series, n_days). Returns float32 with NaN where the window is incomplete.
"""
n, t = vals.shape
csum = np.zeros((n, t + 1), dtype=np.float64)
np.cumsum(vals, axis=1, dtype=np.float64, out=csum[:, 1:])
out = np.full((n, t), np.nan, dtype=np.float32)
out[:, window:] = ((csum[:, window:-1] - csum[:, :-window - 1]) / window).astype(np.float32)
return out
def rolling_std_from_cumsum(vals: np.ndarray, window: int) -> np.ndarray:
"""Std (population) of the `window` days strictly before each day t, per row."""
n, t = vals.shape
csum = np.zeros((n, t + 1), dtype=np.float64)
np.cumsum(vals, axis=1, dtype=np.float64, out=csum[:, 1:])
csum2 = np.zeros((n, t + 1), dtype=np.float64)
np.cumsum(vals.astype(np.float64) ** 2, axis=1, dtype=np.float64, out=csum2[:, 1:])
out = np.full((n, t), np.nan, dtype=np.float32)
s = csum[:, window:-1] - csum[:, :-window - 1]
s2 = csum2[:, window:-1] - csum2[:, :-window - 1]
var = s2 / window - (s / window) ** 2
out[:, window:] = np.sqrt(np.clip(var, 0.0, None)).astype(np.float32)
return out
def lag(vals: np.ndarray, k: int) -> np.ndarray:
"""Value k days earlier, per row. NaN where unavailable."""
n, t = vals.shape
out = np.full((n, t), np.nan, dtype=np.float32)
out[:, k:] = vals[:, :-k]
return out
def build_day_map(calendar: pd.DataFrame) -> pd.DataFrame:
"""One row per d_1..d_1941 with calendar-derived per-day features."""
cal = calendar.copy()
cal["d_idx"] = cal["d"].str.slice(2).astype(np.int16)
cal = cal[cal["d_idx"] <= cfg.N_DAYS].sort_values("d_idx").reset_index(drop=True)
day_map = pd.DataFrame(
{
"d": cal["d_idx"],
"date": pd.to_datetime(cal["date"]),
"wm_yr_wk": cal["wm_yr_wk"].astype(np.int32),
# wday: 1=Saturday..7=Friday (M5 convention); keep as-is, it's just a category
"day_of_week": cal["wday"].astype(np.int8),
"month": cal["month"].astype(np.int8),
"snap_CA": cal["snap_CA"].astype(np.int8),
"snap_TX": cal["snap_TX"].astype(np.int8),
"snap_WI": cal["snap_WI"].astype(np.int8),
"event_name": cal["event_name_1"].fillna("none").astype("category"),
"event_type": cal["event_type_1"].fillna("none").astype("category"),
}
)
assert len(day_map) == cfg.N_DAYS, f"expected {cfg.N_DAYS} days, got {len(day_map)}"
return day_map
def build_price_matrix(
prices: pd.DataFrame, series_meta: pd.DataFrame, week_ids: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""(n_series, n_weeks) price matrix + int8 price-change flag matrix.
NaN price = product not offered that week (M5 convention: before first sale).
"""
n_series = len(series_meta)
week_pos = {int(w): i for i, w in enumerate(week_ids)}
n_weeks = len(week_ids)
# calendar extends past d_1941, so sell_prices has weeks beyond our horizon: drop them
prices = prices[prices["wm_yr_wk"].isin(week_pos.keys())]
key_to_row = pd.Series(
np.arange(n_series, dtype=np.int32),
index=series_meta["store_id"].astype(str) + "|" + series_meta["item_id"].astype(str),
)
rows = key_to_row.reindex(prices["store_id"] + "|" + prices["item_id"]).to_numpy()
cols = prices["wm_yr_wk"].map(week_pos).to_numpy()
price_mat = np.full((n_series, n_weeks), np.nan, dtype=np.float32)
valid = ~pd.isna(rows) & ~pd.isna(cols)
price_mat[rows[valid].astype(np.int64), cols[valid].astype(np.int64)] = (
prices["sell_price"].to_numpy(dtype=np.float32)[valid]
)
# price changed vs previous offered week (first offered week counts as no change)
prev = np.roll(price_mat, 1, axis=1)
prev[:, 0] = np.nan
change = ((price_mat != prev) & ~np.isnan(price_mat) & ~np.isnan(prev)).astype(np.int8)
return price_mat, change
def main() -> None:
check_raw_files()
print("Reading calendar and prices ...")
calendar = pd.read_csv(cfg.RAW_CALENDAR)
prices = pd.read_csv(
cfg.RAW_PRICES,
dtype={"store_id": "string", "item_id": "string", "wm_yr_wk": np.int32,
"sell_price": np.float32},
)
day_map = build_day_map(calendar)
print("Reading wide sales table ...")
d_cols = [f"d_{i}" for i in range(1, cfg.N_DAYS + 1)]
sales = pd.read_csv(cfg.RAW_SALES, dtype={c: np.int16 for c in d_cols})
n_series = len(sales)
print(f" {n_series} series x {cfg.N_DAYS} days")
series_meta = sales[["id", "item_id", "dept_id", "cat_id", "store_id", "state_id"]].copy()
for c in series_meta.columns:
series_meta[c] = series_meta[c].astype("category")
vals = sales[d_cols].to_numpy(dtype=np.float32) # (n_series, n_days)
del sales
print("Computing lag / rolling features on the wide matrix ...")
feats_wide = {f"lag_{k}": lag(vals, k) for k in cfg.LAGS}
for w in cfg.ROLL_MEAN_WINDOWS:
feats_wide[f"rolling_mean_{w}"] = rolling_from_cumsum(vals, w)
feats_wide[f"rolling_std_{cfg.ROLL_STD_WINDOW}"] = rolling_std_from_cumsum(
vals, cfg.ROLL_STD_WINDOW
)
print("Building price matrix ...")
week_ids = day_map["wm_yr_wk"].unique()
price_mat, price_change_mat = build_price_matrix(prices, series_meta, week_ids)
week_idx_of_day = pd.Series(
np.arange(len(week_ids)), index=week_ids
)[day_map["wm_yr_wk"]].to_numpy() # (n_days,)
del prices
print("Melting to long format (~59M rows) with downcast dtypes ...")
n_days = cfg.N_DAYS
series_rep = np.repeat(np.arange(n_series, dtype=np.int32), n_days)
d_tile = np.tile(np.arange(1, n_days + 1, dtype=np.int16), n_series)
long = pd.DataFrame({"d": d_tile, "sales": vals.ravel().astype(np.int16)})
for name, mat in feats_wide.items():
long[name] = mat.ravel()
feats_wide[name] = None # free as we go
del feats_wide
# id columns as categoricals built straight from codes (cheap, no string repeat)
for c in ("id", "item_id", "dept_id", "cat_id", "store_id", "state_id"):
col = series_meta[c]
long[c] = pd.Categorical.from_codes(
np.repeat(col.cat.codes.to_numpy(), n_days), categories=col.cat.categories
)
# per-day calendar features via day-index take
day_idx = d_tile.astype(np.int32) - 1
long["day_of_week"] = day_map["day_of_week"].to_numpy()[day_idx]
long["month"] = day_map["month"].to_numpy()[day_idx]
for c in ("event_name", "event_type"):
long[c] = pd.Categorical.from_codes(
day_map[c].cat.codes.to_numpy()[day_idx], categories=day_map[c].cat.categories
)
# snap flag for the series' own state
snap_mat = day_map[["snap_CA", "snap_TX", "snap_WI"]].to_numpy() # (n_days, 3)
state_to_col = {s: i for i, s in enumerate(("CA", "TX", "WI"))}
state_col_idx = series_meta["state_id"].map(state_to_col).to_numpy(dtype=np.int8)
long["snap"] = snap_mat[day_idx, np.repeat(state_col_idx, n_days)].astype(np.int8)
# price + price-change flag
wk = week_idx_of_day[day_idx]
long["sell_price"] = price_mat[series_rep, wk]
long["price_change"] = price_change_mat[series_rep, wk]
del price_mat, price_change_mat, series_rep, day_idx, wk
before = len(long)
# Drop rows where the product wasn't offered (no price -> no possible sale, M5 convention)
long = long[long["sell_price"].notna()]
print(f" dropped {before - len(long):,} not-yet-offered rows -> {len(long):,} rows")
print("Memory usage (MB):", int(long.memory_usage(deep=False).sum() / 1e6))
cfg.DATA_DIR.mkdir(parents=True, exist_ok=True)
print("Writing outputs ...")
long.to_parquet(cfg.FEATURES_PARQUET, index=False)
np.save(cfg.SALES_WIDE_NPY, vals)
series_meta.to_parquet(cfg.SERIES_META_PARQUET, index=False)
day_map.to_parquet(cfg.DAY_MAP_PARQUET, index=False)
print(f"Done. features={cfg.FEATURES_PARQUET} rows={len(long):,}")
if __name__ == "__main__":
main()