| |
| """Evaluate RestockIQ's frozen production forecast artifacts on June 2024. |
| |
| This runner deliberately imports the production snapshot builder, feature |
| engineering, artifact loader, and inference code from the pinned Git checkout. |
| Oracle latent demand is loaded separately and used only after predictions have |
| been produced. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import math |
| import os |
| import platform |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| from datetime import date, timedelta |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import pandas as pd |
| import lightgbm as lgb |
| import sqlalchemy |
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import Session |
|
|
|
|
| DEFAULT_EXPECTED_GIT_SHA = "06ae958730f87cc44b3a5dbb7094fb8a3c88f7a3" |
| DEFAULT_EXPECTED_WORKBOOK_SHA256 = ( |
| "9d13eb22b2d2a2acecc46661d4984420e8390861a3bf9c0590cae57f9f5858e0" |
| ) |
| DATASET_ID = "demo-retail-v1" |
| HORIZONS = (1, 7, 14) |
| EVALUATION_START = date(2024, 6, 1) |
| EVALUATION_END = date(2024, 6, 30) |
| ORACLE_LABEL = "units_demanded_est" |
| ORACLE_FORBIDDEN_FEATURES = { |
| "avg_daily_demand_per_store", |
| "cash_locked_in_stock_rp", |
| "demand_profile", |
| "units_demanded_est", |
| } |
|
|
|
|
| def sha256_file(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for block in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(block) |
| return digest.hexdigest() |
|
|
|
|
| def git_output(repo_root: Path, *args: str) -> str: |
| return subprocess.check_output( |
| ["git", "-C", str(repo_root), *args], text=True |
| ).strip() |
|
|
|
|
| def croston_daily(history: np.ndarray, alpha: float = 0.1) -> float: |
| """Causal Croston estimate after consuming all supplied observations.""" |
| level = 0.0 |
| interval = 1.0 |
| elapsed = 0 |
| initialized = False |
| for value in np.asarray(history, dtype=float): |
| elapsed += 1 |
| if value > 0: |
| if not initialized: |
| level = float(value) |
| interval = float(max(elapsed, 1)) |
| initialized = True |
| else: |
| level = alpha * float(value) + (1.0 - alpha) * level |
| interval = alpha * float(elapsed) + (1.0 - alpha) * interval |
| elapsed = 0 |
| return max(0.0, level / interval) if initialized and interval > 0 else 0.0 |
|
|
|
|
| def causal_baselines(history: pd.Series, horizon: int) -> dict[str, float]: |
| values = pd.to_numeric(history, errors="coerce").fillna(0).clip(lower=0) |
| if values.empty: |
| return { |
| "seasonal_naive_7d": 0.0, |
| "moving_average_28d": 0.0, |
| "ewma_28d": 0.0, |
| "croston_01": 0.0, |
| } |
|
|
| last_week = values.tail(7).to_numpy(dtype=float) |
| seasonal = float( |
| sum(last_week[index % len(last_week)] for index in range(horizon)) |
| ) |
| moving_average = float(values.tail(28).mean() * horizon) |
| ewma = float( |
| values.ewm(span=28, adjust=False, min_periods=1).mean().iloc[-1] |
| * horizon |
| ) |
| croston = float(croston_daily(values.to_numpy(dtype=float)) * horizon) |
| return { |
| "seasonal_naive_7d": seasonal, |
| "moving_average_28d": moving_average, |
| "ewma_28d": ewma, |
| "croston_01": croston, |
| } |
|
|
|
|
| def regression_metrics(actual: np.ndarray, prediction: np.ndarray) -> dict[str, Any]: |
| y = np.asarray(actual, dtype=float) |
| p = np.asarray(prediction, dtype=float) |
| error = p - y |
| nonzero = y != 0 |
| return { |
| "n": int(len(y)), |
| "actual_sum": float(y.sum()), |
| "prediction_sum": float(p.sum()), |
| "mae": float(np.mean(np.abs(error))), |
| "rmse": float(np.sqrt(np.mean(np.square(error)))), |
| "wmape": float(np.abs(error).sum() / max(np.abs(y).sum(), 1e-12)), |
| "wmape_percent": float( |
| 100.0 * np.abs(error).sum() / max(np.abs(y).sum(), 1e-12) |
| ), |
| "bias": float(np.mean(error)), |
| "mape_nonzero": ( |
| float(np.mean(np.abs(error[nonzero] / y[nonzero]))) |
| if bool(nonzero.any()) |
| else None |
| ), |
| "mape_nonzero_percent": ( |
| float(100.0 * np.mean(np.abs(error[nonzero] / y[nonzero]))) |
| if bool(nonzero.any()) |
| else None |
| ), |
| "zero_actual_rows": int((~nonzero).sum()), |
| } |
|
|
|
|
| def pinball(actual: np.ndarray, prediction: np.ndarray, alpha: float) -> float: |
| residual = np.asarray(actual, dtype=float) - np.asarray(prediction, dtype=float) |
| return float(np.mean(np.maximum(alpha * residual, (alpha - 1.0) * residual))) |
|
|
|
|
| def atomic_json(path: Path, value: Any) -> None: |
| temp = path.with_suffix(path.suffix + ".tmp") |
| temp.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") |
| temp.replace(path) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| description="Exact-artifact RestockIQ forecast evaluation" |
| ) |
| parser.add_argument("--repo-root", type=Path, required=True) |
| parser.add_argument("--output-dir", type=Path, required=True) |
| parser.add_argument("--workbook", type=Path) |
| parser.add_argument( |
| "--expected-git-sha", default=DEFAULT_EXPECTED_GIT_SHA |
| ) |
| parser.add_argument( |
| "--expected-workbook-sha256", default=DEFAULT_EXPECTED_WORKBOOK_SHA256 |
| ) |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| repo_root = args.repo_root.expanduser().resolve() |
| output_dir = args.output_dir.expanduser().resolve() |
| workbook = ( |
| args.workbook.expanduser().resolve() |
| if args.workbook |
| else repo_root |
| / "backend" |
| / "data" |
| / "synthetic" |
| / "RestockIQ_Dataset_Sintetis.xlsx" |
| ) |
| artifact_dir = repo_root / "backend" / "artifacts" / "restockiq-demand-v1" |
|
|
| if not (repo_root / ".git").exists(): |
| raise SystemExit(f"Not a Git checkout: {repo_root}") |
| if not workbook.is_file(): |
| raise SystemExit(f"Workbook not found: {workbook}") |
| if not (artifact_dir / "manifest.json").is_file(): |
| raise SystemExit(f"Frozen artifact manifest not found: {artifact_dir}") |
|
|
| git_sha = git_output(repo_root, "rev-parse", "HEAD") |
| if git_sha != args.expected_git_sha: |
| raise SystemExit( |
| f"Git SHA mismatch: expected {args.expected_git_sha}, got {git_sha}. " |
| "Do not silently evaluate another release." |
| ) |
| workbook_sha = sha256_file(workbook) |
| if workbook_sha != args.expected_workbook_sha256: |
| raise SystemExit( |
| "Workbook SHA-256 mismatch: " |
| f"expected {args.expected_workbook_sha256}, got {workbook_sha}" |
| ) |
|
|
| backend_root = repo_root / "backend" |
| sys.path.insert(0, str(backend_root)) |
| os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") |
|
|
| from app.db.seed import seed |
| from app.ml.artifact_store import load_model_artifacts |
| from app.ml.demand_engine import generate_demand_forecasts |
| from app.services.retail_snapshot_service import build_retail_snapshot |
|
|
| artifacts = load_model_artifacts(artifact_dir, force_reload=True) |
| manifest = artifacts.manifest |
| used_oracle = set(manifest.get("oracle_fields_used_as_features", [])) |
| if used_oracle: |
| raise SystemExit(f"Artifact manifest declares Oracle features: {used_oracle}") |
| declared_features = set(artifacts.reconstruction_feature_columns) |
| for artifact in artifacts.forecasts.values(): |
| declared_features.update(artifact.feature_columns) |
| leaked_features = declared_features & ORACLE_FORBIDDEN_FEATURES |
| if leaked_features: |
| raise SystemExit(f"Oracle feature leak detected: {sorted(leaked_features)}") |
| if manifest.get("training_cutoff") != "2024-05-31": |
| raise SystemExit( |
| f"Unexpected training cutoff: {manifest.get('training_cutoff')}" |
| ) |
|
|
| sales = pd.read_excel(workbook, sheet_name="Fact_Daily_Sales") |
| stores = pd.read_excel(workbook, sheet_name="Dim_Stores") |
| sales["date"] = pd.to_datetime(sales["date"]).dt.date |
| required_columns = { |
| "date", |
| "store_id", |
| "sku_id", |
| "units_sold", |
| ORACLE_LABEL, |
| } |
| missing_columns = required_columns - set(sales.columns) |
| if missing_columns: |
| raise SystemExit(f"Workbook missing evaluation columns: {missing_columns}") |
|
|
| origin_end = EVALUATION_END - timedelta(days=min(HORIZONS)) |
| origins = list( |
| pd.date_range(EVALUATION_START, origin_end, freq="D").date |
| ) |
| prediction_rows: list[dict[str, Any]] = [] |
|
|
| with tempfile.TemporaryDirectory(prefix="restockiq-eval-") as temp_dir: |
| database_path = Path(temp_dir) / "evaluation.sqlite3" |
| database_url = f"sqlite:///{database_path}" |
| seed(database_url=database_url, workbook_path=workbook) |
| engine = create_engine(database_url) |
|
|
| with Session(engine) as db: |
| for origin_index, origin in enumerate(origins, start=1): |
| eligible_horizons = [ |
| horizon |
| for horizon in HORIZONS |
| if origin + timedelta(days=horizon) <= EVALUATION_END |
| ] |
| if not eligible_horizons: |
| continue |
| print( |
| f"[{origin_index:02d}/{len(origins):02d}] origin={origin} " |
| f"horizons={eligible_horizons}", |
| flush=True, |
| ) |
|
|
| for store_id in stores["store_id"].astype(str).sort_values(): |
| snapshot = build_retail_snapshot( |
| db, |
| dataset_id=DATASET_ID, |
| store_id=store_id, |
| decision_date=origin, |
| horizon_days=max(eligible_horizons), |
| lookback_days=182, |
| ) |
| result = generate_demand_forecasts( |
| snapshot, |
| artifacts, |
| horizon_days=max(eligible_horizons), |
| ) |
|
|
| for forecast in result.forecasts: |
| sku_id = forecast.sku_id |
| sku_history = sales.loc[ |
| (sales["store_id"].astype(str) == store_id) |
| & (sales["sku_id"].astype(str) == sku_id) |
| & (sales["date"] <= origin) |
| ].sort_values("date") |
|
|
| for horizon in eligible_horizons: |
| target_start = origin + timedelta(days=1) |
| target_end = origin + timedelta(days=horizon) |
| target_rows = sales.loc[ |
| (sales["store_id"].astype(str) == store_id) |
| & (sales["sku_id"].astype(str) == sku_id) |
| & (sales["date"] >= target_start) |
| & (sales["date"] <= target_end) |
| ] |
| if len(target_rows) != horizon: |
| raise SystemExit( |
| "Incomplete Oracle target window for " |
| f"{origin}/{store_id}/{sku_id}/H{horizon}: " |
| f"expected {horizon} rows, got {len(target_rows)}" |
| ) |
| actual = float(target_rows[ORACLE_LABEL].sum()) |
| quantiles = forecast.forecasts[horizon] |
| baselines = causal_baselines( |
| sku_history["units_sold"], horizon |
| ) |
| prediction_rows.append( |
| { |
| "decision_date": origin.isoformat(), |
| "target_start": target_start.isoformat(), |
| "target_end": target_end.isoformat(), |
| "store_id": store_id, |
| "sku_id": sku_id, |
| "horizon_days": horizon, |
| "actual_oracle_demand": actual, |
| "frozen_product_q10": quantiles.q10, |
| "frozen_product_q50": quantiles.q50, |
| "frozen_product_q90": quantiles.q90, |
| **baselines, |
| "model_version": result.model_version, |
| "training_cutoff": manifest["training_cutoff"], |
| "git_sha": git_sha, |
| } |
| ) |
|
|
| predictions = pd.DataFrame(prediction_rows).sort_values( |
| ["horizon_days", "decision_date", "store_id", "sku_id"], |
| kind="stable", |
| ) |
| if predictions.empty: |
| raise SystemExit("Evaluation produced no prediction rows") |
|
|
| expected_rows = sum( |
| ( |
| EVALUATION_END |
| - (EVALUATION_START + timedelta(days=horizon)) |
| ).days |
| + 1 |
| for horizon in HORIZONS |
| ) * len(stores) * sales["sku_id"].nunique() |
| if len(predictions) != expected_rows: |
| raise SystemExit( |
| f"Unexpected prediction count: expected {expected_rows}, " |
| f"got {len(predictions)}" |
| ) |
|
|
| point_models = [ |
| "frozen_product_q50", |
| "seasonal_naive_7d", |
| "moving_average_28d", |
| "ewma_28d", |
| "croston_01", |
| ] |
| metric_rows: list[dict[str, Any]] = [] |
| store_metric_rows: list[dict[str, Any]] = [] |
| quantile_rows: list[dict[str, Any]] = [] |
|
|
| for horizon, frame in predictions.groupby("horizon_days", sort=True): |
| y = frame["actual_oracle_demand"].to_numpy(dtype=float) |
| for model_name in point_models: |
| metrics = regression_metrics( |
| y, frame[model_name].to_numpy(dtype=float) |
| ) |
| metric_rows.append( |
| {"horizon_days": int(horizon), "model": model_name, **metrics} |
| ) |
|
|
| q10 = frame["frozen_product_q10"].to_numpy(dtype=float) |
| q50 = frame["frozen_product_q50"].to_numpy(dtype=float) |
| q90 = frame["frozen_product_q90"].to_numpy(dtype=float) |
| crossing_rows = int(((q10 > q50) | (q50 > q90)).sum()) |
| quantile_rows.append( |
| { |
| "horizon_days": int(horizon), |
| "n": int(len(frame)), |
| "coverage_80": float(np.mean((y >= q10) & (y <= q90))), |
| "coverage_80_percent": float( |
| 100.0 * np.mean((y >= q10) & (y <= q90)) |
| ), |
| "mean_interval_width": float(np.mean(q90 - q10)), |
| "pinball_q10": pinball(y, q10, 0.10), |
| "pinball_q50": pinball(y, q50, 0.50), |
| "pinball_q90": pinball(y, q90, 0.90), |
| "quantile_crossing_rows": crossing_rows, |
| } |
| ) |
|
|
| for store_id, store_frame in frame.groupby("store_id", sort=True): |
| store_y = store_frame["actual_oracle_demand"].to_numpy(dtype=float) |
| for model_name in point_models: |
| metrics = regression_metrics( |
| store_y, store_frame[model_name].to_numpy(dtype=float) |
| ) |
| store_metric_rows.append( |
| { |
| "horizon_days": int(horizon), |
| "store_id": str(store_id), |
| "model": model_name, |
| **metrics, |
| } |
| ) |
|
|
| metrics = pd.DataFrame(metric_rows).sort_values( |
| ["horizon_days", "wmape", "model"], kind="stable" |
| ) |
| store_metrics = pd.DataFrame(store_metric_rows).sort_values( |
| ["horizon_days", "store_id", "wmape", "model"], kind="stable" |
| ) |
| quantile_metrics = pd.DataFrame(quantile_rows).sort_values("horizon_days") |
|
|
| comparisons: list[dict[str, Any]] = [] |
| for horizon, frame in metrics.groupby("horizon_days", sort=True): |
| product = frame.loc[frame["model"] == "frozen_product_q50"].iloc[0] |
| baselines = frame.loc[frame["model"] != "frozen_product_q50"] |
| best = baselines.sort_values("wmape", kind="stable").iloc[0] |
| comparisons.append( |
| { |
| "horizon_days": int(horizon), |
| "product_wmape_percent": float(product["wmape_percent"]), |
| "best_baseline": str(best["model"]), |
| "best_baseline_wmape_percent": float(best["wmape_percent"]), |
| "relative_wmape_improvement_vs_best_baseline_percent": float( |
| 100.0 * (best["wmape"] - product["wmape"]) / best["wmape"] |
| ), |
| "product_beats_best_baseline": bool(product["wmape"] < best["wmape"]), |
| } |
| ) |
| comparison_frame = pd.DataFrame(comparisons) |
|
|
| output_dir.mkdir(parents=True, exist_ok=True) |
| evaluator_copy = output_dir / "evaluate_frozen_forecasts.py" |
| shutil.copy2(Path(__file__).resolve(), evaluator_copy) |
| predictions_path = output_dir / "predictions.csv" |
| metrics_path = output_dir / "metrics.csv" |
| store_metrics_path = output_dir / "metrics_by_store.csv" |
| quantile_path = output_dir / "quantile_metrics.csv" |
| comparison_path = output_dir / "baseline_comparison.csv" |
| predictions.to_csv(predictions_path, index=False) |
| metrics.to_csv(metrics_path, index=False) |
| store_metrics.to_csv(store_metrics_path, index=False) |
| quantile_metrics.to_csv(quantile_path, index=False) |
| comparison_frame.to_csv(comparison_path, index=False) |
|
|
| manifest_out = { |
| "evaluation_name": "RestockIQ exact frozen-artifact June 2024 evaluation", |
| "status": "completed_synthetic_controlled_evaluation", |
| "git_sha": git_sha, |
| "git_remote": git_output(repo_root, "remote", "get-url", "origin"), |
| "git_status_porcelain": git_output(repo_root, "status", "--porcelain=v1"), |
| "evaluator_sha256": sha256_file(evaluator_copy), |
| "artifact_manifest_sha256": sha256_file(artifact_dir / "manifest.json"), |
| "workbook": workbook.name, |
| "workbook_sha256": workbook_sha, |
| "dataset_id": DATASET_ID, |
| "evaluation_period": [ |
| EVALUATION_START.isoformat(), |
| EVALUATION_END.isoformat(), |
| ], |
| "origin_rule": "decision date t; target is Oracle t+1 through t+H", |
| "label": ORACLE_LABEL, |
| "label_role": "synthetic Oracle evaluation-only; never a model feature", |
| "model_version": artifacts.version, |
| "training_cutoff": manifest["training_cutoff"], |
| "training_data_hash": manifest["training_data_hash"], |
| "oracle_fields_used_as_features": sorted(used_oracle), |
| "horizons": list(HORIZONS), |
| "stores": int(len(stores)), |
| "skus": int(sales["sku_id"].nunique()), |
| "prediction_rows": int(len(predictions)), |
| "runtime_versions": { |
| "python": platform.python_version(), |
| "numpy": np.__version__, |
| "pandas": pd.__version__, |
| "scipy": __import__("scipy").__version__, |
| "lightgbm": lgb.__version__, |
| "sqlalchemy": sqlalchemy.__version__, |
| }, |
| "baseline_definitions": { |
| "seasonal_naive_7d": "repeat the last seven observed-sales days causally", |
| "moving_average_28d": "28-day observed-sales mean times H", |
| "ewma_28d": "causal span-28 observed-sales EWMA times H", |
| "croston_01": "causal Croston alpha=0.1 daily rate times H", |
| }, |
| "limitations": [ |
| "Synthetic controlled evaluation; not evidence of real merchant impact.", |
| "Oracle latent demand is used only as an evaluation label.", |
| "Metrics apply to the pinned frozen artifact and workbook only.", |
| "No stockout reduction, savings, revenue, ROI, or service-level claim is produced.", |
| ], |
| } |
| atomic_json(output_dir / "run_manifest.json", manifest_out) |
|
|
| metrics_payload = { |
| "point_metrics": metrics.to_dict(orient="records"), |
| "quantile_metrics": quantile_metrics.to_dict(orient="records"), |
| "baseline_comparison": comparison_frame.to_dict(orient="records"), |
| } |
| atomic_json(output_dir / "metrics.json", metrics_payload) |
|
|
| summary_lines = [ |
| "# RestockIQ Frozen-Artifact Forecast Evaluation", |
| "", |
| "**Status:** controlled synthetic evaluation completed.", |
| "", |
| f"- Git SHA: `{git_sha}`", |
| f"- Model: `{artifacts.version}`", |
| f"- Training cutoff: `{manifest['training_cutoff']}`", |
| f"- Workbook SHA-256: `{workbook_sha}`", |
| f"- Prediction rows: `{len(predictions):,}`", |
| "- Target: synthetic Oracle demand over `t+1..t+H`.", |
| "- Oracle fields used as model features: `none`.", |
| "", |
| "## Point metrics", |
| "", |
| metrics.to_markdown(index=False, floatfmt=".4f"), |
| "", |
| "## Quantile diagnostics", |
| "", |
| quantile_metrics.to_markdown(index=False, floatfmt=".4f"), |
| "", |
| "## Comparison with the strongest tested baseline", |
| "", |
| comparison_frame.to_markdown(index=False, floatfmt=".4f"), |
| "", |
| "## Claim boundary", |
| "", |
| "These results validate the pinned forecasting artifact only on the controlled synthetic June 2024 window. They do not demonstrate realized stockout reduction, savings, revenue uplift, service-level improvement, ROI, or generalization to real merchants.", |
| "", |
| ] |
| (output_dir / "EVALUATION_SUMMARY.md").write_text("\n".join(summary_lines)) |
|
|
| checksum_files = sorted( |
| path |
| for path in output_dir.iterdir() |
| if path.is_file() and path.name != "SHA256SUMS" |
| ) |
| (output_dir / "SHA256SUMS").write_text( |
| "\n".join(f"{sha256_file(path)} {path.name}" for path in checksum_files) |
| + "\n" |
| ) |
|
|
| print("\nEvaluation completed.") |
| print(metrics.to_string(index=False)) |
| print("\nQuantile diagnostics:") |
| print(quantile_metrics.to_string(index=False)) |
| print(f"\nOutputs: {output_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|