BEACON_FORECAST / generate_synthetic_data.py
Darkweb007's picture
Initial commit: adaptive demand forecaster with drift-triggered retraining
d9a2578
Raw
History Blame Contribute Delete
2.94 kB
"""
Generates SYNTHETIC daily SKU-level demand data: trend, weekly seasonality,
yearly seasonality, noise -- and, for one SKU, an injected supply-shock
event partway through the series (a sudden demand drop that persists at a
new, lower baseline) to exercise the drift-detection pipeline. No real
Walmart, retailer, or sales data is used anywhere in this repo.
"""
import csv
import os
from datetime import date, timedelta
import numpy as np
np.random.seed(11)
N_DAYS = 730
START = date(2024, 1, 1)
SKUS = {
"SKU-001-PANTRY": dict(base=180, trend=0.05, weekly_amp=25, yearly_amp=35, noise=12, shock_day=None),
"SKU-002-BEVERAGE": dict(base=260, trend=0.08, weekly_amp=40, yearly_amp=60, noise=18, shock_day=None),
"SKU-003-SEASONAL-DECOR": dict(base=90, trend=0.02, weekly_amp=15, yearly_amp=140, noise=10, shock_day=None),
"SKU-004-ELECTRONICS": dict(base=140, trend=0.06, weekly_amp=20, yearly_amp=30, noise=14, shock_day=500),
}
SHOCK_DROP_PCT = 0.42
SHOCK_RECOVERY_DAYS = 70
SHOCK_PERMANENT_LOSS_PCT = 0.15 # the new baseline never fully recovers -- a supply chain re-shaping, not a blip
def generate_series(cfg, n_days):
days = np.arange(n_days)
trend = cfg["base"] + cfg["trend"] * days
weekday = days % 7
weekly = cfg["weekly_amp"] * np.sin(2 * np.pi * weekday / 7 + 1.5)
yearly = cfg["yearly_amp"] * np.sin(2 * np.pi * days / 365.25 - 1.2)
noise = np.random.normal(0, cfg["noise"], size=n_days)
values = trend + weekly + yearly + noise
if cfg["shock_day"] is not None:
shock_day = cfg["shock_day"]
multiplier = np.ones(n_days)
for d in range(shock_day, n_days):
days_since_shock = d - shock_day
if days_since_shock < SHOCK_RECOVERY_DAYS:
# sharp drop, gradual partial recovery over SHOCK_RECOVERY_DAYS
recovery_frac = days_since_shock / SHOCK_RECOVERY_DAYS
dip = SHOCK_DROP_PCT * (1 - recovery_frac) + SHOCK_PERMANENT_LOSS_PCT * recovery_frac
else:
dip = SHOCK_PERMANENT_LOSS_PCT
multiplier[d] = 1 - dip
values = values * multiplier
return np.maximum(values, 0).round(1)
def main():
out_path = os.path.join(os.path.dirname(__file__), "data", "sku_demand.csv")
rows = []
for sku, cfg in SKUS.items():
series = generate_series(cfg, N_DAYS)
for i, v in enumerate(series):
d = START + timedelta(days=i)
rows.append({"sku": sku, "date": d.isoformat(), "units_sold": v})
with open(out_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["sku", "date", "units_sold"])
writer.writeheader()
writer.writerows(rows)
print(f"Wrote {len(rows)} rows ({len(SKUS)} SKUs x {N_DAYS} days) to {out_path}")
print("SKU-004-ELECTRONICS has an injected supply-shock event at day 500 (~2025-05-15)")
if __name__ == "__main__":
main()