"""Charts for the model change report (PNG, light surface, Carlito to match the PDF).""" import pickle, json import numpy as np, pandas as pd import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from matplotlib import font_manager as fm FD = '/usr/share/fonts/truetype/crosextra/' for f in ('Carlito-Regular.ttf', 'Carlito-Bold.ttf'): fm.fontManager.addfont(FD + f) plt.rcParams.update({'font.family': 'Carlito', 'font.size': 9.5, 'axes.edgecolor': '#c9c8c2', 'axes.linewidth': 0.6, 'axes.labelcolor': '#52514e', 'xtick.color': '#52514e', 'ytick.color': '#52514e', 'figure.facecolor': '#ffffff', 'axes.facecolor': '#ffffff', 'savefig.facecolor': '#ffffff'}) BEFORE, AFTER, AQUA, BASE, LIGHT, INK, INK2 = '#eb6834', '#2a78d6', '#1baf7a', '#9a9890', '#d3d2cc', '#0b0b0b', '#52514e' OUT = '/home/claude/binzagr/boost/charts' import os; os.makedirs(OUT, exist_ok=True) W = 7.0 # inches, fits the A4 text width (178 mm) def clean_axes(ax, grid='x'): for s in ('top', 'right'): ax.spines[s].set_visible(False) ax.grid(axis=grid, color='#ecebe6', linewidth=0.6); ax.set_axisbelow(True) def hbar_groups(path, groups, series, title, xlabel, fmt='{:.1f}%', xmax=None, note=None): """groups: list of group labels; series: list of (label, colour, values).""" n, k = len(groups), len(series); h = 0.8 / k fig, ax = plt.subplots(figsize=(W, 0.62 * n * k * 0.45 + 1.3)) y = np.arange(n)[::-1] for i, (lab, col, vals) in enumerate(series): yy = y + 0.4 - h * (i + 0.5) ax.barh(yy, vals, height=h * 0.86, color=col, label=lab, edgecolor='white', linewidth=0.8) for yv, v in zip(yy, vals): if v is None or (isinstance(v, float) and np.isnan(v)): continue ax.text(v + (xmax or max(max(s[2]) for s in series)) * 0.008, yv, fmt.format(v), va='center', ha='left', fontsize=8.6, color=INK) ax.set_yticks(y); ax.set_yticklabels(groups, fontsize=9.6, color=INK) ax.set_xlabel(xlabel); clean_axes(ax) if xmax: ax.set_xlim(0, xmax) ax.set_title(title, loc='left', fontsize=11, color=INK, fontweight='bold', pad=26) ax.legend(loc='lower left', bbox_to_anchor=(0, 1.0), ncol=k, frameon=False, fontsize=8.8, handlelength=1.2, columnspacing=1.4) if note: fig.text(0.01, 0.005, note, fontsize=7.8, color=INK2, ha='left', va='bottom') fig.tight_layout(rect=(0, 0.04 if note else 0, 1, 1)); fig.savefig(path, dpi=220); plt.close(fig) def forecast_levels(): T = pd.read_csv('/home/claude/binzagr/boost/work/fc_summary.csv').set_index('level') site_wd4 = {'sku': 75.99, 'van': 43.90, 'principal': 30.40, 'cluster': 22.91} lv = ['sku', 'van', 'principal', 'cluster']; names = ['SKU (548)', 'Van (92)', 'Principal (16)', 'Cluster (6)'] hbar_groups(f'{OUT}/fc_levels.png', names, [('Site today (its better run)', BEFORE, [100 * T.loc[l, 'site_best_run'] for l in lv]), ('Best combination tested, same bias', AFTER, [100 * T.loc[l, 'picked_at_site_bias'] for l in lv]), ('4-week weekday average', LIGHT, [site_wd4[l] for l in lv])], 'Weekly forecast error on the 4 unseen weeks (lower is better)', 'Weekly error (WAPE), %', xmax=90, note='Same data, series, weeks and baselines as the site (raw van sales, 23 Sep - 20 Oct 2026). Company total is left out: four weeks cannot rank models there.') def sku_tradeoff(): pts = [(-30.4, 63.0), (-23.5, 63.9), (-16.5, 65.7), (-9.6, 68.8), (-2.6, 72.6), (4.4, 76.7)] fig, ax = plt.subplots(figsize=(W, 3.5)) x, y = zip(*pts) ax.plot(x, y, color=AFTER, linewidth=2, marker='o', markersize=4.5, label='Best SKU combination, scaled up step by step', zorder=3) ax.annotate('as fitted', (x[0], y[0]), xytext=(6, -11), textcoords='offset points', fontsize=8.3, color=INK2) ax.annotate('near zero bias', (x[4], y[4]), xytext=(7, -13), textcoords='offset points', fontsize=8.3, color=INK2) site = [(-17.2, 66.9, 'Site, weekly run'), (-32.9, 67.5, 'Site, daily run')] ax.scatter([p[0] for p in site], [p[1] for p in site], s=46, color=BEFORE, zorder=4, label='Site today', edgecolor='white', linewidth=1.2) for a, b, t in site: ax.annotate(t, (a, b), xytext=(4, 9), textcoords='offset points', fontsize=8.3, color=INK) base = [(7.2, 87.6, 'Repeat last week'), (-1.8, 76.5, '4-week weekday average')] ax.scatter([p[0] for p in base], [p[1] for p in base], s=40, color=BASE, zorder=4, label='Baselines', marker='s', edgecolor='white', linewidth=1.2) for a, b, t in base: ax.annotate(t, (a, b), xytext=(-8, 7), textcoords='offset points', fontsize=8.3, color=INK, ha='right') ax.axvline(0, color='#c9c8c2', linewidth=0.8, linestyle=(0, (3, 3)), zorder=1) ax.set_xlabel('Bias: forecast total vs actual total, % (negative = forecasting too low)'); ax.set_ylabel('Weekly error (WAPE), %') ax.set_xlim(-36, 12); ax.set_ylim(58, 92); clean_axes(ax, grid='both') ax.set_title('SKU level: lower error is bought with a lower forecast', loc='left', fontsize=11, color=INK, fontweight='bold', pad=22) ax.legend(loc='lower left', bbox_to_anchor=(0, 1.0), ncol=3, frameon=False, fontsize=8.6, handlelength=1.4, columnspacing=1.4) fig.tight_layout(); fig.savefig(f'{OUT}/sku_tradeoff.png', dpi=220); plt.close(fig) def two_panel(path, title, left, right, note=None): """left / right: (panel title, fmt, [(label, colour, value)], xlim).""" fig, axes = plt.subplots(1, 2, figsize=(W, 2.2)) for ax, (pt, fmt, rows, xl) in zip(axes, (left, right)): labs = [r[0] for r in rows][::-1]; vals = [r[2] for r in rows][::-1]; cols = [r[1] for r in rows][::-1] ax.barh(range(len(rows)), vals, color=cols, height=0.62, edgecolor='white', linewidth=0.8) for i, v in enumerate(vals): ax.text(v + (xl[1] - xl[0]) * 0.015, i, fmt.format(v), va='center', fontsize=8.8, color=INK) ax.set_yticks(range(len(rows))); ax.set_yticklabels(labs, fontsize=9, color=INK); ax.set_xlim(*xl) ax.set_title(pt, loc='left', fontsize=9.6, color=INK2, pad=6); clean_axes(ax) fig.suptitle(title, x=0.01, y=0.985, ha='left', fontsize=11, color=INK, fontweight='bold') if note: fig.text(0.01, 0.01, note, fontsize=7.8, color=INK2, ha='left', va='bottom') fig.tight_layout(rect=(0, 0.07 if note else 0, 1, 0.9), w_pad=2.5); fig.savefig(path, dpi=220); plt.close(fig) def churn(): two_panel(f'{OUT}/churn.png', 'Churn warning on outlets the models never saw (test at 25 Aug)', ('AUC (1 = perfect ranking)', '{:.3f}', [('Days since last order', BASE, 0.726), ('LightGBM (site model)', BEFORE, 0.772), ('Extra trees (picked)', AFTER, 0.797)], (0.5, 0.85)), ('Quiet outlets caught in riskiest 20%', '{:.0f}%', [('Days since last order', BASE, 53.4), ('LightGBM (site model)', BEFORE, 52.4), ('Extra trees (picked)', AFTER, 59.2)], (0, 70)), note='1,314 held-out outlets, 103 went quiet in the 8 weeks after 25 Aug. Extra trees was picked on two validation folds before the test.') def adoption(): two_panel(f'{OUT}/adoption.png', 'Cluster adoption: our time-honest copy of the test (Aug - Oct adoptions, held-out outlets)', ('AUC within cluster (mean of 5)', '{:.3f}', [('Channel popularity', BASE, 0.649), ('LightGBM (site model)', BEFORE, 0.708), ('Logistic + XGBoost (picked)', AFTER, 0.750)], (0.5, 0.85)), ('Adoptions caught in top 10%', '{:.0f}%', [('Channel popularity', BASE, 29.5), ('LightGBM (site model)', BEFORE, 24.6), ('Logistic + XGBoost (picked)', AFTER, 31.8)], (0, 45)), note='3,885 held-out outlet x cluster pairs, 220 adoptions; Personal Care left out. The site reports 0.90 pooled AUC and 65% in the top 10%.') def nbs(): r = pickle.load(open('/home/claude/binzagr/boost/work/reco_final_v2.pkl', 'rb')) pick = r['pick']; res = r['res'] rows = [('Random SKUs', LIGHT, 0.76), ('Best-sellers', LIGHT, 3.80), ('Site today (as published)', BEFORE, 11.98), ('Copy of the site scorer', BASE, 100 * res['site_style']['test_p10']), ('EASE alone', LIGHT, 100 * res['ease']['test_p10']), ('EASE + RP3beta + current scores', AFTER if pick == 'blend' else LIGHT, 100 * res['blend']['test_p10']), ('LightGBM ranker on all scores', AFTER if pick == 'ranker' else LIGHT, 100 * res['ranker']['test_p10'])] fig, ax = plt.subplots(figsize=(W, 2.9)) labs = [x[0] for x in rows][::-1]; vals = [x[2] for x in rows][::-1]; cols = [x[1] for x in rows][::-1] ax.barh(range(len(rows)), vals, color=cols, height=0.62, edgecolor='white', linewidth=0.8) for i, v in enumerate(vals): ax.text(v + 0.15, i, f'{v:.1f}%', va='center', fontsize=8.8, color=INK) ax.set_yticks(range(len(rows))); ax.set_yticklabels(labs, fontsize=9.2, color=INK); ax.set_xlim(0, 16) ax.set_xlabel('Share of the 10 picks bought in the 8 unseen weeks (precision@10), %'); clean_axes(ax) ax.set_title('Next-best SKU on the site\'s own test: 4,097 outlets, picks made 25 Aug', loc='left', fontsize=11, color=INK, fontweight='bold', pad=8) fig.tight_layout(); fig.savefig(f'{OUT}/nbs.png', dpi=220); plt.close(fig) if __name__ == '__main__': forecast_levels(); sku_tradeoff(); churn(); adoption() try: nbs() except FileNotFoundError: print('reco_final_v2.pkl not ready; nbs chart skipped') print('charts written')