| """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), |
| |
| "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) |
|
|
| |
| 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] |
| ) |
|
|
| |
| 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) |
| 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() |
| 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 |
| del feats_wide |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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_mat = day_map[["snap_CA", "snap_TX", "snap_WI"]].to_numpy() |
| 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) |
|
|
| |
| 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) |
| |
| 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() |
|
|