সরাসরি পর্বে যান
পর্ব ৭২ · ৭৭-এর মধ্যে · মডিউল ১৩

ক্যাপস্টোন — বাজারঘরের churn আর lifetime value প্রকল্প

Capstone — A Churn and Customer Lifetime Value Project: Leakage-Safe Features, Time-Aware Validation, Calibration, Conformal CLV, Fairness, an Uplift-Aware Offer Policy and an A/B Launch Plan

Taught in Bangla, this practice page builds a full churn and lifetime-value project on fictional shop data: leakage-tested time-window features, rolling-origin model comparison, calibration and a one-time test, conformal CLV intervals, group fairness checks, an uplift-based coupon policy, A/B sample size, a model card and an assertion-checked memo, plus a real uv and pytest run.

এই পর্বে যা শিখবেন

  • একটি ব্যবসায়িক সিদ্ধান্তকে ML প্রকল্পে সাজানো: টাকায় সাফল্যের মাপ, offline সংকেত আর প্রতিটি ধাপের লিখিত মানদণ্ড
  • সময়ের জানালায় feature আর label বানানো, আর leakage ধরার একটি স্বয়ংক্রিয় test লেখা
  • Baseline থেকে দুই model, সময়-ক্রমের বারবার যাচাই, calibration আর একবারের test
  • CLV-র conformal পরিসর, ব্যাখ্যা, জেলাভিত্তিক ন্যায্যতা, আর পরীক্ষার uplift দিয়ে প্রত্যাশিত লাভে কুপনের নিয়ম
  • A/B-র sample size, নজরদারি, model card, assertion-যাচাই করা memo আর pytest-সহ প্যাকেজ
প্রকল্পের ব্রিফ

বাজারঘরের marketing দল প্রতি মাসের ১ তারিখে কিছু ক্রেতাকে ৳১০০-এর কুপন পাঠাতে চায় (মেয়াদ ৯০ দিন)। যিনি এমনিই কিনতেন, তাঁকে কুপন দিলে ছাড়ের টাকা শুধু চলে যায়। প্রশ্ন: “এ মাসে কাকে পাঠাব, আর কত লাভ আশা করি?” উত্তর: একটি তালিকা, টাকায় একটি সংখ্যা, আর তা যাচাইয়ের পরিকল্পনা।

এটি অনুশীলনের পর্ব: আগের ৭১টি পর্বের অনেক অস্ত্র একটি প্রকল্পে। ক্রেতা বিশ্লেষণের ভিত্তি পর্ব ৬৫ (RFM, churn-এর জানালা, uplift, CLV) আর পর্ব ৫৫ (censoring) থেকে; এখানে নতুন হলো পুরো পথটি এক সুতোয় বাঁধা, আর শেষ সিদ্ধান্ত টাকায়। কাঠামো AI-এর ভিত্তি কোর্সের ক্যাপস্টোনের মতো। পাতার ১২টি ব্লক ব্রাউজারের Python-এ (Pyodide, scikit-learn 1.8.0) চলে আর একই namespace ভাগ করে, তাই ক্রমে চালাতে হবে; পরের ব্লক আগে চাপলে আগেরগুলো নিজেই আগে চলে। প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। ডেটা আসে পাতার ভেতরের module bazar_churn.py থেকে।

১ · প্রকল্পের ছক — সিদ্ধান্ত, টাকায় সাফল্যের মাপ আর মানদণ্ড

পাড়ার মুদি দোকানের মালিক সব ক্রেতাকে ফোন করেন না; প্রতি সপ্তাহে আসা খালাম্মাকে ফোন অর্থহীন। তিনি ফোন করেন তাঁকে, যিনি আগে নিয়মিত ছিলেন কিন্তু দুই মাস দেখা নেই, আর যাঁর ফেরা দোকানের জন্য দামি। মাথায় তিনটি প্রশ্ন: চলে যাচ্ছেন কি না, ফোনে কাজ হবে কি না, আর ফিরলে কত লাভ। এই প্রকল্প সেই তিন প্রশ্নকে সংখ্যায় লেখে।

ডেটার একটি সারিও দেখার আগে সংজ্ঞাগুলো লেখা হলো (পর্ব ০২-এর কাঠামোয়)। Unit: মাসের ১ তারিখে এমন ক্রেতা, যিনি আগের ১৮০ দিনে অন্তত একবার কিনেছেন। Prediction time: সেই ১ তারিখ (cutoff). Label (churn): cutoff থেকে পরের ৯০ দিনে কোনো অর্ডার নেই। CLV: থাকলে পরের ১৮০ দিনে কত gross margin (বিক্রির ২০%). ১৮০ দিনে discount-এর প্রভাব সামান্য, তাই বাদ। সাফল্যের মাপ বাছাই সবচেয়ে কঠিন সিদ্ধান্ত:

একই প্রকল্প, পাঁচটি প্রস্তাবিত মাপ
প্রস্তাব
কী বলে
ফাঁক
  1. অস্পষ্ট লক্ষ্য। “Churn কমাতে হবে” শুনতে ঠিক, কিন্তু মাপা যায় না: কাদের churn, কোন সময়ে, কিসের তুলনায়, আর কত খরচে?
  2. Model-এর মাপ। AUC বলে model ঝুঁকির ক্রম কতটা ঠিক সাজায়। দরকারি, কিন্তু সিদ্ধান্ত নয়: যিনি চলেই গেছেন আর ফিরবেন না, তাঁকে চেনাও “ঠিক” ক্রম।
  3. ফাঁদের মাপ। কুপন-পাওয়াদের churn কম দেখালে মনে হয় কুপন কাজ করেছে। কিন্তু model সম্ভবত এমন মানুষও বেছেছিল, যাঁরা এমনিই থাকতেন; তুলনা ছাড়া প্রভাব মাপা যায় না (পর্ব ৬৪)।
  4. সিদ্ধান্তের মাপ। নিয়ম যাঁদের বাছে, তাঁদের এলোমেলো অর্ধেক কুপন পান, বাকিরা পান না (holdout). দুই দলের ১৮০ দিনের margin-এর পার্থক্য, কুপনের খরচ বাদে, প্রতি জনে টাকায়: এটিই মূল মাপ।
  5. পুরো চুক্তি। মূল মাপ launch-এর পরেই জানা যায়। তার আগে প্রবেশের শর্ত হিসেবে offline সংকেত, আর সঙ্গে guardrail: অভিযোগ, unsubscribe আর জেলাভিত্তিক পার্থক্য যেন না বাড়ে।

এবার পুরো প্রকল্পের পরিকল্পনা। প্রতিটি ধাপে কোন তারিখের ডেটা ছোঁয়া যায়, সেটিই আসল শৃঙ্খলা:

বাজারঘর churn + CLVপ্রতি ধাপে ব্লক আর ডেটা

            
  1. আগে কাগজে। সংজ্ঞা, মাপ আর মানদণ্ড লেখা; তারপর ডেটার পরিচয়পত্র। এখানে model নেই।
  2. সময়ের জানালা। প্রতিটি cutoff-এ feature শুধু আগের তথ্য থেকে। কথাটি বিশ্বাসে নয়, একটি test-এ প্রমাণ হয়।
  3. বাছাই শুধু অতীতে। Model আর সেটিং বাছা হয় ২০২৫-এর cutoff-এ, যাদের label জানা গেছে কুপন-পরীক্ষার আগেই।
  4. Test একবার। ১ মার্চ ২০২৬-এর cutoff চূড়ান্ত model দেখবে একবার; ফল যা-ই হোক, সেটিই রিপোর্ট।
  5. পাশের প্রশ্ন। CLV, ব্যাখ্যা আর ন্যায্যতা একই test-এর দিনে মাপা, যাতে সব সংখ্যা একই দিনের ছবি।
  6. সিদ্ধান্ত। পুরোনো এলোমেলো কুপন-পরীক্ষা থেকে কুপনের প্রভাব, আজকের ক্রেতায় প্রত্যাশিত লাভ, তারপর তা যাচাইয়ের A/B আর হস্তান্তর।

প্রতিটি ধাপের গ্রহণের মানদণ্ড আগেই লেখা; না মিললে পরের ধাপে নয়:

ধাপমানদণ্ড (আগে লেখা)
ডেটাnull, নকল আর ভবিষ্যতের তারিখ নেই; data card-এ সীমা লেখা
Featurecutoff-এর পরের অর্ডার মুছলেও feature হুবহু এক
Modelসব fold-এ recency-নিয়মের চেয়ে AUC ≥ ০.০৫ বেশি; জটিল model তখনই, যখন বেশির ভাগ fold-এ জেতে
Calibration ও testECE ≤ ০.০৫; test একবার, CI-সহ
CLV৮০% পরিসরের coverage ৭৫–৮৫%
ন্যায্যতাদলভিত্তিক calibration-এর ফাঁক ≤ ০.০৩; recall-এর ফাঁক CI-সহ লেখা
নিয়ম ও launchপ্রত্যাশিত লাভ “কাউকে না” আর “সবাইকে”-র চেয়ে বেশি; A/B-র n হিসাব করা; memo-র প্রতিটি সংখ্যা কোডে মেলে

২ · ডেটা আর data card — কী আছে, কী নেই

Module-টি ৫,০০০ জন কাল্পনিক ক্রেতার জুলাই ২০২৪–আগস্ট ২০২৬-এর অর্ডার বানায়, seed-সহ। প্রত্যেকের নিজস্ব কেনার হার আর গড় টাকা; প্রতিটি অর্ডারের পর নিজস্ব সম্ভাবনায় তিনি চুপচাপ চলে যান, ডেলিভারি দেরিতে এলে সম্ভাবনা ০.২৫ বেশি। Metro (ঢাকা, চট্টগ্রাম) আর বাকি তিন জেলায় ডেলিভারির প্রতিশ্রুতি আলাদা (২ বনাম ৪ দিন)। ১ ডিসেম্বর ২০২৫-এ সক্রিয় ক্রেতাদের এলোমেলো অর্ধেক কুপন পেয়েছিলেন; এই পরীক্ষা বলবে কুপন কার বেলায় কাজ করে। Simulation-এর “লুকোনো সত্য” (কে কবে চলে গেলেন) বিশ্লেষণ সরাসরি দেখে না, শুধু app-visit-এর মতো সংকেতে।

# bazar_churn.py — বাজারঘর ক্যাপস্টোন (কাল্পনিক, seeded)
import numpy as np
import pandas as pd

START, END = pd.Timestamp("2024-07-01"), pd.Timestamp("2026-08-31")
EXP = pd.Timestamp("2025-12-01")       # কুপন-পরীক্ষা
MARGIN, COUPON = 0.20, 100             # margin, কুপন (৳)
DISTRICTS = ["Dhaka", "Chattogram", "Sylhet", "Rajshahi", "Khulna"]   # প্রথম দুটি metro
FEATURES = ["recency", "tenure", "n_orders", "n_90", "spend_90", "aov",
            "late_share", "ddays", "visits_30"]

def _chain(rng, t0, lam, p0, metro, K=90):
    """অর্ডারের সারি; প্রতি অর্ডারের পর p0 (+0.25 দেরিতে) সম্ভাবনায় বিদায়"""
    n = t0.size
    gap = rng.exponential(1.0, (n, K)) / lam[:, None]
    gap[:, 0] = 0
    t = t0[:, None] + np.cumsum(gap, axis=1)
    dd = np.where(metro[:, None], 1 + rng.poisson(0.8, (n, K)), 2 + rng.poisson(1.9, (n, K)))
    late = dd > np.where(metro, 2, 4)[:, None]             # প্রতিশ্রুতি 2 বা 4 দিন
    stay = rng.uniform(size=(n, K)) >= p0[:, None] + 0.25 * late
    kept = np.cumprod(np.c_[np.ones(n, bool), stay[:, :-1]], axis=1).astype(bool)
    gone = np.where(kept[:, -1], np.inf, np.where(kept, t, -np.inf).max(axis=1))
    i, k = np.nonzero(kept)
    return i, t[i, k], dd[i, k], late[i, k], t, kept, gone

def simulate(n=5000, seed=72):
    rng = np.random.default_rng(seed)
    T, e = (END - START).days, (EXP - START).days
    dist = rng.choice(5, n, p=[0.40, 0.20, 0.15, 0.15, 0.10])
    metro = dist < 2
    lam = rng.gamma(2.0, 0.012, n)                     # দিনে অর্ডারের হার
    p0 = rng.beta(1.2, 16, n)                          # বিদায়ের সম্ভাবনা
    aov = rng.gamma(5.0, 120, n)                       # গড় অর্ডারের টাকা
    i, t, dd, late, tm, kept, gone = _chain(rng, rng.integers(0, T - 30, n).astype(float), lam, p0, metro)
    last_before = np.where(kept & (tm < e), tm, -np.inf).max(axis=1)
    nxt = np.where(kept & (tm >= e), tm, np.inf).min(axis=1)
    treat = np.where(last_before >= e - 180, rng.integers(0, 2, n), -1)   # -1 = পরীক্ষার বাইরে
    pull = (treat == 1) & np.isfinite(nxt) & (nxt >= e + 90) & (rng.uniform(size=n) < 0.20)
    back = (treat == 1) & (gone < e) & (rng.uniform(size=n) < 0.35)       # ফিরে আসা
    b = np.nonzero(back)[0]
    tb0 = e + rng.uniform(0, 90, b.size)
    j, tb, db, lb, _, _, gb = _chain(rng, tb0, lam[b], p0[b], metro[b])
    q = np.nonzero(pull)[0]
    c = np.r_[i, b[j], q]
    od = pd.DataFrame({"customer": c, "day": np.floor(np.r_[t, tb, e + rng.uniform(0, 90, q.size)]),
                       "amount": np.round(aov[c] * rng.gamma(8, 1 / 8, c.size)),
                       "ddays": np.r_[dd, db, np.where(metro[q], 2, 4)],
                       "late": np.r_[late, lb, np.zeros(q.size, bool)]})
    od = od[od.day <= T]
    od["date"] = START + pd.to_timedelta(od.day, unit="D")
    back_at, gone2 = np.full(n, np.inf), np.full(n, -np.inf)
    back_at[b], gone2[b] = tb0, gb                     # লুকোনো সত্য
    cust = pd.DataFrame({"district": np.array(DISTRICTS)[dist], "metro": metro.astype(int),
                         "treat": treat, "lam": lam, "gone": gone,
                         "back_at": back_at, "gone2": gone2})
    return od.sort_values(["date", "customer"]).reset_index(drop=True)[
        ["customer", "date", "amount", "ddays", "late"]], cust

ORDERS, CUST = simulate()

def snapshot(cut, label=True, horizon=180):
    """feature: cut-এর আগে; y: ৯০ দিনে অর্ডার নেই; m: ১৮০ দিনের margin"""
    cut = pd.Timestamp(cut)
    h = ORDERS[ORDERS.date < cut]
    d = (cut - h.date).dt.days
    g = h.assign(d=d, a90=h.amount.where(d < 90, 0), n90=d < 90).groupby("customer")
    X = pd.DataFrame({"recency": g.d.min(), "tenure": g.d.max(), "n_orders": g.size(),
                      "n_90": g.n90.sum(), "spend_90": g.a90.sum(), "aov": g.amount.mean(),
                      "late_share": g.late.mean(), "ddays": g.ddays.mean()})
    X = X[X.recency < 180]                             # unit: শেষ ১৮০ দিনে সক্রিয়
    c = CUST.reindex(X.index)
    now = (cut - START).days                           # এখনো আছেন? (লুকোনো)
    on = (c.gone > now) | ((c.back_at <= now) & (c.gone2 > now))
    rng = np.random.default_rng(cut.toordinal())       # app visit, শেষ ৩০ দিন
    X["visits_30"] = rng.poisson(30 * c.lam.to_numpy() * np.where(on.to_numpy(), 2, 0.4))
    X["metro"] = c.metro.to_numpy()
    if not label:
        return X
    f = ORDERS[(ORDERS.date >= cut) & (ORDERS.date < cut + pd.Timedelta(days=horizon))]
    y = pd.Series((~X.index.isin(f[f.date < cut + pd.Timedelta(days=90)].customer)).astype(int),
                  index=X.index)
    m = (f.groupby("customer").amount.sum() * MARGIN).reindex(X.index, fill_value=0)
    return X, y, m

প্রথম ব্লক ডেটার পরিচয় নেয়, আর চারটি স্বয়ংক্রিয় যাচাই চালায়; একটিও ব্যর্থ হলে assert থামিয়ে দেবে।

import numpy as np
import pandas as pd
import bazar_churn as bc
from bazar_churn import ORDERS, CUST, EXP, FEATURES, snapshot

od = ORDERS
print("অর্ডার:", od.shape, "| ক্রেতা:", od.customer.nunique(), "of", len(CUST))
print("তারিখ:", od.date.min().date(), "→", od.date.max().date())
print("টাকা/অর্ডার: median", od.amount.median(), "| p99", od.amount.quantile(0.99))
print("দেরির হার:", od.groupby(CUST.metro.reindex(od.customer).to_numpy())
      .late.mean().round(3).rename({0: "other", 1: "metro"}).to_dict())
print("জেলা:", CUST.district.value_counts().to_dict())
print("কুপন-পরীক্ষা", EXP.date(), "arm:", CUST.treat.value_counts().sort_index().to_dict())
checks = {"null নেই": not od.isna().any().any(),
          "নকল সারি নেই": not od.duplicated().any(),
          "টাকা > 0": bool((od.amount > 0).all()),
          "ভবিষ্যতের তারিখ নেই": bool((od.date <= bc.END).all())}
print(checks)
assert all(checks.values())

আসল আউটপুট (ব্রাউজারের Python, pandas 3.0.2):

অর্ডার: (26750, 5) | ক্রেতা: 5000 of 5000
তারিখ: 2024-07-01 → 2026-08-31
টাকা/অর্ডার: median 524.0 | p99 1786.0
দেরির হার: {'other': 0.298, 'metro': 0.189}
জেলা: {'Dhaka': 2013, 'Chattogram': 1051, 'Sylhet': 726, 'Rajshahi': 715, 'Khulna': 495}
কুপন-পরীক্ষা 2025-12-01 arm: {-1: 2618, 0: 1208, 1: 1174}
{'null নেই': True, 'নকল সারি নেই': True, 'টাকা > 0': True, 'ভবিষ্যতের তারিখ নেই': True}

২৬,৭৫০টি অর্ডার, ৫,০০০ জন ক্রেতা। অর্ডারের মাঝামাঝি টাকা ৳৫২৪, আর শীর্ষ ১%-এর সীমা ৳১,৭৮৬: লেজ লম্বা, তাই পরে CLV-র পরিসর চওড়া হবে। Metro-র বাইরে প্রায় ৩০% অর্ডার দেরিতে, metro-তে ১৯%: এই পার্থক্য পরে ন্যায্যতার প্রশ্নে ফিরবে। কুপন-পরীক্ষায় ১,২০৮ জন control, ১,১৭৪ জন কুপন; −১ মানে পরীক্ষার সময় সক্রিয় ছিলেন না।

এবার পর্ব ০৫ আর AI-এর ভিত্তি কোর্সের পর্ব ৭২-এর ধাঁচে একটি data card:

ভাগORDERS আর CUST (কাল্পনিক)
উদ্দেশ্য৯০ দিনের churn, ১৮০ দিনের margin আর কুপনের প্রভাব শেখানো ও মাপা
গঠনORDERS: customer, date, amount (৳), ddays (ডেলিভারির দিন), late; CUST: district, metro, treat (−১/০/১), আর simulation-এর লুকোনো কলাম lam, gone, back_at, gone2
সংগ্রহseed ৭২ দিয়ে বানানো; কোনো আসল ক্রেতা, ফোন নম্বর বা দোকান নেই
পরীক্ষা১ ডিসেম্বর ২০২৫, সক্রিয় ক্রেতার এলোমেলো অর্ধেক, ৳১০০ কুপন, মেয়াদ ৯০ দিন
জানা সীমাএকটি সরল জগৎ: মৌসুম, ঈদ, প্রতিযোগী, দামের বদল নেই; app-visit শুধু cutoff-এর আগের ৩০ দিনের একটি গোনা; জেলার পার্থক্য শুধু ডেলিভারিতে
ব্যবহারশুধু শেখার জন্য; সংখ্যাগুলো এই ডেটার, কোনো বাস্তব দোকানের সাধারণ সত্য নয়

মানদণ্ড ১–২: চারটি যাচাই True, আর data card-এ সীমা লেখা। ✔

৩ · সময়ের জানালায় feature আর label — leakage ছাড়া

পর্ব ৬৫-এ দেখেছেন, cutoff-এর পরের একটিমাত্র অর্ডার feature-এ ঢুকলে AUC কাগজে বাড়ে, বাস্তবে নামে (target leakage). এখানে নিয়ম তিনটি: feature শুধু cutoff-এর আগের observation window থেকে; label পরের ৯০ দিনের label window-এ; আর train-এর শেষ cutoff-এর label window শেষ হবে যাচাই বা test-এর cutoff-এর আগে। নিচের সময়রেখা পুরো প্রকল্পের ক্যালেন্ডার:

প্রকল্পের ক্যালেন্ডার — কোন cutoff কোন কাজে

নীল = train-এর cutoff, কমলা = ৯০ দিনের label, সবুজ = কুপন-পরীক্ষা, লাল = test, বেগুনি = আজ

Project calendar: training cutoffs, validation, coupon experiment, test and launch Train · cutoff Jan–Jun 2025 যাচাই · Jun–Sep (rolling) কুপন-পরীক্ষা · 1 Dec 2025 Test · 1 Mar 2026 · একবার আজ · 1 Sep 2026 · label নেই Jul 24Jan 25Jul 25Jan 26Jul 26 labellabel৯০ দিনCLV ১৮০ দিন
ছবি ৩.১ — Train-এর শেষ cutoff (১ জুন ২০২৫)-এর label শেষ হয় ২৯ আগস্ট, যাচাইয়ের প্রথম cutoff-এর আগে। কুপন-পরীক্ষার জানালা (ডিসেম্বর–ফেব্রুয়ারি) কোনো train label-এ পড়ে না, তাই train-এর churn কুপনে প্রভাবিত নয়। Test-এর cutoff-এ CLV-র ১৮০ দিন শেষ হয় ২৭ আগস্ট ২০২৬, ডেটার শেষ দিনের আগে। “আজ”-এর ক্রেতার কোনো label নেই: সেখানে শুধু সিদ্ধান্ত।

বাস্তবে feature আসে ডেটাবেস থেকে, পর্ব ৬১-এর মতো একটি query-তে (PostgreSQL-এর ধাঁচে)। মূল কথা একটি: প্রতিটি সারিতে date < :cutoff.

-- :cutoff = '2025-04-01'; প্রতিটি feature শুধু cutoff-এর আগের অর্ডার থেকে
SELECT customer,
       :cutoff - MAX(date)                         AS recency,
       :cutoff - MIN(date)                         AS tenure,
       COUNT(*)                                    AS n_orders,
       COUNT(*) FILTER (WHERE date >= :cutoff - 90) AS n_90,
       AVG(amount)                                 AS aov,
       AVG(late::int)                              AS late_share
FROM orders
WHERE date < :cutoff
GROUP BY customer
HAVING :cutoff - MAX(date) < 180;               -- unit: শেষ ১৮০ দিনে সক্রিয়

দ্বিতীয় ব্লক সব cutoff-এর টেবিল বানায়, তারপর একটি leakage test চালায়: cutoff-এর পরের সব অর্ডার মুছে দিয়ে আবার feature বানায়। Feature যদি সত্যিই শুধু অতীত দেখে, দুই টেবিল হুবহু এক হবে।

CUTS = pd.date_range("2025-01-01", "2025-09-01", freq="MS")   # train-এর cutoff
TEST, LAUNCH = pd.Timestamp("2026-03-01"), pd.Timestamp("2026-09-01")
P = {c: snapshot(c) for c in [*CUTS, EXP, TEST]}
rows = [(c.date(), len(y), y.mean(), m.mean()) for c, (X, y, m) in P.items()]
T = pd.DataFrame(rows, columns=["cutoff", "n", "churn_90", "margin_180"])
print(T.round(3).to_string(index=False))

# leakage test: cutoff-এর পরের সব অর্ডার মুছে দিলে feature এক থাকতে হবে
full = bc.ORDERS
for c in [CUTS[3], TEST]:
    bc.ORDERS = full[full.date < c]
    same = snapshot(c, label=False).equals(P[c][0])
    bc.ORDERS = full
    print("leakage test", c.date(), "features unchanged:", same)
    assert same
X, y, m = P[CUTS[0]]
print(X.head(3).round(2).to_string())

আসল আউটপুট:

    cutoff    n  churn_90  margin_180
2025-01-01 1244     0.472     245.224
2025-02-01 1397     0.478     238.731
2025-03-01 1504     0.489     246.677
2025-04-01 1614     0.491     249.613
2025-05-01 1735     0.484     246.758
2025-06-01 1849     0.479     243.862
2025-07-01 1971     0.490     246.398
2025-08-01 2092     0.487     248.690
2025-09-01 2171     0.501     254.512
2025-12-01 2375     0.424     253.498
2026-03-01 2604     0.480     234.425
leakage test 2025-04-01 features unchanged: True
leakage test 2026-03-01 features unchanged: True
          recency  tenure  n_orders  n_90  spend_90     aov  late_share  ddays  visits_30  metro
customer                                                                                        
4               2     141         6     3    1935.0  665.50        0.17   3.67          0      0
8              12      84         2     2    1376.0  688.00        0.50   3.00          4      1
11              8     153         3     2    1884.0  969.67        0.00   1.00          0      1

দোকান বাড়ছে, তাই সক্রিয় ক্রেতা ১,২৪৪ থেকে ২,৬০৪। Churn-এর হার প্রায় স্থির, ০.৪৭–০.৫০; শুধু ১ ডিসেম্বরে ০.৪২৪, কারণ অর্ধেক মানুষ কুপন পেয়েছিলেন। ওই label train-এ দিলে model কুপনের প্রভাবকে “স্বাভাবিক আচরণ” হিসেবে শিখত। দুই cutoff-এ leakage test পাস। ক্রেতা ৪-এর app-visit ০, অথচ দুই দিন আগে কিনেছেন: একটি সংকেতে সিদ্ধান্ত হয় না।

মানদণ্ড ৩: leakage test দুই cutoff-এ True. ✔ মনে রাখবেন, test-টি শুধু অর্ডারের টেবিলের ভবিষ্যৎ ধরে; অন্য টেবিল (যেমন app-visit-এর log) ভবিষ্যৎ থেকে হালনাগাদ হলে তার জন্য আলাদা test লাগবে।

৪ · Baseline থেকে model — সময় ধরে বারবার যাচাই

একটি fold-এর ফল ভাগ্য হতে পারে, তাই rolling origin (পর্ব ৫২-এর backtesting-এর মতো): train cutoff বাড়তে থাকে, আর প্রতিবার যাচাই হয় তিন মাস পরের cutoff-এ, যাতে label window না মেশে। Baseline দুটি: সবার জন্য train-এর গড় হার (Brier প্রায় ০.২৫), আর শুধু recency দিয়ে সাজানোর নিয়ম। Model দুটি: log + standardize করে logistic regression, আর HistGradientBoosting (পর্ব ৩৬, পর্ব ৩৮)। সব model-এর একই fold, একই feature, একই মাপ।

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
from sklearn.metrics import roc_auc_score, brier_score_loss

def stack(cs):
    S = [P[c] for c in cs]
    return [pd.concat([s[i] for s in S]) for i in range(3)]
make_lr = lambda: make_pipeline(FunctionTransformer(np.log1p), StandardScaler(),
                                LogisticRegression(max_iter=1000))
make_hgb = lambda: HistGradientBoostingClassifier(max_iter=150, learning_rate=0.05,
                       max_leaf_nodes=15, early_stopping=False, random_state=72)
res = []
for k in range(2, 6):                       # rolling origin: ৪টি fold
    Xt, yt, _ = stack(CUTS[:k + 1]); va = CUTS[k + 3]   # মাঝে ৯০ দিনের ফাঁক
    Xv, yv, _ = P[va]
    r = {"valid": va.strftime("%b"), "n_train": len(yt), "rule": roc_auc_score(yv, Xv.recency),
         "base_brier": brier_score_loss(yv, np.full(len(yv), yt.mean()))}
    for name, make in [("lr", make_lr), ("hgb", make_hgb)]:
        p = make().fit(Xt[FEATURES], yt).predict_proba(Xv[FEATURES])[:, 1]
        r[name], r[name + "_brier"] = roc_auc_score(yv, p), brier_score_loss(yv, p)
    res.append(r)
R = pd.DataFrame(res).set_index("valid")
print(R.round(3).to_string())
print("mean:", R.mean(numeric_only=True).round(3).to_dict())
print("lr − hgb AUC প্রতি fold:", (R.lr - R.hgb).round(3).tolist())

আসল আউটপুট:

       n_train   rule  base_brier     lr  lr_brier    hgb  hgb_brier
valid                                                               
Jun       4145  0.692        0.25  0.770     0.196  0.767      0.198
Jul       5759  0.680        0.25  0.768     0.196  0.759      0.201
Aug       7494  0.701        0.25  0.789     0.187  0.785      0.189
Sep       9343  0.678        0.25  0.773     0.194  0.776      0.193
mean: {'n_train': 6685.25, 'rule': 0.688, 'base_brier': 0.25, 'lr': 0.775, 'lr_brier': 0.193, 'hgb': 0.772, 'hgb_brier': 0.195}
lr − hgb AUC প্রতি fold: [0.002, 0.009, 0.004, -0.004]

Recency-নিয়ম একাই AUC প্রায় ০.৬৯: শক্ত baseline, আর দুই model তাকে প্রতিটি fold-এ প্রায় ০.০৮–০.১০ ছাড়ায়। দুই model-এর গড় ০.৭৭৫ বনাম ০.৭৭২; জোড়ায় দেখলে logistic চারটির তিনটিতে এগিয়ে, পার্থক্য −০.০০৪ থেকে +০.০০৯. এটি জয় নয়, সমতা। সিদ্ধান্ত: logistic regression, কারণ সমান ফলে সরল model ব্যাখ্যা, calibration আর রক্ষণাবেক্ষণে সস্তা। এই simulation-এ ঝুঁকি log-এ প্রায় রৈখিক; বাস্তবের ডেটায় ফল উল্টাতে পারে, তাই প্রতিবার মাপতে হয়।

মানদণ্ড ৪: প্রতিটি fold-এ নিয়মের চেয়ে AUC ≥ ০.০৫ বেশি; boosting বেশির ভাগ fold-এ জেতেনি, তাই বাদ। ✔

৫ · Calibration আর তালাবন্ধ test — একবার

সম্ভাবনা সরাসরি টাকায় গুণ হবে, তাই ০.৭ মানে সত্যিই প্রায় ৭০% চাই (calibration, পর্ব ২৬)। চূড়ান্ত model শেখে জানুয়ারি–জুনে; আগস্টে একটি isotonic সংশোধন শেখে (FrozenEstimator: model নিজে বদলায় না); দুটিকে মাপা হয় সেপ্টেম্বরে।

from sklearn.calibration import CalibratedClassifierCV
from sklearn.frozen import FrozenEstimator

def ece(y, p, bins=10):
    k = np.minimum((p * bins).astype(int), bins - 1)
    return sum(abs(y[k == b].mean() - p[k == b].mean()) * (k == b).mean()
               for b in range(bins) if (k == b).any())
Xt, yt, _ = stack(CUTS[:6])                       # Jan–Jun: চূড়ান্ত train
lr = make_lr().fit(Xt[FEATURES], yt)
Xc, yc, _ = P[CUTS[7]]                            # Aug: calibration-এর ডেটা
iso = CalibratedClassifierCV(FrozenEstimator(lr), method="isotonic").fit(Xc[FEATURES], yc)
Xs, ys, _ = P[CUTS[8]]                            # Sep: যাচাই
for name, mdl in [("lr", lr), ("lr+isotonic", iso)]:
    p = mdl.predict_proba(Xs[FEATURES])[:, 1]
    print(f"{name:12s} Brier {brier_score_loss(ys, p):.4f}  ECE {ece(ys.to_numpy(), p):.4f}"
          f"  mean p {p.mean():.3f} vs rate {ys.mean():.3f}")
p = lr.predict_proba(Xs[FEATURES])[:, 1]
tab = pd.DataFrame({"bin": pd.cut(p, np.linspace(0, 1, 6)), "p": p, "y": ys.to_numpy()})
print(tab.groupby("bin", observed=True).agg(n=("y", "size"), mean_p=("p", "mean"),
                                            churn=("y", "mean")).round(3).to_string())

আসল আউটপুট:

lr           Brier 0.1940  ECE 0.0255  mean p 0.492 vs rate 0.501
lr+isotonic  Brier 0.1944  ECE 0.0252  mean p 0.486 vs rate 0.501
              n  mean_p  churn
bin                           
(0.0, 0.2]  339   0.128  0.139
(0.2, 0.4]  489   0.294  0.313
(0.4, 0.6]  542   0.511  0.548
(0.6, 0.8]  549   0.706  0.687
(0.8, 1.0]  252   0.860  0.849

Logistic regression এমনিতেই প্রায় calibrated: ECE ০.০২৬, গড় অনুমান ০.৪৯২ আর আসল হার ০.৫০১. Isotonic কিছু সারায়নি (Brier সামান্য খারাপ), তাই বাদ। ঝুড়ির টেবিলে (এক ধরনের reliability diagram) প্রতিটি সারিতে গড় অনুমান আর আসল হারের ফারাক ০.০৪-এর কম।

এবার test, একবার। ১ মার্চ ২০২৬-এর cutoff-এ AUC-র ৯৫% পরিসর আসে ৩০০ বার bootstrap থেকে।

# test: একবার, চূড়ান্ত model (lr, Jan–Jun) — এর পরে আর কোনো বদল নয়
Xte, yte, mte = P[TEST]
pte = lr.predict_proba(Xte[FEATURES])[:, 1]
rng = np.random.default_rng(72)
boot = []
for _ in range(300):
    i = rng.integers(0, len(yte), len(yte))
    boot.append(roc_auc_score(yte.iloc[i], pte[i]))
TESTRES = {"n": len(yte), "churn": yte.mean(), "auc": roc_auc_score(yte, pte),
           "auc_lo": np.quantile(boot, 0.025), "auc_hi": np.quantile(boot, 0.975),
           "rule_auc": roc_auc_score(yte, Xte.recency),
           "brier": brier_score_loss(yte, pte), "ece": ece(yte.to_numpy(), pte)}
for k, v in TESTRES.items():
    print(f"{k:9s} {v:.3f}")
top = pte >= np.quantile(pte, 0.7)
print("শীর্ষ ৩০% ঝুঁকির মধ্যে আসল churn:", round(yte[top].mean(), 3),
      "| ধরা পড়া churner-এর ভাগ:", round(yte[top].sum() / yte.sum(), 3))

আসল আউটপুট:

n         2604.000
churn     0.480
auc       0.775
auc_lo    0.756
auc_hi    0.793
rule_auc  0.689
brier     0.193
ece       0.029
শীর্ষ ৩০% ঝুঁকির মধ্যে আসল churn: 0.766 | ধরা পড়া churner-এর ভাগ: 0.479

Test-এ AUC ০.৭৭৫ [০.৭৫৬, ০.৭৯৩], যাচাইয়ের গড়ের সমান; recency-নিয়ম ০.৬৮৯. ECE ০.০২৯, মানদণ্ডের (≤ ০.০৫) ভেতরে। কাজের ভাষায়: ঝুঁকির শীর্ষ ৩০%-এর ৭৬.৬% সত্যিই চলে গেছেন, আর সব churner-এর ৪৭.৯% এই তালিকায়। ফল যা এল, তা-ই রিপোর্ট; এর পরে model বদলালে নতুন test লাগবে।

মানদণ্ড ৫–৬: ECE ০.০২৯ ≤ ০.০৫; test একবার, CI-সহ। ✔

৬ · CLV আর তার পরিসর — conformal দিয়ে সৎ অনিশ্চয়তা

প্রশ্ন: “এই ক্রেতা থাকলে ১৮০ দিনে কত margin দেবেন?” তাই CLV model শেখে শুধু তাঁদের সারিতে, যাঁরা থেকেছেন। টাকা অঋণাত্মক আর লেজ লম্বা, তাই HistGradientBoosting-এর loss="poisson", যা গড় অনুমান করে (log-এ শিখে উল্টালে গড় নয়, মাঝামাঝি মানের কাছাকাছি আসে, আর মোট টাকা কম দেখায়)। পর্ব ৬৫-এর BG/NBD আর Gamma-Gamma একটি বিকল্প পথ। পরিসরের জন্য পর্ব ৫৮-এর split conformal, আপেক্ষিক ভুল দিয়ে: score = |আসল − অনুমান| ÷ অনুমান, তাই বড় ক্রেতার পরিসর বড়। Calibration-এর ডেটা জুনের cutoff, যার ১৮০ দিন কুপন-পরীক্ষার আগেই শেষ।

from sklearn.ensemble import HistGradientBoostingRegressor
# CLV: থাকলে ১৮০ দিনের margin; শুধু থেকে-যাওয়াদের সারিতে
Xa, ya, ma = stack(CUTS[:3])                      # Jan–Mar: ১৮০ দিন শেষ হয় Aug-এ
clv = HistGradientBoostingRegressor(loss="poisson", max_iter=150, learning_rate=0.05,
          max_leaf_nodes=15, random_state=72).fit(Xa[FEATURES][ya == 0], ma[ya == 0])
Xk, yk, mk = P[CUTS[5]]                           # Jun: conformal calibration
s = (yk == 0).to_numpy()
fk = clv.predict(Xk[FEATURES][s])
score = np.abs(mk[s] - fk) / fk                   # আপেক্ষিক ভুল
n_cal, alpha = s.sum(), 0.2
q = np.sort(score)[int(np.ceil((n_cal + 1) * (1 - alpha))) - 1]
st = (yte == 0).to_numpy()                        # test: যাঁরা থেকেছেন
f = clv.predict(Xte[FEATURES][st])
lo, hi = np.maximum(f * (1 - q), 0), f * (1 + q)
truth = mte[st].to_numpy()
CLVRES = {"n_cal": n_cal, "q": q, "coverage": ((truth >= lo) & (truth <= hi)).mean(),
          "median_width": np.median(hi - lo), "mean_pred": f.mean(), "mean_true": truth.mean()}
for k, v in CLVRES.items():
    print(f"{k:12s} {v:8.3f}")
i = np.argsort(f)[[len(f) // 2, int(len(f) * 0.95)]]
for j in i:
    print(f"ক্রেতা: অনুমান ৳{f[j]:.0f}, 80% পরিসর [{lo[j]:.0f}, {hi[j]:.0f}], আসল ৳{truth[j]:.0f}")

আসল আউটপুট:

n_cal         964.000
q               0.790
coverage        0.829
median_width  625.065
mean_pred     448.468
mean_true     416.384
ক্রেতা: অনুমান ৳395, 80% পরিসর [83, 708], আসল ৳413
ক্রেতা: অনুমান ৳920, 80% পরিসর [193, 1646], আসল ৳1425

৯৬৪টি calibration সারি থেকে q = ০.৭৯০: অনুমানের ০.২১ থেকে ১.৭৯ গুণ পর্যন্ত পরিসর। Test-এ coverage ০.৮২৯, লক্ষ্য ০.৮০-এর কাছে আর মানদণ্ডের (৭৫–৮৫%) ভেতরে। গড় অনুমান ৳৪৪৮, আসল ৳৪১৬: প্রায় ৮% বেশি, কারণ calibration থেকে test পর্যন্ত নয় মাসে দোকান বদলেছে; conformal-এর নিশ্চয়তা দাঁড়িয়ে আছে exchangeability-র ওপর, যা সময়ের সঙ্গে ক্ষয়ে যায়। মাঝামাঝি প্রস্থ ৳৬২৫: একজনের ১৮০ দিনের কেনাকাটা সত্যিই অনিশ্চিত। দলের মোট টাকায় ভুল অনেক কম, কারণ ব্যক্তির ভুল যোগে কাটাকাটি হয়।

মানদণ্ড ৭: coverage ০.৮২৯, ৭৫–৮৫%-এর ভেতরে। ✔ বিকল্প হিসেবে পর্ব ২১-এর bootstrap দিয়ে গড় CLV-র পরিসর মাপা যায়; conformal দেয় একজনের পরিসর। দুটি আলাদা প্রশ্ন।

৭ · ব্যাখ্যা আর ন্যায্যতা — কেন, আর কার বেলায় কতটা ঠিক

দুই স্তরে ব্যাখ্যা। পুরো model-এর জন্য test-এ permutation importance (পর্ব ৫৬): একটি কলাম এলোমেলো করলে AUC কত নামে। একজন ক্রেতার জন্য: logistic regression-এ log-odds = intercept + ∑ coefj × zj, যেখানে zj হলো log আর standardize করা feature (train-এর গড় ০)। তাই প্রতিটি পদ সেই feature-এর অবদান, গড় ক্রেতার তুলনায়। স্বাধীন feature ধরলে রৈখিক model-এ এটিই নিখুঁত Shapley value (পর্ব ৫৭), কোনো আনুমানিক হিসাব লাগে না।

from sklearn.inspection import permutation_importance
pi = permutation_importance(lr, Xte[FEATURES], yte, scoring="roc_auc",
                            n_repeats=5, random_state=72)
imp = pd.DataFrame({"auc_drop": pi.importances_mean, "sd": pi.importances_std},
                   index=FEATURES).sort_values("auc_drop", ascending=False)
print(imp.round(3).head(5).to_string())

# একজন ক্রেতা: linear model-এ log-odds = intercept + Σ coef × z (z = scaled log1p)
sc, m = lr[1], lr[2]
z = sc.transform(np.log1p(Xte[FEATURES]))
who = int(np.argmin(np.abs(pte - 0.80)))              # ঝুঁকি প্রায় ০.৮০
contrib = pd.Series(m.coef_[0] * z[who], index=FEATURES).sort_values(key=abs, ascending=False)
print("\nক্রেতা", Xte.index[who], "| কাঁচা মান:",
      Xte.iloc[who][["recency", "n_orders", "visits_30", "late_share"]].round(2).to_dict())
print("intercept", round(m.intercept_[0], 3), "+ অবদান:")
print(contrib.round(3).head(5).to_string())
logit = m.intercept_[0] + contrib.sum()
print(f"মোট log-odds {logit:.3f} → p = {1 / (1 + np.exp(-logit)):.3f} | model: {pte[who]:.3f}")

আসল আউটপুট:

            auc_drop     sd
visits_30      0.146  0.005
recency        0.036  0.001
late_share     0.022  0.002
n_90           0.009  0.001
tenure         0.008  0.002

ক্রেতা 4669 | কাঁচা মান: {'recency': 88.0, 'n_orders': 10.0, 'visits_30': 0.0, 'late_share': 0.3}
intercept -0.114 + অবদান:
visits_30     0.802
n_orders      0.457
recency       0.353
tenure       -0.242
late_share    0.119
মোট log-odds 1.386 → p = 0.800 | model: 0.800

পুরো model: app-visit এলোমেলো করলে AUC ০.১৪৬ নামে, recency-তে ০.০৩৬, দেরির ভাগে ০.০২২. Recency একাই শক্ত (০.৬৯), কিন্তু অন্য feature-এর সঙ্গে তথ্য ভাগ করে, তাই এখানে ছোট দেখায়। একজন ক্রেতা (৪৬৬৯): ঝুঁকি ০.৮০. সবচেয়ে বড় কারণ ৩০ দিনে একবারও app না খোলা (+০.৮০)। তারপর একটি শিক্ষণীয় পদ: ১০টি অর্ডার ঝুঁকি বাড়ায় (+০.৪৬)। কারণ recency-র পাশে বসে এর মানে বদলায়: নিয়মিত ক্রেতা ৮৮ দিন চুপ থাকলে তা অনিয়মিত ক্রেতার ৮৮ দিনের চেয়ে বেশি সন্দেহজনক, ঠিক BG/NBD-র যুক্তি। Tenure কিছুটা কমায় (−০.২৪)। যোগফল ১.৩৮৬, sigmoid-এ ০.৮০০, model-এর সঙ্গে হুবহু। সাবধান: পরস্পর-সম্পর্কিত feature-এ coefficient-এর চিহ্ন কারণ নয়।

এবার ন্যায্যতা (পর্ব ৬০)। জেলার দল (metro বনাম বাকি) model-এর feature-এ নেই; শুধু যাচাইয়ে লাগে। কুপন একটি সুবিধা, তাই প্রশ্ন: যাঁরা সত্যিই চলে যাচ্ছেন, তাঁদের কত ভাগ দুই দলে “ঝুঁকিতে” তালিকায় আসেন (equal opportunity), আর দুই দলেই অনুমান কি আসল হারের সঙ্গে মেলে (calibration within groups)?

# জেলা-দল শুধু যাচাইয়ের জন্য; model-এর feature-এ নেই
grp = np.where(Xte.metro == 1, "metro", "other")
thr = np.quantile(pte, 0.7)                        # শীর্ষ ৩০% = "ঝুঁকিতে"
rows = {}
for g in ["metro", "other"]:
    s = grp == g
    y, p = yte[s].to_numpy(), pte[s]
    rows[g] = {"n": s.sum(), "churn": y.mean(), "mean_p": p.mean(), "auc": roc_auc_score(y, p),
               "flag_rate": (p >= thr).mean(), "recall": (p[y == 1] >= thr).mean(),
               "precision": y[p >= thr].mean(), "late_share": Xte.late_share[s].mean()}
FAIR = pd.DataFrame(rows).T
print(FAIR.round(3).to_string())
rng = np.random.default_rng(72)
d = []
for _ in range(300):                               # recall-এর পার্থক্যের bootstrap
    i = rng.integers(0, len(yte), len(yte))
    y, p, g = yte.to_numpy()[i], pte[i], grp[i]
    r = [(p[(g == k) & (y == 1)] >= thr).mean() for k in ["other", "metro"]]
    d.append(r[0] - r[1])
print("recall: other − metro =", round(FAIR.recall["other"] - FAIR.recall["metro"], 3),
      "| 95% CI", np.quantile(d, [0.025, 0.975]).round(3))

আসল আউটপুট:

            n  churn  mean_p    auc  flag_rate  recall  precision  late_share
metro  1641.0  0.463   0.479  0.774      0.265   0.442      0.772       0.186
other   963.0  0.508   0.518  0.774      0.359   0.536      0.757       0.296
recall: other − metro = 0.094 | 95% CI [0.039 0.143]

দুই দলেই calibration ভালো (গড় অনুমান বনাম আসল: ০.৪৭৯/০.৪৬৩ আর ০.৫১৮/০.৫০৮, ফাঁক ≤ ০.০১৬) আর AUC সমান (০.৭৭৪)। কিন্তু metro-র বাইরের churner-দের ৫৩.৬% তালিকায় আসেন, metro-র ৪৪.২%: ফাঁক ০.০৯৪ [০.০৩৯, ০.১৪৩]. কারণ দুই দলের আসল হার আলাদা: বাইরে দেরি বেশি (২৯.৬% বনাম ১৮.৬%), তাই churn বেশি; তখন calibration আর সমান recall একসঙ্গে সাধারণত মেলে না। সুবিধা ঝুঁকি অনুযায়ী যাচ্ছে বলে ফাঁকটি গ্রহণযোগ্য লেখা হলো; অংশ ৮-এ ছবিটি বদলাবে।

মানদণ্ড ৯: calibration-এর ফাঁক ০.০১৬ ≤ ০.০৩; recall-এর ফাঁক CI-সহ লেখা। ✔ (ধাপ ৮ ছিল ব্যাখ্যা: ক্রেতা ৪৬৬৯-এর অবদানের যোগফল model-এর সঙ্গে মেলে। ✔)

৮ · কাকে কুপন — পরীক্ষা থেকে uplift, তারপর প্রত্যাশিত লাভ

Churn-এর সম্ভাবনা বলে কে চলে যাচ্ছেন, কুপন কাকে ফেরাবে তা বলে না (পর্ব ৬৫-এর uplift). ভাগ্যক্রমে ডিসেম্বরের এলোমেলো পরীক্ষা আছে। সেদিনের feature-এ চূড়ান্ত model দিয়ে ঝুঁকি মেপে চারটি সমান ভাগ করা হলো; প্রতিটি ভাগে কুপনহীন আর কুপন-দলের churn-এর পার্থক্যই সেই ভাগের uplift u. তারপর প্রতিটি ক্রেতার প্রত্যাশিত লাভ:

লাভ = u × CLV − ৳১০০ × (1 − p + u)

প্রথম পদ: কুপনের কারণে যিনি থেকে যান, তাঁর margin. দ্বিতীয় পদ: কুপনের খরচ, যা দিতে হয় যিনিই কেনেন, এমনকি যিনি এমনিই কিনতেন (তাঁর সম্ভাবনা 1 − p, আর কুপনে ফেরা মানুষের u). একটি অনুমান স্পষ্ট লেখা থাক: কুপনে ফেরা ক্রেতা একজন সাধারণ থাকা-ক্রেতার সমান margin দেবেন (CLV model যা অনুমান করে)। ব্লকটি পরীক্ষার দিনেই এই হিসাবকে পরীক্ষায় মাপা টাকার সঙ্গে মিলিয়ে দেখে।

from bazar_churn import COUPON
# ১ ডিসেম্বর ২০২৫-এর এলোমেলো কুপন-পরীক্ষা
Xe, ye, me = P[EXP]
pe = lr.predict_proba(Xe[FEATURES])[:, 1]          # শুধু আগের তথ্য
EDGES = np.quantile(pe, [0.25, 0.5, 0.75])         # ঝুঁকির চারটি ভাগ
E = pd.DataFrame({"risk": np.digitize(pe, EDGES) + 1, "t": CUST.treat[Xe.index].to_numpy(),
                  "churn": ye.to_numpy(), "m": me.to_numpy()})
E["net"] = E.m - COUPON * (E.t == 1) * (1 - E.churn)    # কিনলে কুপন খরচ
g = E.groupby(["risk", "t"])[["churn", "net"]]
mu, se = g.mean().unstack(), (g.std() / np.sqrt(g.count())).unstack()
UP = pd.DataFrame({"n": E.groupby("risk").size(), "churn_ctrl": mu.churn[0],
                   "uplift": mu.churn[0] - mu.churn[1], "se": np.hypot(se.churn[0], se.churn[1]),
                   "net_taka": mu.net[1] - mu.net[0], "net_se": np.hypot(se.net[0], se.net[1])})
print(UP.round(3).to_string())

def expected_profit(X):
    """uplift × CLV − কুপন × P(ব্যবহার)"""
    p = lr.predict_proba(X[FEATURES])[:, 1]
    risk = np.digitize(p, EDGES) + 1
    u = UP.uplift.reindex(risk).to_numpy()
    v = clv.predict(X[FEATURES])                   # অনুমান!
    return pd.DataFrame({"p": p, "risk": risk, "u": u, "clv": v,
                         "profit": u * v - COUPON * (1 - p + u)}, index=X.index)
chk = expected_profit(Xe).groupby("risk").profit.mean()
print("\nপরীক্ষার দিনে ৳/জন: model-এর হিসাব বনাম পরীক্ষায় মাপা")
print(pd.DataFrame({"model": chk, "measured": UP.net_taka}).round(1).T.to_string())

আসল আউটপুট:

        n  churn_ctrl  uplift     se  net_taka  net_se
risk                                                  
1     594       0.193   0.103  0.028     1.796  40.959
2     593       0.396   0.101  0.039   -18.052  23.022
3     594       0.629   0.163  0.040   -19.048  18.919
4     594       0.802   0.279  0.037    58.441  16.977

পরীক্ষার দিনে ৳/জন: model-এর হিসাব বনাম পরীক্ষায় মাপা
risk         1     2     3     4
model    -40.7 -26.8   6.1  42.5
measured   1.8 -18.1 -19.0  58.4

কুপন প্রতিটি ভাগে churn কমায়, সবচেয়ে বেশি সর্বোচ্চ ঝুঁকির ভাগে: ০.৮০২ থেকে ০.২৭৯ কম (± ০.০৩৭); সেখানেই মাপা টাকা স্পষ্ট ধনাত্মক, +৳৫৮.৪/জন (± ১৭)। নিচের ভাগে খরচ যায় তাঁদের ওপর, যাঁরা এমনিই কিনতেন। Model-এর হিসাব আর মাপা টাকা চারটি ভাগেই দুই standard error-এর ভেতরে মেলে; ভাগ ১-এ SE ৳৪১, সেখানে মাপা সংখ্যা প্রায় অর্থহীন। অনুমানটি টেকে, প্রমাণ নয়।

এবার “আজ”, ১ সেপ্টেম্বর ২০২৬: label নেই, শুধু সিদ্ধান্ত। চারটি নিয়মের তুলনা: কাউকে না, সবাইকে, ঝুঁকির শীর্ষ ৩০%, আর যাঁর প্রত্যাশিত লাভ ধনাত্মক।

import matplotlib.pyplot as plt
XL = snapshot(LAUNCH, label=False)                 # আজ, ১ সেপ্টেম্বর ২০২৬: label নেই
D = expected_profit(XL)
D["offer"] = D.profit > 0
top30 = D.p >= D.p.quantile(0.7)
POL = {"nobody": 0.0, "everyone": D.profit.sum(),
       "top 30% risk": D.profit[top30].sum(), "profit > 0": D.profit[D.offer].sum()}
print({k: round(v) for k, v in POL.items()}, "| offers:", int(D.offer.sum()), "of", len(D))
DT = D.groupby("risk").agg(n=("p", "size"), mean_p=("p", "mean"), uplift=("u", "first"),
                          clv=("clv", "mean"), offers=("offer", "sum"),
                          exp_profit=("profit", lambda s: s[s > 0].sum()))
print(DT.round(2).to_string())
grp = XL.metro.map({1: "metro", 0: "other"})
print("কুপন পাবেন (ভাগ):", D.offer.groupby(grp).mean().round(3).to_dict(),
      "| গড় CLV:", D.clv.groupby(grp).mean().round(0).to_dict())
s = D.sort_values("profit", ascending=False).profit.cumsum().to_numpy()
r = D.sort_values("p", ascending=False).profit.cumsum().to_numpy()
fig, ax = plt.subplots(figsize=(6, 3.4))
ax.plot(s, label="sorted by expected profit"); ax.plot(r, label="sorted by churn risk")
ax.axhline(0, color="grey", lw=0.8)
ax.set(xlabel="customers offered", ylabel="expected profit (BDT)", title="Offer policy at launch")
ax.legend(); fig.subplots_adjust(bottom=0.16, left=0.15); plt.show()

আসল আউটপুট (ছবিটি ব্লকের নিচে আঁকা হয়):

{'nobody': 0, 'everyone': 1457, 'top 30% risk': 36958, 'profit > 0': 48616} | offers: 1199 of 2700
        n  mean_p  uplift     clv  offers  exp_profit
risk                                                 
1     616    0.18    0.10  526.12      62      988.18
2     651    0.42    0.10  424.97      81     1238.50
3     693    0.63    0.16  370.40     380     9977.54
4     740    0.82    0.28  336.07     676    36411.34
কুপন পাবেন (ভাগ): {'metro': 0.458, 'other': 0.421} | গড় CLV: {'metro': 446.0, 'other': 348.0}

সবাইকে দিলে প্রত্যাশিত লাভ মাত্র ৳১,৪৫৭: নিচের ভাগের ক্ষতি ওপরের লাভ খেয়ে ফেলে। শীর্ষ ৩০% ঝুঁকিতে ৳৩৬,৯৫৮ (৮১০ জন)। লাভ-ভিত্তিক নিয়মে ১,১৯৯ জন, ৳৪৮,৬১৬. ছবিতে লাভের ক্রমের বক্ররেখা দ্রুত ওঠে, শিখরে থামে, তারপর নামে; ঝুঁকির ক্রমেরটি ধীরে। ন্যায্যতার নতুন ছবি: ঝুঁকির তালিকায় metro-র বাইরের মানুষ বেশি ছিলেন, কিন্তু টাকার নিয়মে metro সামান্য বেশি কুপন পায় (৪৫.৮% বনাম ৪২.১%), কারণ CLV model দেরির জন্য বাইরের ক্রেতার ভবিষ্যৎ margin কম ধরে (৳৩৪৮ বনাম ৳৪৪৬)। খারাপ সেবা পাওয়া দলকে “কম মূল্যবান” দেখিয়ে কম সুবিধা দেওয়া একটি বিপজ্জনক চক্র (ভাবনার প্রশ্ন ৩)।

লাভ = u × CLV − 100 × (1 − p + u) · গড় ক্রেতা
ভাগ
p · u · CLV
হিসাব
কুপন
  1. ভাগ ১। ৮২% এমনিই কিনবেন, তাই কুপনের খরচ প্রায় সবার ওপর: গড়ে ৳৯২. Uplift × CLV মাত্র ৳৫৪. গড় ক্রেতায় ক্ষতি প্রায় ৳৩৮; শুধু খুব উঁচু CLV-র ৬২ জনে লাভ।
  2. ভাগ ২। একই ছবি, ক্ষতি কম: প্রায় ৳২৫. ৮১ জন উঁচু CLV-র কারণে তালিকায়।
  3. ভাগ ৩। সীমান্ত: গড় ক্রেতায় প্রায় +৳৭, তাই প্রায় অর্ধেক (৩৮০ জন) তালিকায়; CLV যত বেশি, লাভ তত নিশ্চিত।
  4. ভাগ ৪। u = ০.২৭৯ বড়, আর ৮২.৫% এমনিই কিনবেন না বলে খরচ কম: গড়ে +৳৪৮. ৭৪০-এর ৬৭৬ জন তালিকায়।
  5. চার নিয়ম। সবাইকে দেওয়া প্রায় শূন্য লাভ; ঝুঁকির শীর্ষ ৩০% ভালো; লাভ-ভিত্তিক নিয়ম সবচেয়ে ভালো, কারণ uplift আর CLV দুটিই দেখে।
  6. সাবধানতা। u-র প্রতিটি মান প্রায় ৫৯৪ জনের পরীক্ষা থেকে, ± ০.০৩–০.০৪. শুধু ধনাত্মক অনুমান বেছে যোগ করলে যোগফল আশাবাদী হয় (winner's curse). তাই ৳৪৮,৬১৬ একটি অনুমান, যার যাচাই পরের অংশের A/B.

মানদণ্ড ১০: লাভ-ভিত্তিক নিয়মের প্রত্যাশিত লাভ “কাউকে না” (৳০) আর “সবাইকে” (৳১,৪৫৭)-এর চেয়ে বেশি। ✔ অনুমানটি (u, CLV) memo-তে স্পষ্ট লেখা থাকবে।

৯ · Launch — A/B পরিকল্পনা, নজরদারি, model card আর memo

৳৪৮,৬১৬ একটি অনুমান; প্রমাণ আসবে launch-এর পরীক্ষা থেকে (A/B test, পর্ব ৬২)। নকশা: নিয়ম যাঁদের বাছবে, তাঁদের customer id-র hash দিয়ে এলোমেলো অর্ধেক কুপন পাবেন, বাকিরা holdout. মূল মাপ ১৮০ দিনের net margin (কুপনের খরচ বাদে), প্রতি জনে। দুই গড়ের তুলনায় প্রতি দলে n = 2(z1−α/2 + z1−β)² σ² ÷ δ², যেখানে σ হলো মাপটির standard deviation আর δ হলো যে পার্থক্য ধরতে চাই (MDE). σ আসে ডিসেম্বরের পরীক্ষা থেকে, δ হলো প্রত্যাশিত লাভ/জন। শেষে নজরদারির প্রথম সংখ্যা: test-এর দিন আর আজকের score-বণ্টনের PSI (পর্ব ৫৯)।

from scipy.stats import norm
za, zb = norm.ppf(1 - 0.05 / 2), norm.ppf(0.80)        # α = 0.05 (দুই দিক), power 80%
Esel = E[E.risk >= 3]                                  # একই ধরনের ক্রেতা
sigma = Esel.net.std()                                 # net margin-এর SD
delta = D.profit[D.offer].mean()                       # প্রত্যাশিত লাভ/জন (৳)
n_taka = int(np.ceil(2 * (za + zb) ** 2 * sigma**2 / delta**2))
p1 = Esel[Esel.t == 0].churn.mean()                    # holdout-এর churn
p2 = p1 - 0.10                                         # ১০ বিন্দু কম
pbar = (p1 + p2) / 2
top = za * np.sqrt(2 * pbar * (1 - pbar)) + zb * np.sqrt(p1 * (1 - p1) + p2 * (1 - p2))
n_churn = int(np.ceil(top**2 / (p1 - p2) ** 2))
ABPLAN = {"sigma": sigma, "delta": delta, "n_per_arm_taka": n_taka,
          "months_taka": 2 * n_taka / D.offer.sum(), "p1": p1, "n_per_arm_churn": n_churn}
for k, v in ABPLAN.items():
    print(f"{k:16s} {v:9.2f}")
# score PSI: test বনাম আজ
b = np.quantile(pte, np.linspace(0, 1, 11)); b[[0, -1]] = 0, 1
a1, a2 = np.histogram(pte, b)[0] / len(pte), np.histogram(D.p, b)[0] / len(D)
PSI = np.sum((a2 - a1) * np.log(a2 / a1))
print(f"score PSI, test → launch: {PSI:.3f}  (সতর্কতা ≥ 0.1, ব্যবস্থা ≥ 0.25)")

আসল আউটপুট:

sigma               222.14
delta                40.55
n_per_arm_taka      472.00
months_taka           0.79
p1                    0.71
n_per_arm_churn     350.00
score PSI, test → launch: 0.042  (সতর্কতা ≥ 0.1, ব্যবস্থা ≥ 0.25)

σ = ৳২২২, δ = ৳৪০.৫: প্রতি দলে ৪৭২ জন, মোট ৯৪৪, অর্থাৎ এক মাসের তালিকার (১,১৯৯) প্রায় ৭৯%. Churn মূল মাপ হলে (০.৭১ থেকে ০.৬১) ৩৫০ জন লাগত; কিন্তু churn কমলেই লাভ হয় না, তাই টাকাই মূল মাপ। PSI ০.০৪২: score-এর বণ্টনে আজকের ক্রেতারা test-এর দিনের মতোই, তাই বণ্টন-সরণের দিক থেকে model চালাতে বাধা নেই।

বিষয়পরিকল্পনা (আগে লেখা)
Unit আর ভাগক্রেতা; নিয়মের তালিকা থেকে id-hash দিয়ে ৫০/৫০
মাপমূল: ১৮০ দিনের net margin/জন; দ্বিতীয়: ৯০ দিনের churn; guardrail: অভিযোগ, unsubscribe, জেলাভিত্তিক কুপনের হার
আকার আর সময়প্রতি দলে ≥ ৪৭২ (α = ০.০৫, power ৮০%); এক মাসের তালিকা, তারপর ১৮০ দিন অপেক্ষা; মাঝপথে বারবার দেখে থামানো নয় (পর্ব ৬২-এর peeking)
বিশ্বাসের যাচাইদুই দলের আকার ৫০/৫০-এর কাছে কি না (SRM), আর কুপন সত্যিই পাঠানো হয়েছে কি না
সিদ্ধান্ত৯৫% CI-র নিচের সীমা > ৳০ হলে চালু, সঙ্গে স্থায়ী ১০% holdout; না হলে বন্ধ, কারণ লিখে (পর্ব ৬৩-এর বিশ্লেষণ)

চালানো আর নজরদারি। সিদ্ধান্ত মাসে একবার, তাই মাসের ১ তারিখে একটি batch job পুরো টেবিলে score দেয় (পর্ব ৬৮, Model serving — FastAPI, batch scoring আর ONNX; ধারণার জন্য System Design কোর্সের batch আর stream). কী দেখবেন, পর্ব ৬৯ (ML system-এর test আর নজরদারি)-এর ভাষায়:

কীমাপ ও সীমাসীমা পেরোলে
Batch চলল কি নাসারির সংখ্যা আগের মাসের ±২০%, null নেইতালিকা পাঠানো থামানো
Score driftPSI ≥ ০.১ সতর্কতা, ≥ ০.২৫ পর্যালোচনাকোন feature সরল, খোঁজা
Calibration (৯০ দিন পরে label)ECE > ০.০৫ বা গড়ের ফাঁক > ০.০৫নতুন cutoff-এ আবার train
কুপনের প্রভাবস্থায়ী holdout-এ বাড়তি margin, পরপর দুই cohort ≤ ০নিয়ম থামানো, নতুন পরীক্ষা
ন্যায্যতাদলভিত্তিক কুপনের হার আর calibration-এর ফাঁক > ০.০৫পর্যালোচনা, CLV-র ভূমিকা কমানো

সীমা যত নিচু, মিথ্যা alarm তত বেশি; ঘটনায় কী করবেন, Cloud & DevOps কোর্সের incident management-এ। এবার model card, সীমা বেশি, প্রশংসা কম:

# Model card — বাজারঘর churn-v1 আর clv-v1 (কাল্পনিক ডেটা)
## উদ্দেশ্য
- মাসিক retention কুপনের তালিকা; সিদ্ধান্ত marketing দলের
## নিষেধ
- দাম বাড়ানো, সেবা কমানো, একক ক্রেতা সম্পর্কে রায়
## Model আর ডেটা
- churn: log1p + StandardScaler + LogisticRegression, 9টি feature, train cutoff Jan–Jun 2025
- clv: HistGradientBoostingRegressor(loss="poisson"); scikit-learn 1.8.0
## Test (cutoff 1 Mar 2026, n = 2604, একবার)
- AUC 0.775 [0.756, 0.793]; recency-নিয়ম 0.689; ECE 0.029
- CLV 80% conformal পরিসর: coverage 0.829
## দলভিত্তিক (metro বনাম বাকি)
- calibration-এর ফাঁক ≤ 0.016; churner-দের "ঝুঁকিতে" আসার হার 0.442 বনাম 0.536
- লাভের নিয়মে কুপনের হার 0.458 বনাম 0.421: CLV দেরির জন্য বাইরের ক্রেতাকে কম মূল্য দেয়
## জানা সীমা আর অনুমান
- uplift: Dec 2025-এর চার ঝুঁকি-ভাগ (±0.03–0.04); মৌসুমে বদলাতে পারে
- অনুমান: কুপনে ফেরা ক্রেতা = সাধারণ ক্রেতা; A/B-তে যাচাই
## নজরদারি
- মাসিক PSI, ৯০ দিন পরে ECE, স্থায়ী 10% holdout; মালিক: বাজারঘর ডেটা দল

এবার memo (পর্ব ৬৭-এর এক পাতার decision memo). পর্ব ৬৭-এর মতোই, memo-র প্রতিটি সংখ্যা কোড আবার হিসাব করে, আর assertion মিলিয়ে দেখে; কোড বা ডেটা বদলালে memo পুরোনো থাকলে ব্লকটি থেমে যাবে।

def memo_numbers():
    """memo-র প্রতিটি সংখ্যা আগের ব্লকের ফল থেকে"""
    return {"test_auc": TESTRES["auc"], "auc_lo": TESTRES["auc_lo"], "auc_hi": TESTRES["auc_hi"],
            "rule_auc": TESTRES["rule_auc"], "ece": TESTRES["ece"],
            "clv_coverage": CLVRES["coverage"], "recall_gap": FAIR.recall["other"] - FAIR.recall["metro"],
            "uplift_top": UP.uplift[4], "measured_top": UP.net_taka[4],
            "offers": float(D.offer.sum()), "customers": float(len(D)),
            "profit_policy": POL["profit > 0"] / 1000, "profit_top30": POL["top 30% risk"] / 1000,
            "profit_all": POL["everyone"] / 1000, "n_per_arm": ABPLAN["n_per_arm_taka"], "psi": PSI}
MEMO = {"test_auc": 0.775, "auc_lo": 0.756, "auc_hi": 0.793, "rule_auc": 0.689, "ece": 0.029,
        "clv_coverage": 0.829, "recall_gap": 0.094, "uplift_top": 0.279, "measured_top": 58.4,
        "offers": 1199, "customers": 2700, "profit_policy": 48.6, "profit_top30": 37.0,
        "profit_all": 1.5, "n_per_arm": 472, "psi": 0.042}
N = memo_numbers()
digits = lambda v: len(str(v).split(".")[1]) if "." in str(v) else 0
bad = {k: (v, N[k]) for k, v in MEMO.items() if round(N[k], digits(v)) != v}
assert not bad, f"memo-র সংখ্যা কোডের সঙ্গে মেলে না: {bad}"
print(f"memo-র {len(MEMO)}টি সংখ্যাই কোড থেকে আবার মিলেছে")
print(f"সুপারিশ: {MEMO['offers']:.0f} জনকে কুপন, প্রত্যাশিত লাভ ৳{MEMO['profit_policy']} হাজার,"
      f" A/B-তে প্রতি দলে {MEMO['n_per_arm']} জন")

আসল আউটপুট:

memo-র 16টি সংখ্যাই কোড থেকে আবার মিলেছে
সুপারিশ: 1199 জনকে কুপন, প্রত্যাশিত লাভ ৳48.6 হাজার, A/B-তে প্রতি দলে 472 জন

MEMO-র একটি সংখ্যা বদলে চালালে AssertionError দেখবেন, ফারাকসহ। তুলনা হয় memo-র দশমিকের ঘর পর্যন্ত।

নমুনা memo

বিষয়: ১,১৯৯ জনকে কুপন দিন, প্রথমে এক মাসের A/B হিসেবে
প্রাপক: marketing প্রধান · ডেটা: বাজারঘর, জুলাই ২০২৪–আগস্ট ২০২৬ (কাল্পনিক)

সুপারিশ: এ মাসে ২,৭০০ সক্রিয় ক্রেতার যে ১,১৯৯ জনের প্রত্যাশিত লাভ ধনাত্মক, তাঁদের এলোমেলো অর্ধেককে ৳১০০-এর কুপন দিন; বাকি অর্ধেক তুলনার দল।

কেন: ডিসেম্বরের পরীক্ষায় কুপন সবচেয়ে ঝুঁকিপূর্ণ ভাগে churn ০.২৭৯ কমিয়েছিল, প্রতি জনে প্রায় ৳৫৮ বাড়তি net margin; কম ঝুঁকির ক্রেতারা এমনিই কেনেন। নির্বাচিতদের প্রত্যাশিত লাভ ১৮০ দিনে প্রায় ৳৪৮.৬ হাজার; ঝুঁকির শীর্ষ ৩০%-কে দিলে ৳৩৭.০ হাজার, সবাইকে ৳১.৫ হাজার।

ভরসা কতটা: churn model নতুন ডেটায় AUC ০.৭৭৫ (০.৭৫৬ থেকে ০.৭৯৩), সম্ভাবনা বাস্তবের সঙ্গে মেলে (ECE ০.০২৯), আর CLV-র ৮০% পরিসর ৮২.৯% ক্ষেত্রে আসল টাকা ধরেছে।

ঝুঁকি: (১) লাভের হিসাব ধরে নেয়, কুপনে ফেরা ক্রেতা সাধারণ ক্রেতার মতো কিনবেন; তাই A/B (প্রতি দলে ৪৭২ জন) ছাড়া পূর্ণ চালু নয়। (২) Metro-র বাইরের churner-রা ঝুঁকির তালিকায় বেশি আসেন (ফাঁক ০.০৯৪), অথচ দেরির ডেলিভারি তাঁদের CLV কমিয়ে দেখায় বলে কুপন সামান্য কম পান; মূল সমাধান ডেলিভারি। (৩) আজকের ক্রেতারা test-এর দিনের মতোই (PSI ০.০৪২)।

মানদণ্ড ১১–১২: A/B-র n হিসাব করা (৪৭২), নজরদারির ছক লেখা, memo-র ১৬টি সংখ্যা কোডে মেলে। ✔

১০ · প্যাকেজ আর pytest, নিজের মূল্যায়ন, আর AI-এর যুগে

পাতা থেকে প্রকল্পে — uv আর pytest (ডেস্কটপে সত্যিকারের run)

AI-এর ভিত্তি কোর্সের পর্ব ১২-এর কাঠামোয় একই কোড একটি src layout package-এ: data.py (পাতার bazar_churn.py হুবহু), model.py, policy.py, report.py, আর tests/-এ সাতটি pytest test: data card, দুই cutoff-এ leakage, label-এর সংজ্ঞা, recency-নিয়মকে হারানো, calibration আর একই seed-এ একই model. Python 3.14.4, uv 0.11.28; প্রকল্প তৈরিতে --vcs none --author-from none, যাতে uv কম্পিউটারের Git পরিচয় না পড়ে।

~/bazarghar-ml$ uv init --lib --vcs none --author-from none --python 3.14 bazarghar-churn
Initialized project `bazarghar-churn` at `~/bazarghar-ml/bazarghar-churn`
~/bazarghar-ml$ cd bazarghar-churn
$ uv add "scikit-learn==1.8.0" "pandas>=3.0" numpy scipy
 + numpy==2.5.4
 + pandas==3.1.1
 + scikit-learn==1.8.0
 + scipy==1.18.1
 …
$ uv add --dev pytest
 + pytest==9.2.0
 …
$ uv run pytest -q
.......                                                                  [100%]
7 passed in 1.12s

তারপর lockfile-সহ ফাইলগুলো নতুন ফোল্ডারে কপি করে পুরো পথ আবার:

~/bazarghar-ml/check$ uv sync --locked
Installed 16 packages in 1.25s
 …
$ uv run pytest -v
…
collecting ... collected 7 items
tests/test_project.py::test_data_card PASSED                             [ 14%]
tests/test_project.py::test_features_ignore_the_future[cut0] PASSED      [ 28%]
tests/test_project.py::test_features_ignore_the_future[cut1] PASSED      [ 42%]
tests/test_project.py::test_label_is_no_order_in_90_days PASSED          [ 57%]
tests/test_project.py::test_beats_recency_rule PASSED                    [ 71%]
tests/test_project.py::test_calibrated_on_test PASSED                    [ 85%]
tests/test_project.py::test_same_seed_same_model PASSED                  [100%]
============================== 7 passed in 4.33s ==============================
$ uv run python -m bazarghar_churn.report
{
  "test_auc": 0.775,
  "ece": 0.029,
  "clv_coverage": 0.829,
  "uplift_top": 0.279,
  "offers": 1199.0,
  "profit_policy_k": 48.616,
  "n_per_arm": 472.0,
  "sklearn": "1.8.0"
}
memo: all 7 numbers match

সত্যিকারের run (path নিরপেক্ষ করা, কিছু লাইন “…” দিয়ে বাদ)। সংখ্যাগুলো পাতার সঙ্গে হুবহু এক, যদিও ডেস্কটপের numpy 2.5.4 আর pandas 3.1.1 নতুন; scikit-learn ইচ্ছা করে 1.8.0-এ বাঁধা। Leakage test-টি pytest-এর monkeypatch দিয়ে ORDERS সাময়িকভাবে ছোট করে। Model ফাইল সংরক্ষণ আর তার নিরাপত্তা AI-এর ভিত্তি কোর্সের পর্ব ৬৪-এ।

নিজেকে মূল্যায়ন — rubric

নিজের প্রকল্প (বা এই প্রকল্পের নিজস্ব সংস্করণ) প্রতিটি সারিতে মিলিয়ে দেখুন। “দক্ষ” মানে কাজ চলবে; “চমৎকার” মানে অন্য কেউ যাচাই করতে পারবেন।

দিকশুরুদক্ষচমৎকার
ছকশুধু “churn predict”unit, cutoff, label লেখাটাকায় মূল মাপ, guardrail আর প্রতিটি ধাপের মানদণ্ড আগে লেখা
ডেটা ও leakageএলোমেলো ভাগসময় দিয়ে ভাগfeature-এর ভবিষ্যৎ-test, label window-এর ফাঁক, data card
Modelএকটি model, একটি সংখ্যাbaseline-সহ CVrolling fold, জোড়ায় তুলনা, সরলতার যুক্তি
অনিশ্চয়তাশুধু pointtest-এ CIcalibration, conformal coverage, অনুমানের SE
সিদ্ধান্তঝুঁকির শীর্ষ kchurn × CLVপরীক্ষার uplift, খরচ, অনুমান লেখা, A/B-র n
দায়িত্ব ও হস্তান্তরnotebookmodel cardদলভিত্তিক মাপ, নজরদারি, assertion-যাচাই memo, pytest আর lockfile

আরও এগোতে চাইলে

  • সময় পর্যন্ত ঘটনা: ৯০ দিনের হ্যাঁ/না-র বদলে পর্ব ৫৫-এর Cox model.
  • প্রতিজনের uplift: চারটি ভাগের বদলে পর্ব ৬৫-এর T-learner, Qini বক্ররেখায় তুলনা।
  • Drift-এর মহড়া: ২০২৬ থেকে দেরির প্রভাব ০.৪০ করে PSI আর ECE কখন সতর্ক করে, মাপুন (পর্ব ৫৯)।
  • ন্যায্যতার দাম: দুই দলে কুপনের হার সমান রাখার শর্তে লাভ কত কমে, টাকায় লিখুন (পর্ব ৬০)।
  • Dashboard: decision table পর্ব ৬৬-এর Streamlit পাতায়।
  • বড় হলে: feature-এর query পর্ব ৭০ (বড় ডেটায় ML)-এর DuckDB বা Polars দিয়ে; নকশা পর্ব ৭১ (ML system design)-এর checklist দিয়ে যাচাই।

AI-এর যুগে

AI assistant কয়েক মিনিটে ঝকঝকে churn notebook দেয়, প্রায়ই এই নীরব ভুলসহ: সময়ের ডেটায় এলোমেলো train_test_split; “মোট অর্ডার” কলাম feature-এ; শুধু AUC; uplift ছাড়া ঝুঁকির শীর্ষে কুপন; coefficient বা SHAP-কে কারণ বলা; পুরোনো API (multi_class, scikit-learn 1.8-এ TypeError; cv="prefit", এখন FrozenEstimator); memo-তে হাতে লেখা সংখ্যা। কাজ ছোট ভাগে চান, চালিয়ে মিলিয়ে নিন, আসল ক্রেতার ডেটা পেস্ট করবেন না।

কয়েকটি বানানো সারি আর কলামের নামই যথেষ্ট:

আমার orders টেবিলে কলাম: customer, date, amount, late. Python 3.14, pandas 3, scikit-learn 1.8.0 দিয়ে কোড লেখো যা: ১. snapshot(cutoff) ফাংশনে feature বানাবে শুধু date < cutoff থেকে, আর label = cutoff-এর পরের ৯০ দিনে অর্ডার নেই। ২. একটি test লিখবে: cutoff-এর পরের সব অর্ডার মুছলে feature হুবহু এক থাকে কি না। ৩. rolling origin-এ (train cutoff ≤ k, যাচাই k + ৩ মাস) recency-নিয়ম, LogisticRegression আর HistGradientBoosting তুলনা করবে, প্রতি fold-এ AUC আর Brier. ৪. পুরোনো বা সরানো parameter (penalty=None, multi_class, cv="prefit") ব্যবহার করবে না, আর কোনো warning চাপা দেবে না। ৫. কোনো সংখ্যা নিজে বানিয়ে লিখবে না; আমি চালিয়ে দেখব।

আরও গভীরে: Uplift modeling-এর প্রাথমিক কাজের মধ্যে Victor Lo-র “true lift model” (২০০২) আর Nicholas Radcliffe ও Patrick Surry-র ধারাবাহিক কাজ উল্লেখযোগ্য। খরচ-সচেতন সিদ্ধান্তে calibrated সম্ভাবনার গুরুত্ব নিয়ে Charles Elkan-এর “The Foundations of Cost-Sensitive Learning” (২০০১) পড়ার মতো। Conformal prediction-এর ভিত্তি Vovk, Gammerman ও Shafer-এর বই (২০০৫), আর model card-এর প্রস্তাব Margaret Mitchell ও সহকর্মীদের (২০১৯)।

পরীক্ষা ও ইন্টারভিউতে:

  • p = ০.৬, u = ০.২, CLV = ৳৪০০, কুপন ৳১০০: প্রত্যাশিত লাভ? ০.২ × ৪০০ − ১০০ × (১ − ০.৬ + ০.২) = ৮০ − ৬০ = +৳২০.
  • Churn model-এ এলোমেলো k-fold কেন ভুল? একই ক্রেতার অনেক cutoff দুই পাশে পড়ে আর label window মেশে; সময়-ক্রমে, ফাঁক রেখে ভাগ।
  • দুই দলের আসল হার আলাদা হলে calibration আর সমান recall একসঙ্গে? সাধারণত সম্ভব নয়; কোনটি রাখবেন, তা ক্ষতি আর সুবিধার প্রকৃতি দেখে ঠিক করুন।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১সবচেয়ে বেশি churn-ঝুঁকির ক্রেতাদের কুপন দেওয়াই তো স্বাভাবিক। uplift আর CLV জুড়ে এত জটিলতা কেন?

কারণ ঝুঁকি আর প্রভাব আলাদা প্রশ্ন। ঝুঁকি বলে কুপন ছাড়া কী ঘটবে; প্রভাব বলে কুপনের কারণে কী বদলাবে। দুইয়ের পার্থক্য নিয়ে marketing-এর গবেষণা পুরোনো। Victor Lo ২০০২ সালে “true lift model” নামে দেখান, response model যাঁদের বাছে তাঁদের অনেকে এমনিই কিনতেন। Nicholas Radcliffe আর Patrick Surry নব্বইয়ের দশকের শেষ দিক থেকে uplift নিয়ে কাজ করেন, আর ক্রেতাদের চার ভাগের ধারণা জনপ্রিয় করায় তাঁদের কাজ প্রায়ই উল্লেখ করা হয়: persuadable, sure thing, lost cause, আর sleeping dog, যিনি যোগাযোগে বিরক্ত হয়ে চলে যান।

আমাদের প্রকল্পে সংখ্যাগুলোই কথা বলে। ঝুঁকির শীর্ষ ৩০%-কে দিলে প্রত্যাশিত লাভ ৳৩৬,৯৫৮; লাভ-ভিত্তিক নিয়মে ৳৪৮,৬১৬, প্রায় ৩২% বেশি। পার্থক্য আসে দুই দিক থেকে। প্রথমত, uplift সব ভাগে সমান নয়: সর্বোচ্চ ঝুঁকির ভাগে ০.২৭৯, নিচের ভাগে প্রায় ০.১০. দ্বিতীয়ত, খরচ দিতে হয় তাঁদেরও, যাঁরা এমনিই কিনতেন; কম ঝুঁকির ভাগে সেই খরচই লোকসানের মূল। CLV তৃতীয় মাত্রা: একই uplift-এ দামি ক্রেতাকে ফেরানো বেশি লাভের।

তবে জটিলতার দামও আছে। প্রতিটি নতুন অংশ একটি নতুন অনুমান আনে: uplift ডিসেম্বরের পরীক্ষা থেকে, যা সেপ্টেম্বরে বদলাতে পারে; CLV ধরে নেয় ফেরা ক্রেতা সাধারণ ক্রেতার মতো। তাই এই নিয়ম প্রস্তাব, প্রমাণ নয়, আর তার পরীক্ষাও নকশায় রাখা হয়েছে। Sleeping dog-দের কথাও মনে রাখুন: এই simulation-এ কুপনে কেউ বিরক্ত হন না, কিন্তু বাস্তবে বারবার বার্তা পাঠালে unsubscribe বাড়তে পারে; তাই সেটি guardrail.

মূল উপলব্ধি: কে চলে যাবেন আর কাকে ফেরানো যাবে আলাদা প্রশ্ন; সিদ্ধান্ত হয় দ্বিতীয়টির টাকায়, আর তার জন্য এলোমেলো পরীক্ষা লাগে।

প্র ০২HistGradientBoosting চারটি fold-এর তিনটিতে logistic regression-এর কাছে হারল। সরল model বাছাই কি সঠিক, নাকি boosting-কে আরও tune করা উচিত ছিল?

দুটি দিক থেকে ভাবুন: প্রমাণ আর খরচ। প্রমাণের দিকে, গড় পার্থক্য ০.০০৩ আর fold-ভেদে −০.০০৪ থেকে +০.০০৯: এটি সমতা। আরও tuning করলে boosting হয়তো সামান্য এগোত, কিন্তু অনেক সেটিং চেষ্টা করে সেরাটি বাছলে যাচাইয়ের সংখ্যা নিজেই আশাবাদী হয়; tuning-এর বাজেট আগে ঠিক করা ভালো অভ্যাস (পর্ব ৩৮)। খরচের দিকে, logistic regression-এর ব্যাখ্যা নিখুঁত আর সস্তা (অংশ ৭-এর অবদানের যোগফল model-এর সঙ্গে হুবহু), calibration এমনিই ভালো, আর ছোট দলের পক্ষে রক্ষণাবেক্ষণ সহজ।

এটি কি সাধারণ সত্য যে টেবিলের ডেটায় সরল model ভালো? না। Léo Grinsztajn ও সহকর্মীরা ২০২২ সালে অনেক মাঝারি আকারের টেবিল-ডেটায় দেখান, গাছ-ভিত্তিক model প্রায়ই neural network-কে ছাড়ায়; বহু প্রতিযোগিতায় boosting-ই জেতে। কিন্তু কোন model জিতবে তা ডেটা কীভাবে তৈরি, তার ওপর নির্ভর করে। আমাদের simulation-এ ঝুঁকি মূলত কয়েকটি হারের গুণফল, যা log-এ যোগফল হয়ে যায়; log + logistic তাই প্রায় সঠিক রূপ। বাস্তবের ডেটায় মৌসুম, প্রচারণা আর interaction থাকলে boosting এগিয়ে যেতে পারে।

Leo Breiman ২০০১ সালের “Statistical Modeling: The Two Cultures” প্রবন্ধে ডেটা-মডেলিং আর algorithmic modeling-এর দুই সংস্কৃতির কথা বলেন, আর অনুমানের সঠিকতাকে গুরুত্ব দেওয়ার পক্ষে যুক্তি দেন। এই প্রকল্প দুই সংস্কৃতিরই একটি শিক্ষা মানে: একই fold-এ মেপে দেখা, আর মাপে সমান হলে সেই model, যা বোঝা আর যাচাই করা সহজ। পরের বছর ডেটা বাড়লে একই তুলনা আবার চালাতে হবে; সিদ্ধান্তটি model card-এ লেখা থাকুক।

মূল উপলব্ধি: Model বাছাই একটি মাপা সিদ্ধান্ত; সমান ফলে সরলতা জেতে, আর সিদ্ধান্তটি পরের ডেটায় আবার যাচাই হয়।

প্র ০৩লাভের নিয়মে metro-র বাইরের ক্রেতারা কম কুপন পান, কারণ দেরির ডেলিভারি তাঁদের CLV কম দেখায়। এটি কি ন্যায্য? কী করবেন?

এখানে দুটি আলাদা ছবি। ঝুঁকির model-এ দুই দলেই calibration ঠিক, আর বাইরের churner-রা তালিকায় বেশি আসেন (০.৫৩৬ বনাম ০.৪৪২), কারণ তাঁদের আসল churn বেশি। এমন অবস্থায় calibration আর সমান recall একসঙ্গে সাধারণত পাওয়া যায় না: Jon Kleinberg, Sendhil Mullainathan ও Manish Raghavan (২০১৬) আর Alexandra Chouldechova (২০১৭) গাণিতিকভাবে এই টানাপোড়েন দেখান। কিন্তু টাকার নিয়মে ছবি উল্টায়: বাইরের ক্রেতা ৪২.১% কুপন পান, metro ৪৫.৮%. কারণ CLV model শিখেছে, দেরি পাওয়া ক্রেতা কম কেনেন, তাই তাঁদের ভবিষ্যৎ মূল্য কম।

এর একটি পরিচিত বাস্তব সমান্তরাল আছে। Ziad Obermeyer ও সহকর্মীরা ২০১৯ সালে Science-এ দেখান, যুক্তরাষ্ট্রের একটি স্বাস্থ্য-algorithm রোগীর প্রয়োজন মাপতে আগের চিকিৎসা-খরচ ব্যবহার করত; যে দল কম সেবা পেয়েছিল, তাদের খরচ কম, তাই তারা “কম অসুস্থ” দেখাত। আমাদের CLV-ও তেমন: খারাপ সেবা কম কেনাকাটা আনে, আর কম কেনাকাটা আরও কম সুবিধা। এটি একটি চক্র, যা নিজে থামে না।

কী করবেন? প্রথমত, মাপটি লুকোবেন না: model card আর memo-তে দুটি হারই লেখা হয়েছে। দ্বিতীয়ত, দামটি টাকায় মাপুন: দুই দলে কুপনের হার সমান রাখার শর্তে লাভ কত কমে, তা হিসাব করা যায় (“আরও এগোতে চাইলে”-র তালিকায়)। তৃতীয়ত, সবচেয়ে কার্যকর পদক্ষেপ model-এর বাইরে: বাইরের জেলায় দেরি কমানো। কুপন একটি উপশম, রোগের কারণ ডেলিভারি। শেষ সিদ্ধান্ত ব্যবসা আর নীতির, ডেটা বিজ্ঞানীর একার নয়; তাঁর কাজ বিকল্পগুলোর দাম স্পষ্ট করা।

মূল উপলব্ধি: ন্যায্যতা শুধু model-এর নয়, পুরো সিদ্ধান্তের মাপ; মূল্যের proxy অতীতের অসম সেবা বহন করতে পারে।

প্র ০৪Test, calibration, conformal, পরীক্ষার uplift: সব offline প্রমাণ ভালো। তবু launch-এর আগে আরেকটি A/B কেন? দেরি আর খরচ কি অপচয় নয়?

Offline প্রমাণ model-এর অনুমান যাচাই করে, সিদ্ধান্তের ফল নয়। আমাদের ৳৪৮,৬১৬ সংখ্যাটি তিনটি অনুমানের ওপর দাঁড়িয়ে। এক, ডিসেম্বরের uplift সেপ্টেম্বরেও খাটবে; কিন্তু মৌসুম, প্রতিযোগীর ছাড় আর ক্রেতার মিশ্রণ বদলায়। দুই, কুপনে ফেরা ক্রেতা সাধারণ থাকা-ক্রেতার মতো কিনবেন; অনেকে হয়তো একবার কুপন ব্যবহার করে আবার হারিয়ে যান। তিন, শুধু ধনাত্মক অনুমান বেছে যোগ করা হয়েছে, যা winner's curse-এর কারণে আশাবাদী। এই অনুমানগুলোর কোনোটি offline ডেটায় সম্পূর্ণ যাচাই করা যায় না।

Ron Kohavi, Diane Tang ও Ya Xu-র “Trustworthy Online Controlled Experiments” (২০২০) বইয়ে বড় প্রযুক্তি প্রতিষ্ঠানের অভিজ্ঞতা থেকে একটি কথা বারবার আসে: আত্মবিশ্বাসী ধারণার বড় একটি অংশ পরীক্ষায় কোনো উন্নতি দেখায় না। একই বইয়ে Twyman-এর নিয়মের উল্লেখ আছে: যে সংখ্যা অবিশ্বাস্য রকম ভালো, তা সম্ভবত ভুল। তাই এলোমেলো পরীক্ষা একটি বীমা, যার দাম জানা।

আর খরচটি সীমিত। প্রতি দলে ৪৭২ জন, এক মাসের তালিকার প্রায় ৭৯%; holdout-এর মানুষ কুপন পান না, তাই প্রত্যাশিত লাভের প্রায় অর্ধেক এক মাসের জন্য ছেড়ে দিতে হয়। বিপরীতে, ভুল নিয়ম মাসের পর মাস চালালে ক্ষতি বাড়ে, আর কেউ জানতেও পারেন না। পরীক্ষাটি আরও একটি জিনিস দেয়: নতুন uplift-এর মাপ, যা পরের মাসের নিয়মে ব্যবহার করা যাবে। তবে পরীক্ষাও নিখুঁত নয়: ১৮০ দিনের অপেক্ষায় ব্যবসা অধৈর্য হয়, তাই আগে থেকে লিখে রাখা উচিত যে মাঝপথে বারবার দেখে থামানো হবে না।

মূল উপলব্ধি: Offline মাপ নিয়মে প্রবেশের অনুমতি দেয়; নিয়মটি সত্যিই টাকা আনে কি না, তা বলে শুধু এলোমেলো পরীক্ষা।

প্র ০৫নিয়ম চালু হলে কুপন-পাওয়া ক্রেতারা কম churn করবেন। পরের মাসে এই ডেটায় model আবার train করলে কী সমস্যা হবে?

Model তখন নিজের প্রভাব শিখবে। ধরুন সর্বোচ্চ ঝুঁকির ভাগে প্রায় সবাই কুপন পেলেন, আর তাঁদের churn ০.৮০ থেকে প্রায় ০.৫২-এ নামল। নতুন train ডেটায় দেখা যাবে, “৮৮ দিন চুপ, app খোলেননি” এমন ক্রেতা অর্ধেকই থেকে যান। Model শিখবে এঁরা তেমন ঝুঁকিপূর্ণ নন, পরের মাসে তাঁদের কম কুপন দেবে, তারপর তাঁদের churn আবার বাড়বে। এই দোলাচল একটি feedback loop: model-এর সিদ্ধান্ত তার নিজের ভবিষ্যৎ label বদলায়।

D. Sculley ও সহকর্মীরা ২০১৫ সালের “Hidden Technical Debt in Machine Learning Systems” প্রবন্ধে এমন feedback loop-কে ML system-এর লুকোনো ঋণের একটি উৎস হিসেবে চিহ্নিত করেন: সরাসরি loop, যেখানে model নিজের train ডেটা বেছে নেয়, আর লুকোনো loop, যেখানে দুটি system একে অপরকে প্রভাবিত করে। আমাদের প্রকল্প এর জন্য আগেই তিনটি ব্যবস্থা রেখেছে। এক, ছবি ৩.১-এ দেখেছেন, কুপন-পরীক্ষার সময়ের label train-এ নেওয়া হয়নি। দুই, স্থায়ী ১০% holdout: এই দলের কেউ কখনো কুপন পান না, তাই তাঁদের ডেটা “কুপন ছাড়া” আচরণের পরিষ্কার উৎস, churn model train করার জায়গা আর uplift মাপার তুলনার দল। তিন, প্রতিটি সারিতে কুপন পেয়েছেন কি না লিখে রাখা, যাতে পরে বিশ্লেষণে তা আলাদা করা যায়।

দামও আছে: holdout-এর মানুষ কুপন না পাওয়ায় কিছু লাভ হারায়, আর তাঁদের সংখ্যা কম বলে model শেখার ডেটা কমে। কেউ কেউ এর বদলে ছোট এলোমেলো অংশে কুপন দেওয়ার (exploration) কথা বলেন, যাতে uplift সবসময় নতুন করে মাপা যায়। কোন ভারসাম্য ঠিক, তা ব্যবসা আর ডেটার আকারের ওপর নির্ভর করে, কিন্তু কোনো তুলনার দল না রাখা প্রায় সবসময় ভুল।

মূল উপলব্ধি: যে model সিদ্ধান্ত বদলায়, সে নিজের ভবিষ্যৎ ডেটাও বদলায়; স্থায়ী holdout ছাড়া পরের model ভুল জিনিস শেখে।

অনুশীলন

হাতে হিসাব, কোড, নকশার সমালোচনা আর আউটপুট অনুমান; প্রতিটির উত্তর নিচে লুকানো আছে।

  1. টাকার হিসাব (numerical): কুপন ৳১০০. (ক) ক্রেতা A: p = ০.৭০, u = ০.১৫, CLV = ৳৫০০; ক্রেতা B: p = ০.২০, u = ০.১০, CLV = ৳৬০০. কাকে কুপন দেবেন? (খ) B-র জন্য কুপনের কোন দামে লাভ শূন্য, আর সেই দাম সরাসরি ব্যবহার করা নিরাপদ কি? (গ) σ = ৳২২০ আর δ = ৳৪০ হলে, α = ০.০৫ (দুই দিক), power ৮০%-এ A/B-র প্রতি দলে n কত? δ অর্ধেক হলে?
    • (ক) A: ০.১৫ × ৫০০ − ১০০ × (১ − ০.৭০ + ০.১৫) = ৭৫ − ৪৫ = +৳৩০, দেবেন। B: ০.১০ × ৬০০ − ১০০ × (১ − ০.২০ + ০.১০) = ৬০ − ৯০ = −৳৩০, দেবেন না; B-র CLV বেশি হলেও তিনি এমনিই কিনবেন (৮০%), তাই খরচ প্রায় নিশ্চিত।
    • (খ) c × ০.৯০ = ৬০ থেকে c ≈ ৳৬৬.৭. কিন্তু u মাপা হয়েছিল ৳১০০-এর কুপনে; ৳৬৬-এর কুপনে প্রভাব কম হতেই পারে, তাই নতুন দামের জন্য নতুন পরীক্ষা লাগে।
    • (গ) z-এর যোগ ১.৯৬০ + ০.৮৪২ = ২.৮০২, বর্গ ৭.৮৪৯. n = ২ × ৭.৮৪৯ × ২২০² ÷ ৪০² = ২ × ৭.৮৪৯ × ৪৮,৪০০ ÷ ১,৬০০ ≈ ৪৭৪.৯, তাই ৪৭৫. δ অর্ধেক (৳২০) হলে n চার গুণ, প্রায় ১,৯০০, কারণ n চলে ১/δ²-এর সঙ্গে।
  2. Leakage ধরা (কোড): ব্লক ১–৩ চালানোর পরে একটি snapshot_leaky(cut) লিখুন, যা সৎ feature-এর সঙ্গে n_total যোগ করে: ক্রেতার সব সময়ের অর্ডারের সংখ্যা, যেমন অনেক ডেটাবেসে “মোট অর্ডার” কলাম থাকে। তারপর ব্লক ২-এর মতো ভবিষ্যৎ-মোছা test দিয়ে দুটি function পরীক্ষা করুন, আর দশটি feature দিয়ে test-এর AUC মাপুন।
    def snapshot_leaky(cut):
        X, y, m = snapshot(cut)
        X["n_total"] = bc.ORDERS.groupby("customer").size().reindex(X.index)  # ভুল
        return X, y, m
    
    def no_future(make, cut):
        before = make(cut)[0]
        full = bc.ORDERS
        bc.ORDERS = full[full.date < cut]
        try:
            return make(cut)[0].equals(before)
        finally:
            bc.ORDERS = full
    
    print("honest:", no_future(snapshot, TEST), "| leaky:", no_future(snapshot_leaky, TEST))
    F2 = FEATURES + ["n_total"]
    Xt, yt, _ = (pd.concat(z) for z in zip(*[snapshot_leaky(c) for c in CUTS[:6]]))
    Xv, yv, _ = snapshot_leaky(TEST)
    for cols in [FEATURES, F2]:
        m = make_lr().fit(Xt[cols], yt)
        print(len(cols), "features, test AUC", round(roc_auc_score(yv, m.predict_proba(Xv[cols])[:, 1]), 3))

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0, পাতার ব্লক ১–৩-এর পরে):

    honest: True | leaky: False
    9 features, test AUC 0.775
    10 features, test AUC 0.911

    Test ফাঁসটি ধরেছে, আর ফাঁস-model-এর AUC ০.৯১১: চমৎকার দেখায়, কারণ যিনি পরে কিনেছেন তাঁর n_total বেশি, অর্থাৎ label-এর টুকরো feature-এ। বাস্তবে scoring-এর দিন ভবিষ্যৎ অর্ডার থাকে না, তাই এই সংখ্যা কখনো পাওয়া যাবে না। try/finally নিশ্চিত করে, মাঝপথে error হলেও আসল ORDERS ফিরে আসে।

  3. নকশার সমালোচনা: এক সহকর্মীর পরিকল্পনা: “সব মাসের cutoff-এর সারি একসঙ্গে করে train_test_split(random_state=1). Feature-এ ডেটাবেসের total_orders কলাম। পাঁচটি model test-এ চালিয়ে যেটির AUC বেশি, সেটি নেব। তারপর ঝুঁকির শীর্ষ ৩০%-কে কুপন, আর সাফল্য মাপব কুপন-পাওয়াদের churn গত বছরের সব ক্রেতার churn-এর চেয়ে কম কি না দেখে।” কী কী ভুল, আর কীভাবে সাজাবেন?
    • এলোমেলো ভাগ: একই ক্রেতার সারি দুই পাশে, label window মেশে; সময়-ক্রমে, ফাঁক রেখে ভাগ।
    • total_orders: ভবিষ্যতের অর্ডারও গোনে; cutoff-এর আগে থেকে হিসাব, ভবিষ্যৎ-মোছা test-সহ।
    • Test দিয়ে বাছাই: সংখ্যা আশাবাদী হয়; বাছাই rolling fold-এ, test একবার।
    • ঝুঁকির শীর্ষ ৩০%: uplift আর খরচ উপেক্ষা; পরীক্ষার uplift দিয়ে প্রত্যাশিত লাভ।
    • সাফল্যের মাপ: ভিন্ন মানুষ, ভিন্ন সময়ের তুলনা; এলোমেলো holdout, টাকায় মাপ।
    • যা নেই: calibration, CLV, দলভিত্তিক মাপ, নজরদারি, model card.
  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে?
    import pandas as pd
    od = pd.DataFrame({"customer": [1, 1, 2, 3, 3],
                       "date": pd.to_datetime(["2026-01-10", "2026-03-01", "2025-11-20",
                                               "2026-02-27", "2026-05-31"])})
    cut = pd.Timestamp("2026-03-01")
    last = od[od.date < cut].groupby("customer").date.max()
    rec = (cut - last).dt.days
    active = rec[rec < 180].index
    w = od[(od.date >= cut) & (od.date < cut + pd.Timedelta(days=90))]
    y = pd.Series(~active.isin(w.customer), index=active).astype(int)
    print(y.to_dict(), rec.to_dict())

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, pandas 3.1.1):

    {1: 0, 2: 1, 3: 1} {1: 50, 2: 101, 3: 2}

    ক্রেতা ১: ১ মার্চের অর্ডার cutoff-এর দিনেই, তাই feature-এ নয় (<), label window-এ (>=): churn ০, recency ৫০. ক্রেতা ২: ১০১ দিন আগে কিনেছেন, এখনো সক্রিয়, পরে কিছু নেই: churn ১. ক্রেতা ৩: পরের অর্ডার ৩১ মে, কিন্তু cutoff + ৯০ দিন = ৩০ মে আর শর্ত <: জানালার বাইরে, churn ১. সীমানার এক দিন label উল্টে দেয়; “<” না “≤”, সংজ্ঞায় লিখুন আর test করুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি churn-কুপন প্রকল্পের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. টাকায় মূল মাপ আর প্রতিটি ধাপের মানদণ্ড লেখা
  2. Cutoff-এর আগের তথ্য থেকে feature, আর ভবিষ্যৎ-মোছা leakage test
  3. সময়-ক্রমের fold-এ baseline আর model তুলনা করে বাছা
  4. Calibration দেখা, তারপর তালাবন্ধ test একবার
  5. এলোমেলো পরীক্ষার uplift আর CLV দিয়ে প্রত্যাশিত লাভের নিয়ম
  6. A/B, নজরদারি, model card আর যাচাই-করা memo

একটি churn model-এর শেষ train cutoff ১ জুন, আর label = পরের ৯০ দিনে অর্ডার নেই। যাচাইয়ের cutoff সবচেয়ে আগে কোনটি হলে train-এর label যাচাইয়ের সময়ের সঙ্গে মেশে না?

  • ১ জুলাই
  • ১ জুন
  • ১ সেপ্টেম্বর
  • যেকোনো তারিখ, এলোমেলো ভাগই যথেষ্ট

একজন ক্রেতার churn-এর সম্ভাবনা p = ০.৬, কুপনের uplift u = ০.২, থাকলে ১৮০ দিনের margin ৳৪০০, কুপন ৳১০০ (যিনিই কেনেন তিনিই ব্যবহার করেন)। প্রত্যাশিত লাভ কত?

  • +৳২০
  • +৳৮০
  • +৳৪০
  • −৳২০

একটি churn model দুই জেলা-দলেই ভালোভাবে calibrated, কিন্তু দল B-র আসল churn-এর হার বেশি। একই সীমা দিয়ে “ঝুঁকিতে” তালিকা করলে সাধারণত কী ঘটে?

  • দুই দলে churner-দের তালিকায় আসার হার সমান হয়
  • দুই দলে তালিকায় আসার হার আর churner-দের ধরা পড়ার হার আলাদা হয়
  • কোনো ভুল হয় না, কারণ model calibrated
  • জেলা feature-এ না থাকলে দুই দলের হার সমান হতে বাধ্য

CLV-র জন্য ৮০% split conformal পরিসর বানানো হলো, আর পরের সময়ের test-এ coverage এল ০.৮২৯. এর সবচেয়ে ঠিক ব্যাখ্যা কোনটি?

  • প্রতিটি ক্রেতার আসল CLV ৮২.৯% সম্ভাবনায় তাঁর পরিসরে
  • পরিসর যথেষ্ট সরু, তাই model নিখুঁত
  • conformal ব্যর্থ, কারণ ঠিক ০.৮০ আসেনি
  • সব ক্রেতা মিলিয়ে পরিসর প্রায় ৮০% ক্ষেত্রে আসল টাকা ধরে, আর সময়ের সঙ্গে সামান্য সরে যেতে পারে

একটি A/B-তে টাকার মাপের σ স্থির রেখে যে পার্থক্য ধরতে চান (δ) তা অর্ধেক করলে প্রতি দলে প্রয়োজনীয় n কী হয়?

  • দ্বিগুণ
  • অর্ধেক
  • চার গুণ
  • একই থাকে

একটি দোকান model দিয়ে বাছা ক্রেতাদের কুপন দিল, কোনো holdout রাখেনি। পরে দেখা গেল কুপন-পাওয়াদের churn, না-পাওয়াদের চেয়ে কম। কোন সিদ্ধান্ত টানা যায়?

  • কুপনের প্রভাব সম্পর্কে নির্ভরযোগ্য কিছু বলা যায় না
  • কুপন churn কমিয়েছে, প্রমাণিত
  • কুপন ক্ষতি করেছে
  • model-এর AUC বেশি, তাই কুপনও কাজ করেছে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. আগে টাকা: মূল মাপ holdout-এর তুলনায় বাড়তি net margin; AUC, ECE, coverage শুধু প্রবেশের শর্ত।
  2. সময় মানুন: feature cutoff-এর আগে থেকে, ভবিষ্যৎ-মোছা test-এ প্রমাণিত; যাচাই সময়-ক্রমে, test একবার (AUC ০.৭৭৫)।
  3. সমান হলে সরল: logistic আর boosting প্রায় সমান, তাই সরল model; isotonic বাদ।
  4. ঝুঁকি নয়, প্রভাব: uplift × CLV − খরচ: ৳৪৮.৬ হাজার, ঝুঁকির শীর্ষ ৩০%-এ ৳৩৭.০ হাজার; যাচাই A/B-তে।
  5. দায়িত্ব: দলভিত্তিক মাপ চূড়ান্ত সিদ্ধান্তেও; স্থায়ী holdout, model card, যাচাই-করা memo, pytest.

পরের পর্বে: পর্ব ৭৩ (Numerical সমস্যার অনুশীলন — ML-এর গণিত আর অ্যালগরিদম, পূর্ণ সমাধানসহ) থেকে পরীক্ষা ও ইন্টারভিউ প্রস্তুতির মডিউল শুরু: পুরো কোর্সের হিসাবগুলো, ধাপে ধাপে সমাধানসহ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান