"""PDF: Binzagr PoV model review - before and after figures, how each was reached, and what is more accurate.""" import sys, pickle import pandas as pd sys.path.insert(0, '/home/claude/binzagr/docs') from bzpdf import * # noqa from reportlab.platypus import Image, KeepTogether OUT = sys.argv[1] if len(sys.argv) > 1 else '/mnt/user-data/outputs/Binzagr_Data/06_Model_Review/Binzagr_Model_Review_Before_After.pdf' WK, CH = '/home/claude/binzagr/boost/work', '/home/claude/binzagr/boost/charts' Wd = A4[0] - 32 * mm N = pickle.load(open(f'{WK}/report_numbers.pkl', 'rb')); LV = N['lever'] fc = pd.read_csv(f'{WK}/fc_summary.csv').set_index('level') rr = pickle.load(open(f'{WK}/reco_race.pkl', 'rb')); RT = rr['table'].set_index('strategy') rf = pickle.load(open(f'{WK}/reco_final_v2.pkl', 'rb')); RES = rf['res'] ch = pickle.load(open(f'{WK}/churn_race.pkl', 'rb')); CT = ch['table'].set_index('model') ad = pickle.load(open(f'{WK}/adoption_race.pkl', 'rb')); AT = ad['table'].set_index('model') p10c, p10a = N['p10']; p5c, p5a = N['p5']; hitc, hita = N['hit']; ci = N['ci'] PICK = N['pick']; pct1 = lambda x: f'{100 * x:.1f}%'; pct0 = lambda x: f'{100 * x:.0f}%' sb = lambda x: '0%' if round(100 * x) == 0 else f'{round(100 * x):+d}%' et_ci = ch['ci']['Extra trees'] def img(name, width=Wd, ratio=None): from PIL import Image as PI w, h = PI.open(f'{CH}/{name}').size return Image(f'{CH}/{name}', width=width, height=width * h / w) def H2(text): return [CondPageBreak(45 * mm), H(text, 1)] story = [NextPageTemplate('main')] story += title_block('BINZAGR PoV · MODEL REVIEW', 'Can better models boost the dashboard figures?', 'Before and after figures for every model on binzagr.snop.live, how each "after" was reached, and whether it is more accurate.', ['Version 1.0 · 23 October 2026 · Site read on 23 Oct 2026: Value cockpit to Value roll-up (14 pages)', 'Data: 02_Cleaned v3, the same files the site uses. Our hold-out sales match the site to the cent.', 'Companion data: 06_Model_Review/data (before_after_figures.csv / .json, every test table, improved picks and churn scores)']) story.append(kpis([(f'+{CT.loc["Extra trees", "test_auc"] - CT.loc["LightGBM", "test_auc"]:.3f}', 'churn AUC on unseen outlets (0.772 to 0.797), picked before the test'), (f'+{100 * (p10a - p10c):.1f} pts', f'next-best SKU picks bought ({pct1(p10c)} to {pct1(p10a)}) on the site\'s own test'), (f'-{100 * (0.6686 - fc.loc["sku", "picked_at_site_bias"]):.1f} pts', 'SKU forecast error at the same bias (66.9% to 65.5%)'), (f'+SAR {LV["total_change"] / 1e3:.0f}K', 'a year on the SAR 3.45M base case (under 1%)')], Wd)) story.append(Spacer(1, 4 * mm)) story.append(H('1. The answer', 0)) story.append(P('Better models lift three figures a little and leave the rest where they are. The forecast ensemble is already close to the best this ' 'data allows, and some headline figures go down when they are measured fairly. The limit is the data: eight months of history and ' 'four test weeks, not the choice of algorithm.')) story += bullets([ f'Churn warning: real boost. Extra trees, picked on validation, scores AUC {CT.loc["Extra trees", "test_auc"]:.3f} against {CT.loc["LightGBM", "test_auc"]:.3f} for ' f'LightGBM (the site\'s model type) on the same unseen outlets: +{et_ci[1]:.3f}, 90% interval +{et_ci[0]:.3f} to +{et_ci[2]:.3f}. Its riskiest 20% holds ' f'{pct0(CT.loc["Extra trees", "test_top20_capture"])} of the outlets that went quiet, against {pct0(CT.loc["LightGBM", "test_top20_capture"])}.', f'Next-best SKU: small boost. On the site\'s own test (picks on 25 Aug, 4,097 outlets, 8 weeks) a LightGBM ranker over new scores (EASE, RP3beta) ' f'raises the share of picks bought from {pct1(p10c)} (our copy of the site scorer; the site reports 12.0%) to {pct1(p10a)}, 90% interval ' f'+{100 * ci[0]:.1f} to +{100 * ci[1]:.1f} points. The top 5, which the value uses, barely moves.', f'Cluster adoption: boost in our copy, but the site figure needs checking. Blending logistic regression with XGBoost lifts the within-cluster AUC from ' f'{AT.loc["LightGBM", "test_auc_within_mean"]:.2f} to {AT.loc[ad["pick"], "test_auc_within_mean"]:.2f}. With a time-honest split no model reproduces the site\'s 0.90 ' f'(we reach {AT.loc["LightGBM", "test_auc_pooled"]:.2f} to {AT.loc[ad["pick"], "test_auc_pooled"]:.2f}).', f'Demand forecast: keep it, show the bias. At SKU level one equal blend of the best three models is 1.4 points better than the site at the same bias. ' f'At van, principal and cluster level the site\'s blends are 0.6 to 1.4 points better than anything we could build without its foundation models.', 'Goes down when measured fairly: the "22% less SKU error" falls to about 15% if the forecast must be unbiased; the principal-level ML loses to a ' '4-week weekday average on the page as shown; the company-total comparison cannot rank models at all in four weeks.', f'Value: model accuracy drives only about SAR 217K of the SAR 3.45M. All upgrades together add about SAR {LV["total_change"] / 1e3:.0f}K a year. ' 'The large levers (fleet SAR 1.39M, labour SAR 639K, price SAR 397K) rest on benchmarks and assumptions that better data, not better models, would firm up.']) story.append(Spacer(1, 2 * mm)) story.append(callout('How to read "before" and "after". Before = the figure on the site. After = the same test re-run on the same data with the best ' 'model or combination, chosen on earlier validation windows only; the test weeks were used once. Where the site\'s exact model could not be ' 'run (its three foundation models), we compare against the site\'s own published result and show our copy of its method beside it.', Wd)) # ---------------------------------------------------------------- replacement table REP = pickle.load(open(f'{WK}/replacements.pkl', 'rb')); TYPES = REP['types'] TCOL = {'Replace': '#2a78d6', 'Fix': '#B3261E', 'Add': '#1B7F3B', 'Verify first': '#B26A00', 'Recompute': '#6250d6', 'Keep': '#7C8591'} story.append(PageBreak()) story += H2('2. Before and after: every figure to change on the site') story.append(P('One row per figure, page by page, in the words the site uses. "On the site now" is the text to find; "Replace with" is the text to put in its ' 'place. The same table is in data/before_after_figures.csv and .json, with the numbers in separate columns and the basis of every after value.')) leg = [['Type', 'What to do']] + [[f'{k}', v] for k, v in TYPES.items()] story.append(table(leg, [26 * mm, 152 * mm], markup_cols=(0,), font_size=7.8)) story.append(Spacer(1, 3 * mm)) rt = [['#', 'Page and place', 'On the site now', 'Replace with', 'Type']] for r_ in REP['rows']: rt.append([str(r_['no']), f"{clean(r_['page'])}
{clean(r_['where_on_page'])}", r_['on_site_now'], r_['replace_with'], f'{r_["type"]}']) story.append(table(rt, [7 * mm, 36 * mm, 55 * mm, 58 * mm, 22 * mm], markup_cols=(1, 4), font_size=7.5)) story.append(P('Replace rows use numbers measured on the same test once the new model is live on the site; until then the current figures stand. ' 'Verify-first rows are lower in our time-honest re-test, so they should be re-checked before the demo, not raised.', 'small')) # ---------------------------------------------------------------- method story += H2('3. How the after figures were reached') story.append(P('Every test copies the site\'s own: the same files, the same series, the same test windows and the same comparison baselines. We first checked ' 'that our copy reproduces the site\'s baselines; only then did we try new models.')) base = [['Check', 'Site', 'Our copy'], ['Hold-out van sales, 23 Sep - 20 Oct (company total)', 'SAR 5,060,755', 'SAR 5,060,755 (all 92 vans within SAR 0.02)'], ['SKU error: repeat last week / 4-week weekday average', '87.5% / 76.0%', f'{pct1(fc.loc["sku", "naive7"])} / {pct1(fc.loc["sku", "wd4"])}'], ['Van error: repeat last week / 4-week weekday average', '53.3% / 43.9%', f'{pct1(fc.loc["van", "naive7"])} / {pct1(fc.loc["van", "wd4"])}'], ['Principal error: repeat last week / 4-week weekday average', '35.8% / 30.4%', f'{pct1(fc.loc["principal", "naive7"])} / {pct1(fc.loc["principal", "wd4"])}'], ['Next-best SKU: outlets judged / best-sellers / random', '4,097 / 3.95% / 0.66%', f'4,097 / {pct1(RT.loc["Best-sellers (popularity)", "test_p10"])} / {pct1(RT.loc["Random SKUs", "test_p10"])}'], ['Next-best SKU: the site scorer', '12.0%, 58.2% of outlets hit', f'{pct1(p10c)}, {pct1(hitc)} of outlets hit'], ['Cluster adoption: test adoption rate', '4.5% (242 of 5,335 pairs)', '5.7% (220 of 3,885 pairs; Personal Care left out)']] story.append(table(base, [86 * mm, 40 * mm, 52 * mm], font_size=8)) story.append(Spacer(1, 2 * mm)) story += bullets([ 'Chosen before the test. Every model, weight and combination rule was picked on validation windows that end before the test starts ' '(forecast: origins 28 Jul and 25 Aug; next-best SKU: picks at 30 Jun; churn: cut-offs 16 and 30 Jun; adoption: May - Jul). Combination rules were nested: ' 'members picked on one window, the rule scored on the next. The test was then run once.', 'Compared fairly. Forecasts are compared at the same bias, because weekly error rewards forecasting low (section 4). Classifiers are compared on the ' 'same held-out outlets, with paired bootstrap intervals for the gain.', 'What we could not run. The site\'s foundation models (Chronos-2, TimesFM 2.5, TiRex) cannot be downloaded in our environment. Our forecast pool is ' 'LightGBM (daily and weekly direct models) plus seven statistical methods (ETS, Theta, SES, ADIDA, IMAPA, TSB, SBA) on daily and weekly data and the two ' 'baselines, so our forecast "after" is a lower bound for what the ' 'site could reach by changing its combination rule.']) # ---------------------------------------------------------------- forecast story += H2('4. Demand forecast (Forecast proof, van loads)') story.append(P('The site blends the best one to three models per series, picked by a backtest race (the ratio is not shown; the race leads are foundation ' 'models 31, LightGBM 30 and statistical 31 of 92 vans). We tested ten combination rules on the same 28 days.')) story.append(img('fc_levels.png')) fl = [['Level', 'Site today', 'Our copy of the site style', 'Best rule (picked on validation)', 'As fitted', 'Same bias as site', 'Verdict']] for lv, nm, vd in [('sku', 'SKU (548)', 'Small boost'), ('van', 'Van (92)', 'Keep site'), ('principal', 'Principal (16)', 'Keep site'), ('cluster', 'Cluster (6)', 'Keep site')]: r_ = fc.loc[lv] fl.append([nm, f'{pct1(r_.site_best_run)} at {sb(r_.site_bias)} ({r_.site_run} run)', f'{pct1(r_.replica_site_style)} at {sb(r_.replica_bias)}', r_.picked_rule.replace(' (picked on validation)', ''), f'{pct1(r_.picked_wape)} at {sb(r_.picked_bias)}', pct1(r_.picked_at_site_bias), vd]) story.append(table(fl, [20 * mm, 30 * mm, 28 * mm, 34 * mm, 24 * mm, 20 * mm, 22 * mm], font_size=7.8)) story.append(Spacer(1, 2 * mm)) story += bullets([ 'Composition. At SKU level, racing models per SKU overfits: with two 4-week windows to choose from, the race picks whatever was lucky. Our copy of ' f'that rule scored {pct1(fc.loc["sku", "replica_site_style"])}, worse than the 4-week average. One blend for all SKUs of the three models that were best ' f'on validation ({fc.loc["sku", "level_top3_members"].replace("_w", " weekly").replace("_d", " daily").replace("LGBM", "LightGBM")}) scored ' f'{pct1(fc.loc["sku", "picked_wape"])}.', 'Ratio. Equal weights. Weights fitted on validation (LightGBM daily 57%, repeat-last-week 17%, Theta 10%, LightGBM weekly 7%, SBA 6%, ETS 3%) ' 'scored 63.4% against 63.0% for equal weights: fitted ratios do not beat a plain average with this little data.', 'Van, principal, cluster. The site\'s blends, with foundation models, beat everything we could build by 0.6 to 1.4 points at the same bias. Keep them.', 'Company total. The four test weeks were SAR 1.08M, 1.38M, 1.56M and 1.05M. Any flat forecast between 1.08M and 1.38M a week scores exactly 16.2%, ' 'because the misses cancel, so no model can be shown to beat another at total.']) story.append(KeepTogether([img('sku_tradeoff.png'), P('Weekly error (WAPE) is lowest for a forecast below the mean, because SKU sales are lumpy: most weeks are small, a few are large. Both the site\'s runs and ' 'our blend forecast 17% to 33% too low. Held at the site\'s bias, our blend is 1.4 points better. Forced to zero bias it scores 74.1%: 15% less error than ' 'repeating last week (not 22%) and 3% less than the 4-week weekday average. For ordering, a forecast 17% too low means ordering 17% too little unless ' 'safety stock covers it, so the page should show the bias next to the error.', 'small')])) # ---------------------------------------------------------------- next-best SKU story += H2('5. Next-best SKU') story.append(P('The site ranks SKUs an outlet has not bought by what similar outlets buy, neighbourhood and van penetration, and popularity. We added two ' 'stronger recommenders, EASE (a closed-form item model) and RP3beta (a graph walk), and two ways to combine them with the current scores.')) story.append(img('nbs.png')) nb = [['Model', 'Validation (picks 30 Jun)', 'Test: share of 10 picks bought', 'Test: top 5', 'Test: outlets with a hit']] for k_, lbl in [('Best-sellers (popularity)', 'Best-sellers'), ('Site-style blend (similar baskets + van + popularity)', 'Copy of the site scorer'), ('EASE (item model, closed form)', 'EASE alone'), ('RP3beta (graph walk)', 'RP3beta alone'), ('Blend: EASE + RP3beta + site blend + van', 'EASE + RP3beta + current scores'), ('LightGBM ranker on all scores', 'LightGBM ranker (picked)')]: x = RT.loc[k_] val = rf['fair_blend_val'] if k_.startswith('Blend') else x.val_p10 nb.append([lbl, pct1(val) + (' (weights from 2 Jun)' if k_.startswith('Blend') else ''), pct1(x.test_p10), pct1(x.test_p5), pct1(x.test_outlets_hit)]) story.append(table(nb, [52 * mm, 36 * mm, 34 * mm, 24 * mm, 32 * mm], font_size=8)) story.append(Spacer(1, 2 * mm)) story += bullets([ f'Picked fairly. On validation the ranker scored {pct1(rf["ranker_val"])} and the blend {pct1(rf["fair_blend_val"])} (blend weights tuned on an earlier ' f'cut-off, so both are out of sample). The ranker was picked; on the test it scored {pct1(p10a)}. The blend would have scored {pct1(RES["blend"]["test_p10"])}: ' 'the honest choice is not always the best one on the test.', f'Gain. +{100 * (p10a - p10c):.1f} points over our copy of the site scorer, 90% bootstrap interval +{100 * ci[0]:.1f} to +{100 * ci[1]:.1f}. ' f'Expect the site\'s 12.0% to reach about {100 * (0.1198 + p10a - p10c):.1f}%.', f'Ceiling. In the test weeks the 4,097 outlets started buying 5.6 new SKUs each on average, so a perfect list would have 42% of its 10 picks bought. ' 'Models reach 12% to 13% because adoption depends on what the rep offers and what is on the van, which the files do not record.']) # ---------------------------------------------------------------- churn story += H2('6. Outlet churn warning') story.append(P('Same protocol as our 05_Predictions churn model: will an outlet active in the last 13 weeks make no purchase in the next 8? 30% of outlets are ' 'held out; seven model types and every 2- and 3-model blend were compared on two validation folds, then the winner was tested once.')) story.append(img('churn.png')) cm = [['Model', 'Validation AUC (mean of 2)', 'Test AUC', 'Test: quiet outlets in riskiest 20%']] for k_ in ['Extra trees', 'Random forest', 'Blend: all seven', 'Blend: LightGBM + Logistic regression', 'Logistic regression', 'CatBoost', 'LightGBM', 'XGBoost', 'HistGradientBoosting', 'Recency rule']: x = CT.loc[k_] cm.append([('Days since last order' if k_ == 'Recency rule' else k_) + (' (picked)' if k_ == ch['pick'] else ''), f'{x.val_auc_mean:.3f}', f'{x.test_auc:.3f}', pct0(x.test_top20_capture)]) story.append(table(cm, [70 * mm, 38 * mm, 30 * mm, 40 * mm], font_size=8)) story.append(Spacer(1, 2 * mm)) story += bullets([ f'Gain. Extra trees beats LightGBM by +{et_ci[1]:.3f} AUC (90% interval +{et_ci[0]:.3f} to +{et_ci[2]:.3f}) on {ch["n_test"]:,} unseen outlets, ' f'{ch["pos"]} of which went quiet. LightGBM itself beats the days-since-last-order rule by +{ch["ci_rec"][1]:.3f} AUC, but its riskiest 20% catches no more ' 'quiet outlets than the rule; Extra trees does (59% vs 53%).', 'The site\'s own churn label and test period differ from ours (its rule scores 0.68, ours 0.73), so its 0.77 is not directly comparable; expect a similar ' 'gain of about +0.02 to +0.03 AUC. New scores for every outlet as of 20 Oct are in data/churn_scores_v2.csv.']) # ---------------------------------------------------------------- adoption story.append(CondPageBreak(120 * mm)) story += H2('7. Cluster adoption') story.append(P('Will an outlet start buying from a sales team it has never bought from? We trained on adoptions up to 31 Jul and tested on August - October ' 'adoptions at held-out outlets, for the five clusters sold under distributor 1000 (Personal Care sells under its own outlet codes, which the site ' 'merges by location and name).')) story.append(img('adoption.png')) am = [['Model', 'Validation: AUC within cluster', 'Test: AUC pooled', 'Test: AUC within cluster', 'Test: adoptions in top 10%']] for k_ in [ad['pick'], 'XGBoost', 'Logistic regression', 'CatBoost', 'LightGBM', 'Channel popularity', 'What neighbours buy']: x = AT.loc[k_] am.append([k_ + (' (picked)' if k_ == ad['pick'] else ''), f'{x.val_auc_within_mean:.3f}', f'{x.test_auc_pooled:.3f}', f'{x.test_auc_within_mean:.3f}', pct0(x.test_top10_capture)]) story.append(table(am, [62 * mm, 32 * mm, 26 * mm, 30 * mm, 28 * mm], font_size=8)) story.append(Spacer(1, 2 * mm)) story.append(callout('Check before the demo. The site reports a pooled AUC of 0.90 and 65% of adoptions in the top 10%. With a split that only trains on ' f'adoptions before the test window, no model we tried gets above {AT.test_auc_pooled.max():.2f} pooled or {pct0(AT.test_top10_capture.max())} in the top 10%, and ' 'LightGBM alone catches fewer adoptions in its top 10% than ranking by channel popularity. If the site model trained on August - October ' 'adoptions of other outlets, it has seen the test period: re-run it on adoptions before 1 August. Within one cluster its own AUC is 0.67 to ' '0.86; show that next to the pooled 0.90, which mostly reflects that Unilever HPC adopts far more often than the other teams.', Wd, kind='warn')) # ---------------------------------------------------------------- pages without a model + value story += H2('8. Where a better model does not help') story.append(P('Most of the SAR 3.45M rests on benchmarks, simulations and rules. A different algorithm cannot move these; only better data can.')) nm = [['Page', 'What the figure is', 'What limits it'], ['Van sales (fleet SAR 1.39M)', 'Re-zoning simulation and top-quartile drops per van-day', 'Straight-line distances from outlet GPS; no van tracks or cost per van-day'], ['Warehouse labour, P&L (SAR 639K)', 'Benchmark against the best quarter of shifts, Monte Carlo range', 'No pay rates, roster or attendance'], ['Shift supervisor (198 sessions)', 'Rules plus an IsolationForest anomaly model', 'No attendance, so the flags cannot be scored at all'], ['Receivables, Collection list (SAR 174K)', 'Rules on a rebuilt ageing', 'No payment history to train a model on'], ['Returns (SAR 315K), price (SAR 397K)', 'Measured rates against cluster medians', 'Descriptive; promotion and trade-spend data would sharpen them'], ['Cluster harmonisation (SAR 321K)', 'Own-best neighbourhood benchmark', 'The lever does not use the adoption model\'s accuracy']] story.append(table(nm, [46 * mm, 66 * mm, 66 * mm], font_size=8)) story.append(Spacer(1, 3 * mm)) vl = [['Lever (a year unless stated)', 'Site base', 'After', 'Basis'], ['ML forecast to safety stock', 'SAR 65.2K', f'SAR {LV["safety_same_bias"] / 1e3:.1f}K', f'Same bias. SAR {LV["safety_zero_bias"] / 1e3:.1f}K if forecasts must be unbiased'], ['Inventory cash released (one-off)', 'SAR 296K', f'SAR {LV["safety_oneoff_same_bias"] / 1e3:.0f}K', f'Same bias. SAR {LV["safety_oneoff_zero_bias"] / 1e3:.0f}K if unbiased'], ['Whitespace / next-best SKU', 'SAR 104.5K', f'SAR {LV["whitespace"] / 1e3:.1f}K', 'Scaled by top-5 precision (the list the value uses)'], ['Churn early-warning and win-back', 'SAR 47.3K', f'up to SAR {LV["churn_upper"] / 1e3:.1f}K', 'Upper bound: whole lever scaled by churners caught'], ['Annual total', 'SAR 3.45M', f'SAR {(3454548 + LV["total_change"]) / 1e6:.3f}M', 'All model upgrades together']] story.append(table(vl, [56 * mm, 26 * mm, 30 * mm, 66 * mm], font_size=8)) # ---------------------------------------------------------------- data asks story += H2('9. What more data would unlock') da = [['Model or page', 'Data to ask for', 'What it would change'], ['Demand forecast', '12-24 months of daily sales', 'Learns Ramadan, Eid and the summer peak; allows test windows long enough to rank models'], ['Demand forecast', 'Promotion and trade-loading calendar', 'The mid-October burst (one SKU at about 24,000 cases in a week) becomes a known input'], ['Forecast, van loads', 'Stock on hand; van load / unload by SKU', 'Turns forecasts into orders and loads; separates lost sales from low demand'], ['Next-best SKU', 'Rep visit logs and what was offered', 'Separates "not offered" from "refused"; the gap between 12% and the 42% ceiling is mostly here'], ['Churn', 'Visit logs or geo-fence flags; closure reasons', 'Tells closed shops from quiet ones'], ['Cluster adoption', 'More months of sales', '242 test adoptions put about +/-0.03 around the AUC; per-cluster AUCs rest on a handful'], ['Collection list', 'Customer ledger with payment dates; real credit limits', 'Allows a payment-risk model; explains the SAR 336K unmatched'], ['Shift supervisor, labour', 'Attendance, pay rates, roster', 'Lets the badge flags be scored; moves SAR 639K from Simulated to Corrected']] story.append(table(da, [36 * mm, 56 * mm, 86 * mm], font_size=8)) # ---------------------------------------------------------------- appendix story.append(PageBreak()) story += H2('Appendix: files and definitions') story.append(table([['File (06_Model_Review/data)', 'What it holds'], ['before_after_figures.csv, before_after_figures.json', 'Section 2 as data: page, place, text on the site now, replacement text, type, basis, numeric before and after'], ['forecast_by_level.csv', 'Per level: site result and bias, our baselines, our copy of the site rule, the picked rule, error at the site bias'], ['forecast_all_rules.csv', 'Every model and combination rule: validation (25 Aug) and hold-out error, per level'], ['next_best_sku_models.csv', 'Every recommender on the site\'s test: validation and test precision, top 5, outlets with a hit'], ['churn_models.csv', 'Every churn model and blend: two validation folds, test AUC, riskiest-20% capture'], ['adoption_models.csv', 'Every adoption model and blend: pooled and within-cluster AUC, top-10% capture'], ['next_best_sku_picks_v2.csv', 'Ten picks per outlet as of 20 Oct 2026 from the picked ranker (SKUs sold somewhere in the last 4 weeks only)'], ['churn_scores_v2.csv', 'Churn risk per outlet as of 20 Oct 2026: Extra trees (v2) beside the LightGBM version (v1)']], [58 * mm, 120 * mm], font_size=8)) story.append(Spacer(1, 3 * mm)) story += bullets([ 'Weekly error (WAPE) = sum of absolute weekly misses / sum of actual weekly sales, over every series and the 4 test weeks.', 'Bias = forecast total / actual total - 1. Negative means forecasting too low.', 'AUC = how often the model ranks a case that happened (an outlet that went quiet, a cluster adopted) above one that did not; 0.5 is a coin toss.', 'Precision@10 = share of the 10 recommended SKUs that the outlet bought in the 8 weeks after the list was made.', '90% interval = paired bootstrap over outlets (2,000 resamples) of the gain against the current model.', 'Scripts: 06_Model_Review/scripts (fc_race, fc_eval, reco_race, reco_final_v2, reco_picks_v2, churn_race, churn_final_v2, adoption_race, charts, ' 'data_changes, build_report, check_report), run in that order with the 05_Predictions work files.']) doc = BZDoc(OUT, 'Binzagr PoV - Model review: before and after', 'Model review: before and after figures', 'Binzagr PoV model review') build(doc, story) print('wrote', OUT)