"""Write 06_Model_Review/data: before/after per site figure (CSV + JSON), every test table, improved picks and churn scores.""" import json, pickle, os, shutil import numpy as np, pandas as pd W = '/home/claude/binzagr/boost/work' OUT = '/mnt/user-data/outputs/Binzagr_Data/06_Model_Review' D = f'{OUT}/data'; os.makedirs(D, exist_ok=True); os.makedirs(f'{OUT}/scripts', exist_ok=True) fc = pd.read_csv(f'{W}/fc_summary.csv').set_index('level') fce = pd.read_csv(f'{W}/fc_eval.csv') rr = pickle.load(open(f'{W}/reco_race.pkl', 'rb')); rf = pickle.load(open(f'{W}/reco_final_v2.pkl', 'rb')) ch = pickle.load(open(f'{W}/churn_race.pkl', 'rb')); ad = pickle.load(open(f'{W}/adoption_race.pkl', 'rb')) CT = ch['table'].set_index('model'); AT = ad['table'].set_index('model'); RT = rr['table'].set_index('strategy') res = rf['res']; pick = rf['pick'] pick_key = 'ranker' if pick == 'ranker' else 'blend' pick_name = 'LightGBM ranker over EASE, RP3beta and the current scores' if pick == 'ranker' else 'Blend of EASE, RP3beta and the current scores' p10_copy, p10_after = res['site_style']['test_p10'], res[pick_key]['test_p10'] ci = res[pick_key]['gain_90pct_interval'] p5_copy = RT.loc['Site-style blend (similar baskets + van + popularity)', 'test_p5'] p5_after = RT.loc['LightGBM ranker on all scores' if pick == 'ranker' else 'Blend: EASE + RP3beta + site blend + van', 'test_p5'] hit_copy = res['site_style']['outlets_hit']; hit_after = res[pick_key]['outlets_hit'] # ---- value levers (estimates; the site's levers scale with the evidence they cite) fva_site = 1 - 0.6686 / 0.8751 # site SKU raw weekly run vs repeat-last-week fva_same_bias = 1 - fc.loc['sku', 'picked_at_site_bias'] / fc.loc['sku', 'naive7'] fva_zero_bias = 1 - 0.7409 / fc.loc['sku', 'naive7'] ss_base, ss_oneoff = 65178.74, 296267.01 ws_base, churn_base = 104524.0, 47302.0 lever = { 'safety_same_bias': ss_base * fva_same_bias / fva_site, 'safety_oneoff_same_bias': ss_oneoff * fva_same_bias / fva_site, 'safety_zero_bias': ss_base * fva_zero_bias / fva_site, 'safety_oneoff_zero_bias': ss_oneoff * fva_zero_bias / fva_site, 'whitespace': ws_base * p5_after / p5_copy, 'churn_upper': churn_base * CT.loc['Extra trees', 'test_top20_capture'] / CT.loc['LightGBM', 'test_top20_capture'], } lever['total_change'] = (lever['safety_same_bias'] - ss_base) + (lever['whitespace'] - ws_base) + (lever['churn_upper'] - churn_base) def r(x, d=1): return None if x is None else round(float(x), d) rows = [ dict(id='FC-SKU', page='Forecast proof', figure='SKU weekly forecast error (548 SKUs, 4 unseen weeks)', unit='%', before=66.9, before_note='site, raw sales, weekly run, bias -17.2% (page shows 63.8% on base demand)', after=r(100 * fc.loc['sku', 'picked_at_site_bias']), after_note='one equal blend for all SKUs: LightGBM weekly + LightGBM daily + IMAPA, at the same -17% bias ' f"(as fitted: {100 * fc.loc['sku', 'picked_wape']:.1f}% at {100 * fc.loc['sku', 'picked_bias']:.0f}% bias)", change='small boost', how='same data, series, weeks and baselines as the site; rule picked on validation (28 Jul, 25 Aug); hold-out used once; compared at equal bias', action='Replace the per-SKU 3-model race with one equal blend of the best three models for all SKUs; show bias next to the error'), dict(id='FC-SKU-HEADLINE', page='Forecast proof, Value cockpit', figure='Less SKU error than last week repeated', unit='%', before=22.0, before_note='site headline (base demand); 23.6% on raw sales at -17% bias', after=r(100 * fva_zero_bias), after_note='the best blend scaled to zero bias: 74.1% error vs 87.6% for repeat-last-week', change='goes down if forecasts must be unbiased', how='blend scaled until its hold-out total equals actual sales', action='Show bias; quote the gain at a stated bias'), dict(id='FC-VAN', page='Forecast proof', figure='Van weekly forecast error (92 vans)', unit='%', before=40.4, before_note='site, raw sales, daily run, bias -10.4%', after=r(100 * fc.loc['van', 'picked_at_site_bias']), after_note='best combination without foundation models (median of the pool), at the same bias', change='keep the site version', how='as FC-SKU', action='Keep the per-van blends (the foundation models help here)'), dict(id='FC-VAN-LABEL', page='Forecast proof', figure='Van error shown as 38.6% (tile) and 40.0% (level table)', unit='%', before=38.6, before_note='tile from the daily run; table from the weekly run', after=40.0, after_note='one run, chosen before the test', change='relabel', how='read from the site', action='Show one run'), dict(id='FC-PRINCIPAL', page='Forecast proof', figure='Principal weekly forecast error (16 principals)', unit='%', before=28.3, before_note='site, raw sales, daily run, bias -12.7% (weekly run 30.9%; 4-week weekday average 30.4%)', after=r(100 * fc.loc['principal', 'picked_at_site_bias']), after_note='mean of the model pool, at the same bias', change='keep; no boost', how='as FC-SKU', action='Say the ML is level with the 4-week average here, or add that average to the blend'), dict(id='FC-CLUSTER', page='Forecast proof', figure='Cluster weekly forecast error (6 clusters)', unit='%', before=21.3, before_note='site, raw sales, daily run, bias -7.4%', after=r(100 * fc.loc['cluster', 'picked_at_site_bias']), after_note='median of the model pool, at the same bias', change='keep; no boost', how='as FC-SKU', action='Say the ML is level with simple averages here'), dict(id='FC-TOTAL', page='Forecast proof', figure='Company total weekly error 14.6% vs 14.9%', unit='%', before=14.6, before_note='site, base demand', after=None, after_note='any flat forecast between SAR 1.08M and 1.38M a week scores 16.2% on raw sales in these 4 weeks', change='cannot be tested in 4 weeks', how='weekly actuals 1.08M, 1.38M, 1.56M, 1.05M: over- and under-shoots cancel', action='Drop the total comparison or score it on more weeks'), dict(id='NBS-P10', page='Next-best SKU, Value cockpit', figure='Recommended SKUs bought in 8 unseen weeks (precision@10)', unit='%', before=12.0, before_note=f'site (our copy of its scorer: {100 * p10_copy:.1f}%)', after=r(100 * p10_after, 1), after_note=f'{pick_name}; gain over our copy {100 * (p10_after - p10_copy):+.1f} points, 90% interval {100 * ci[0]:+.1f} to {100 * ci[1]:+.1f}', change='boost', how='same test as the site: picks at 25 Aug, 4,097 outlets, 8 weeks; model chosen on the 30 Jun validation cut-off', action='Add EASE (closed-form item model) and RP3beta scores; rank with the picked model'), dict(id='NBS-HIT', page='Next-best SKU', figure='Outlets that bought at least one of the 10 picks', unit='%', before=58.2, before_note=f'site (our copy {100 * hit_copy:.1f}%)', after=r(100 * hit_after), after_note=pick_name, change='boost', how='as NBS-P10', action='as NBS-P10'), dict(id='NBS-P5', page='Next-best SKU, Value roll-up', figure='Precision of the top 5 picks (the list the value uses)', unit='%', before=None, before_note=f'not shown on the site (our copy {100 * p5_copy:.1f}%)', after=r(100 * p5_after), after_note=pick_name, change='relabel and boost', how='as NBS-P10', action='Report the precision of the top 5, which the value lever uses'), dict(id='CHURN-AUC', page='Outlets, Value cockpit', figure='Churn model AUC on unseen outlets', unit='AUC', before=0.77, before_note=f"site LightGBM (our LightGBM {CT.loc['LightGBM', 'test_auc']:.3f} on our test)", after=r(CT.loc['Extra trees', 'test_auc'], 3), after_note=f"Extra trees, picked on two validation folds; gain {ch['ci']['Extra trees'][1]:+.3f}, 90% interval {ch['ci']['Extra trees'][0]:+.3f} to {ch['ci']['Extra trees'][2]:+.3f}", change='boost', how='held-out outlets (30%), label = no purchase in the 8 weeks after 25 Aug; 1,314 outlets, 103 went quiet', action='Swap LightGBM for Extra trees (isotonic-calibrated); keep the recency comparison'), dict(id='CHURN-TOP20', page='Outlets, Value cockpit', figure='Churners caught in the riskiest 20%', unit='%', before=47.9, before_note=f"site (our LightGBM {100 * CT.loc['LightGBM', 'test_top20_capture']:.0f}%; days-since-last-order rule {100 * CT.loc['Recency rule', 'test_top20_capture']:.0f}%)", after=r(100 * CT.loc['Extra trees', 'test_top20_capture']), after_note='Extra trees', change='boost', how='as CHURN-AUC', action='Show the recency rule beside it: it catches as many as LightGBM'), dict(id='ADOPT-AUC', page='Cluster harmonisation, Value cockpit', figure='Adoption model AUC (pooled)', unit='AUC', before=0.90, before_note=f"site; our time-honest copy of LightGBM {AT.loc['LightGBM', 'test_auc_pooled']:.2f}", after=r(AT.loc[ad['pick'], 'test_auc_pooled'], 2), after_note=f"{ad['pick']} in our copy", change='check before the demo', how='5 clusters (Personal Care left out), trained on adoptions to 31 Jul, tested on Aug-Oct adoptions at held-out outlets; 3,885 pairs, 220 adoptions', action='Check that the site model trains only on adoptions before 1 Aug; show within-cluster AUC'), dict(id='ADOPT-WITHIN', page='Cluster harmonisation', figure='Adoption AUC within each cluster (mean)', unit='AUC', before=0.79, before_note=f"site per-cluster AUCs 0.67-0.86 (mean of six); our LightGBM copy {AT.loc['LightGBM', 'test_auc_within_mean']:.2f}", after=r(AT.loc[ad['pick'], 'test_auc_within_mean'], 2), after_note=f"{ad['pick']} in our copy", change='boost (in our copy)', how='as ADOPT-AUC', action='Blend logistic regression with XGBoost instead of LightGBM alone'), dict(id='ADOPT-TOP10', page='Cluster harmonisation, Value cockpit', figure='Adoptions caught by the top 10% of leads', unit='%', before=65.0, before_note=f"site; our LightGBM copy {100 * AT.loc['LightGBM', 'test_top10_capture']:.0f}%, channel popularity {100 * AT.loc['Channel popularity', 'test_top10_capture']:.0f}%", after=r(100 * AT.loc[ad['pick'], 'test_top10_capture']), after_note=f"{ad['pick']} in our copy", change='check before the demo', how='as ADOPT-AUC', action='As ADOPT-AUC'), dict(id='RECV-PASTDUE', page='Value cockpit, Receivables, Collection list', figure='Past due', unit='SAR', before=927163, before_note='site headline, 24% of the book', after=590932, after_note='past due on matched invoices (15%); plus SAR 336,231 older than the invoice window, shown separately', change='fix', how="the site's own collections-lever evidence", action='Show SAR 591K past due plus SAR 336K unmatched'), dict(id='BADGE', page='Shift supervisor, Value roll-up', figure='Badge sessions to check', unit='sessions', before=198, before_note='cockpit (high risk only)', after=625, after_note='high (198) plus medium (427), as the lever evidence counts', change='relabel', how='site dataset wh_badge_risk tiers', action='Label 198 as high risk of 625 high or medium'), dict(id='VAL-SAFETY', page='Value roll-up', figure='ML forecast to safety stock (a year)', unit='SAR', before=round(ss_base), before_note='site base case', after=round(lever['safety_same_bias']), after_note=f"at the same bias; SAR {lever['safety_zero_bias']:,.0f} if forecasts must be unbiased", change='about the same', how='lever scales with the SKU error cut vs repeat-last-week', action='No change needed; quote the bias'), dict(id='VAL-SAFETY-ONEOFF', page='Value roll-up', figure='Inventory cash released (one-off)', unit='SAR', before=round(ss_oneoff), before_note='site base case', after=round(lever['safety_oneoff_same_bias']), after_note=f"at the same bias; SAR {lever['safety_oneoff_zero_bias']:,.0f} if forecasts must be unbiased", change='about the same', how='as VAL-SAFETY', action='As VAL-SAFETY'), dict(id='VAL-WHITESPACE', page='Value roll-up', figure='Whitespace / next-best SKU (a year)', unit='SAR', before=round(ws_base), before_note='site base case', after=round(lever['whitespace']), after_note='scaled by the gain in top-5 precision', change='small boost', how='estimate: lever scales with top-5 precision', action='Update after the new model is live'), dict(id='VAL-CHURN', page='Value roll-up', figure='Churn early-warning and win-back (a year)', unit='SAR', before=round(churn_base), before_note='site base case', after=round(lever['churn_upper']), after_note='upper bound: whole lever scaled by churners caught in the riskiest 20%', change='small boost', how='estimate', action='Update after the new model is live'), dict(id='VAL-TOTAL', page='Value cockpit, Value roll-up', figure='Annual value, base case', unit='SAR', before=3454548, before_note='site', after=round(3454548 + lever['total_change']), after_note='all model upgrades together (estimate)', change='under 1% higher', how='sum of the lever changes above', action='No headline change needed'), ] F = pd.DataFrame(rows) # superseded by replacements.py -> before_after_figures.csv / .json (kept here only for the printout) meta = {'title': 'Binzagr PoV model review: before and after figures', 'as_of': '2026-10-23', 'site_read': '2026-10-23', 'data': '02_Cleaned v3 (the same files the site uses; hold-out sales match the site to the cent)', 'note': 'before = figure on binzagr.snop.live; after = same test re-run with the best model or combination picked on validation windows only', 'figures': rows} # (figure_changes.json no longer written) # ---- full test tables site_raw = {'total': (0.1618, -0.0463, 'weekly'), 'cluster': (0.2133, -0.0741, 'daily'), 'principal': (0.2831, -0.1274, 'daily'), 'van': (0.4043, -0.1043, 'daily'), 'sku': (0.6686, -0.1722, 'weekly')} fl = fc.reset_index().rename(columns={'naive7': 'ours_repeat_last_week', 'wd4': 'ours_4wk_weekday_avg', 'site_best_run': 'site_wape', 'site_bias': 'site_bias', 'replica_site_style': 'ours_site_style_race_wape', 'replica_bias': 'ours_site_style_race_bias', 'picked_rule': 'best_rule_picked_on_validation', 'picked_wape': 'best_rule_wape', 'picked_bias': 'best_rule_bias', 'picked_at_site_bias': 'best_rule_wape_at_site_bias', 'level_top3_members': 'level_top3_members'}) fl.round(4).to_csv(f'{D}/forecast_by_level.csv', index=False, encoding='utf-8-sig') fce.rename(columns={'val': 'validation_wape_25aug', 'holdout': 'holdout_wape'}).round(4).to_csv(f'{D}/forecast_all_rules.csv', index=False, encoding='utf-8-sig') nb = rr['table'].copy(); nb['picked'] = nb.strategy.eq('LightGBM ranker on all scores' if pick == 'ranker' else 'Blend: EASE + RP3beta + site blend + van') nb.round(4).to_csv(f'{D}/next_best_sku_models.csv', index=False, encoding='utf-8-sig') ch['table'].round(4).to_csv(f'{D}/churn_models.csv', index=False, encoding='utf-8-sig') ad['table'].round(4).to_csv(f'{D}/adoption_models.csv', index=False, encoding='utf-8-sig') # ---- improved outputs pk = pd.read_parquet(f'{W}/picks_v2.parquet'); pk['score'] = pk.score.round(5); pk['picks_made_on'] = '2026-10-20' pk.rename(columns={'mainvan': 'main_van'}).to_csv(f'{D}/next_best_sku_picks_v2.csv', index=False, encoding='utf-8-sig') cv = pd.read_parquet(f'{W}/churn_v2.parquet') for c in ('churn_risk_8w_v1', 'churn_risk_8w_v2'): cv[c] = cv[c].round(4) cv['scored_on'] = '2026-10-20'; cv.to_csv(f'{D}/churn_scores_v2.csv', index=False, encoding='utf-8-sig') for s in ('fc_race.py', 'fc_eval.py', 'reco_race.py', 'reco_final_v2.py', 'reco_picks_v2.py', 'churn_race.py', 'churn_final_v2.py', 'adoption_race.py', 'charts.py', 'data_changes.py', 'replacements.py', 'build_report.py', 'check_report.py'): if os.path.exists(f'/home/claude/binzagr/boost/{s}'): shutil.copy(f'/home/claude/binzagr/boost/{s}', f'{OUT}/scripts/{s}') pickle.dump({'lever': lever, 'fva': (fva_site, fva_same_bias, fva_zero_bias), 'p10': (p10_copy, p10_after), 'p5': (p5_copy, p5_after), 'hit': (hit_copy, hit_after), 'ci': ci, 'pick': pick, 'pick_name': pick_name}, open(f'{W}/report_numbers.pkl', 'wb')) print(F[['id', 'before', 'after', 'change']].to_string(index=False)) print({k: round(v) for k, v in lever.items()}) print('files:', sorted(os.listdir(D)))