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

Survival analysis — ক্রেতা কখন চলে যাবে, Kaplan-Meier আর Cox

Survival Analysis — Censoring, Kaplan-Meier, Log-rank, Cox Proportional Hazards, Concordance and Weibull Models

Taught in Bangla, this lesson models when fictional shop customers churn: why censoring breaks averages and churned/not labels, Kaplan-Meier with Greenwood errors matched to statsmodels, the log-rank test, Cox regression with hazard ratios and a hand-written C-index, checking proportional hazards, and censored Weibull likelihood. A real desktop run compares lifelines, Cox and a random survival forest.

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

  • Time-to-event ডেটা আর right censoring চেনা, আর কেন গড় বা churned/not label পক্ষপাতদুষ্ট, সংখ্যা দিয়ে দেখানো
  • Survival function, hazard আর cumulative hazard-এর সম্পর্ক বোঝা
  • Kaplan–Meier আর Greenwood হাতে ও কোডে বানিয়ে statsmodels-এর SurvfuncRight-এর সঙ্গে মেলানো, আর log-rank test পড়া
  • PHReg দিয়ে Cox model চালিয়ে hazard ratio ঠিকভাবে ব্যাখ্যা করা, আর concordance index নিজে লেখা
  • Proportional hazards যাচাই, stratification, censoring-সহ Weibull MLE, আর ML survival model কখন কাজে লাগে তা জানা

এতদিন label ছিল একটি সংখ্যা বা একটি শ্রেণি। এবার প্রশ্ন “কখন?”: একজন ক্রেতা কত দিন পর কেনা বন্ধ করবেন। পর্ব ০২-এ churn-কে সাধারণত একটি হ্যাঁ/না সমস্যা হিসেবে দেখেছেন। এই পর্বে দেখবেন, সময়ের প্রশ্নকে হ্যাঁ/না-তে নামালে কী হারায়, আর পরিসংখ্যানের একটি পুরোনো শাখা, survival analysis, কীভাবে তা সামলায়। ভিত্তি তিনটি: পর্ব ১৮-এর maximum likelihood, পর্ব ২০-এর exposure-এর ধারণা, আর AI-এর ভিত্তি কোর্সের পর্ব ৩৬-এর distribution. চারটি চালানো যায় এমন ব্লক পাতাতেই চলে (ব্রাউজারের Python, NumPy, pandas, SciPy, statsmodels, scikit-learn আর matplotlib); প্রথমবার package নামতে কয়েক সেকেন্ড, scikit-learn-এ প্রায় দশ সেকেন্ড লাগে।

১ · সময় পর্যন্ত ঘটনা আর censoring — অসম্পূর্ণ গল্পও তথ্য

ক্রিকেটের স্কোরকার্ডে “৪৫*” দেখেছেন: ব্যাটার ৪৫ রানে নট-আউট। তিনি কত রান করতেন, কেউ জানে না; শুধু জানা “অন্তত ৪৫”। নট-আউট ইনিংস বাদ দিলে ব্যাটারের দক্ষতা কম দেখায়, আবার নট-আউটকে আউট ধরলেও ভুল। ব্যাটিং গড়ের হিসাব তাই আলাদা নিয়ম মানে। Survival analysis এই “নট-আউট” তথ্য সৎভাবে ব্যবহারের পরিসংখ্যান।

Time-to-event ডেটায় প্রতিটি সারির label দুটি সংখ্যা: একটি সময় T আর একটি ঘটনা-সূচক δ (1 = ঘটনা দেখা গেছে, 0 = দেখা যায়নি)। বাজারঘরে ঘটনা “ক্রেতা চলে গেছেন” (ধরুন ৯০ দিন কোনো অর্ডার নেই, আর চলে যাওয়ার দিন শেষ অর্ডারের দিন), আর সময় মাপা হয় যোগদানের দিন থেকে। ডেটা কাটার দিন ধরা হলো ৩১ মার্চ ২০২৬. যে ক্রেতা তখনো সক্রিয়, তাঁর সময় হলো যোগদান থেকে কাটার দিন পর্যন্ত, আর δ = 0. একে বলে right censoring: আসল সময় এই সংখ্যার ডানে, কোথাও। এখানে censoring আসে শুধু ডেটা কাটার দিন থেকে, তাই একে administrative censoring বলে।

পাঁচজন ক্রেতা: ক্যালেন্ডারে আর যোগদান থেকে মাপা সময়ে

× = চলে গেছেন (ঘটনা); ○ = কাটার দিনে এখনো আছেন (censored)

Right censoring: calendar time versus time since sign-up ক) ক্যালেন্ডারে কাটার দিন ABCDE ২০২৪২০২৫২০২৬ খ) যোগদান থেকে মাস ABCDE ৮২১+ ?১৮৯৪+ ? ০মাস →
ছবি ১.১ — ক্যালেন্ডারে সবাই একই দিনে থামে (লাল দাগ), কিন্তু যোগদান আলাদা বলে প্রত্যেককে আলাদা সময় দেখা হয়েছে। নিচে সময় শূন্য থেকে মাপা: B অন্তত ২১ মাস আর E অন্তত ৪ মাস টিকেছেন; তাঁদের গল্প এখনো চলছে।

একটি গুরুত্বপূর্ণ অনুমান সবকিছুর নিচে আছে: non-informative censoring. অর্থাৎ censoring-এর কারণ ক্রেতার চলে যাওয়ার ঝুঁকির সঙ্গে সম্পর্কহীন। ডেটা কাটার দিন ক্যালেন্ডারের ঘটনা, ক্রেতার আচরণের নয়, তাই এখানে অনুমানটি যুক্তিসংগত। কিন্তু যদি দোকান নিজেই “ঝামেলার” ক্রেতাদের অ্যাকাউন্ট বন্ধ করে আর তাঁদের censored লেখে, তাহলে অনুমান ভাঙে, আর এই পর্বের কোনো পদ্ধতিই তা ঠিক করতে পারে না।

২ · সাধারণ পথে কেন ভুল — গড়, classifier আর censoring

প্রথমে একটি ডেটাসেট। নিচের module ৮০০ জন কাল্পনিক বাজারঘর ক্রেতা বানায়: যোগদানের দিন (২০২৪–২০২৫), Plus সদস্য কি না (plan), প্রথম মাসে ডেলিভারি নিয়ে অভিযোগের সংখ্যা (complaints), আর কুপন দিয়ে এসেছেন কি না (promo). আসল চলে যাওয়ার সময় আসে একটি Weibull model থেকে (অংশ ৩-এ ব্যাখ্যা): Plus সদস্যের ঝুঁকি ০.৬ গুণ, প্রতিটি অভিযোগে ১.৩৫ গুণ, আর কুপনের ক্রেতাদের ঝুঁকির আকার আলাদা। true_time কলামটি শুধু simulation-এ জানা “সত্য”, যা বাস্তবে কখনো পাওয়া যায় না; এটি রাখা হয়েছে পদ্ধতিগুলোর ভুল মাপার জন্য।

# বাজারঘর (কাল্পনিক দোকান): ক্রেতা কবে চলে যায়, seeded ডেটা
import numpy as np
import pandas as pd

CUTOFF = pd.Timestamp("2026-03-31")      # ডেটা কাটার দিন

def customers(n=800, seed=55):
    rng = np.random.default_rng(seed)
    start = pd.Timestamp("2024-01-01")
    signup = start + pd.to_timedelta(rng.integers(0, 731, n), unit="D")
    plan = rng.binomial(1, 0.4, n)           # 1 = Plus সদস্য
    complaints = rng.poisson(0.8, n)         # প্রথম মাসে ডেলিভারি-অভিযোগ
    promo = rng.binomial(1, 0.4, n)          # 1 = কুপন দিয়ে আসা
    # আসল model (Weibull): কুপনের ক্রেতার আকার আলাদা
    k = np.where(promo == 1, 0.6, 1.3)
    lam = np.where(promo == 1, 700.0, 520.0)
    risk = np.exp(np.log(0.6) * plan + np.log(1.35) * complaints)
    true_t = lam * (-np.log(rng.uniform(size=n)) / risk) ** (1 / k)
    follow = (CUTOFF - signup).days.to_numpy()   # কাটার দিন পর্যন্ত কত দিন
    event = (true_t <= follow).astype(int)       # 1 = চলে গেছে, 0 = censored
    time = np.where(event == 1, np.ceil(true_t), follow).astype(float)
    return pd.DataFrame({"signup": signup, "plan": plan,
                         "complaints": complaints, "promo": promo,
                         "time": time, "event": event,
                         "true_time": np.ceil(true_t)})

এখন চারটি সাধারণ কিন্তু ভুল পথ। (১) শুধু যাঁরা চলে গেছেন, তাঁদের গড় সময়। (২) censored ক্রেতাদেরও “চলে গেছেন” ধরে সবার গড়। (৩) যোগদানের বছর ধরে “চলে গেছেন কি না”-র হার। (৪) সেই হ্যাঁ/না label-এ একটি logistic regression (AI-এর ভিত্তি কোর্সের পর্ব ৫৯), যেখানে একটি feature হলো ক্রেতাকে কত দিন দেখা হয়েছে। প্রতিটির পাশে simulation-এর সত্য।

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from bazar_surv import customers, CUTOFF

df = customers()
pd.set_option("display.width", 70)
print(df[["signup", "plan", "complaints", "promo", "time", "event"]].head(4))
print("censored:", f"{1 - df.event.mean():.1%}")
# সত্য (শুধু simulation-এ জানা)
print("truth P(churn within 365 d):", f"{(df.true_time <= 365).mean():.3f}")
print("truth median lifetime:", df.true_time.median(), "days")
# ভুল ১ আর ২: গড় সময়
print("mean time, churned only:", round(df.time[df.event == 1].mean()), "days")
print("mean time, all as events:", round(df.time.mean()), "days")

# ভুল ৩: 'চলে গেছে কি না' — যোগদানের বছর ধরে
df["year"] = df.signup.dt.year
naive = df.groupby("year").event.mean()
truth = df.groupby("year").true_time.apply(lambda t: (t <= 365).mean())
print(pd.DataFrame({"churned (naive)": naive, "true 1-yr churn": truth})
      .round(3))

# ভুল ৪: churned/not classifier — কত দিন দেখা হয়েছে, সেটাই শেখে
seen = (CUTOFF - df.signup).dt.days / 100          # পর্যবেক্ষণ, 100 দিনে
X = np.c_[df.plan, df.complaints, df.promo, seen]
clf = LogisticRegression(C=1e10).fit(X, df.event)
print("odds ratio per 100 days observed:", round(np.exp(clf.coef_[0][3]), 2))
same = [[0, 0, 0, 1.0], [0, 0, 0, 7.0]]            # একই ক্রেতা, 100 বনাম 700 দিন
print("P(churn) same customer:", clf.predict_proba(same)[:, 1].round(3))

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

      signup  plan  complaints  promo   time  event
0 2025-12-05     0           0      1  116.0      0
1 2025-08-31     1           1      0   93.0      1
2 2025-06-15     1           0      0  289.0      0
3 2025-09-28     0           0      1  184.0      0
censored: 43.9%
truth P(churn within 365 d): 0.491
truth median lifetime: 376.0 days
mean time, churned only: 212 days
mean time, all as events: 285 days
      churned (naive)  true 1-yr churn
year
2024            0.711            0.492
2025            0.423            0.490
odds ratio per 100 days observed: 1.44
P(churn) same customer: [0.258 0.757]

প্রথম সারি: ক্রেতা ০ যোগ দিয়েছেন ৫ ডিসেম্বর ২০২৫-এ, কাটার দিন পর্যন্ত ১১৬ দিন, এখনো আছেন (event 0). ৮০০ জনের ৪৩.৯% এমন censored. গড়ের ভুল: আসল median জীবনকাল ৩৭৬ দিন। শুধু চলে যাওয়াদের গড় ২১২ দিন, কারণ লম্বা জীবনের ক্রেতারা কাটার দিনের আগে চলে যাওয়ার সুযোগই পাননি; তাঁরা বাদ পড়েছেন। সবাইকে ঘটনা ধরলে ২৮৫ দিন, কারণ censored সময়গুলো আসল সময়ের চেয়ে ছোট। দুটিই নিচের দিকে টানা, আর ভুলের মাপ নির্ভর করে কত দিন ডেটা জমেছে তার ওপর, ক্রেতার ওপর নয়। হারের ভুল: ২০২৪-এর ক্রেতাদের ৭১.১% “churned”, ২০২৫-এর ৪২.৩%. কেউ ভাববেন ২০২৫-এ ধরে রাখা ভালো হয়েছে। অথচ simulation-এ দুই দলের আসল এক বছরের churn একই, ৪৯%. পার্থক্য শুধু দেখার সময়ের। Classifier: প্রতি ১০০ দিন বেশি দেখায় churn-এর odds ১.৪৪ গুণ। একই Basic ক্রেতা, শূন্য অভিযোগ: ১০০ দিন দেখা হলে “churn-এর সম্ভাবনা” ০.২৫৮, ৭০০ দিন দেখা হলে ০.৭৫৭. এটি কোনো ক্রেতার ঝুঁকি নয়; এটি “কতক্ষণ খাতা খোলা ছিল”-র হিসাব।

“তাহলে শুধু পুরোনো ক্রেতা নিই”, যেমন যাঁদের অন্তত এক বছর দেখা হয়েছে, আর label “এক বছরের মধ্যে চলে গেছেন কি না”। এটি একটি বৈধ পথ (fixed-horizon classification), আর পর্ব ৬৫ (Customer analytics — RFM, retention, churn আর lifetime value) এ ধরনের নকশা কাজে লাগাবে। কিন্তু দাম আছে: সাম্প্রতিক সব ক্রেতা বাদ পড়েন (এখানে ২০২৫-এর প্রায় তিন-চতুর্থাংশ), model পুরোনো আচরণ শেখে, আর “দুই বছরে” বা “median কত দিন” প্রশ্নের উত্তর দেয় না। Survival model একই ডেটায় সব ক্রেতাকে ব্যবহার করে সব সময়ের প্রশ্নের উত্তর দেয়।

৩ · Survival function আর hazard — দুই চোখে একই গল্প

দুটি function পুরো বণ্টনের বর্ণনা দেয়। Survival function S(t) = P(T > t): একজন ক্রেতা t দিনের পরেও থাকবেন, তার সম্ভাবনা। S(0) = ১, আর সময়ের সঙ্গে S কখনো বাড়ে না। দ্বিতীয়টি hazard function:

h(t) = limΔt→০ P(t ≤ T < t + Δt | T ≥ t) / Δt = f(t) / S(t)

অর্থাৎ “আজ পর্যন্ত যিনি আছেন, তিনি ঠিক এখন চলে যাওয়ার হার”। এখানে f(t) হলো T-এর density. ছোট Δt-তে h(t)·Δt ≈ পরের Δt দিনে চলে যাওয়ার শর্তসাপেক্ষ সম্ভাবনা। hazard একটি হার, সম্ভাবনা নয়; তাই ১-এর বেশিও হতে পারে (একক: প্রতি দিন)। hazard যোগ করলে পাওয়া যায় cumulative hazard H(t) = ∫০t h(u) du, আর তিনটির বাঁধন একটি সূত্রে:

S(t) = exp(−H(t))

রিকশায় বাড়ি ফিরছেন। “পুরো পথে চাকা পাংচার না হওয়ার সম্ভাবনা” হলো S. আর “এই মুহূর্তে, এখনো পাংচার হয়নি ধরে, প্রতি কিলোমিটারে পাংচারের হার” হলো hazard: ভাঙা রাস্তায় বেশি, পাকা রাস্তায় কম। পুরো পথে hazard যোগ করে (H) e-এর ঋণাত্মক ঘাতে বসালে পাওয়া যায় পাংচার ছাড়া পৌঁছানোর সম্ভাবনা।

সবচেয়ে সরল model exponential: hazard স্থির, h(t) = ১/λ, তাই S(t) = e−t/λ. এর মানে “স্মৃতিহীন”: দুই বছর থাকা ক্রেতা আর নতুন ক্রেতার আগামী মাসে চলে যাওয়ার ঝুঁকি সমান। বাস্তবে এটি কমই সত্য। Weibull একটি আকার-parameter k যোগ করে:

S(t) = exp(−(t/λ)k),  h(t) = (k/λ)(t/λ)k−১

λ হলো scale (দিনে), k হলো shape. k = ১ হলে exponential. k < ১ হলে hazard সময়ের সঙ্গে কমে: নতুন ক্রেতা বেশি ঝুঁকিতে, যাঁরা টিকে যান তাঁরা অনুগত। কুপনের ক্রেতাদের জন্য simulation-এ k = ০.৬, ঠিক এই ছাঁদ: কুপন ফুরালে অনেকে চলে যান। k > ১ হলে hazard বাড়ে, যেমন অভ্যাস ধীরে ক্ষয়ে যাওয়া; সাধারণ ক্রেতাদের জন্য k = ১.৩. Median জীবনকাল হলো S(t) = ০.৫-এর সমাধান: λ(ln ২)১/k. আর ক্রেতার বৈশিষ্ট্য x ঝুঁকিকে exβ গুণ করলে Sx(t) = S0(t)exβ, যা অংশ ৫-এ আবার আসবে।

মূল ধারণা

Censoring-কে সৎভাবে ব্যবহারের চাবি hazard-এর দৃষ্টিভঙ্গি। “৪+ মাস” সারিটি বলে: প্রথম চার মাসের প্রতিটি দিনে এই ক্রেতা ঝুঁকিতে ছিলেন আর টিকেছেন। পাঁচ মাস থেকে তিনি আর হিসাবে নেই। প্রতিটি সময়ে শুধু “তখনো যারা দেখা যাচ্ছে” তাদের দিয়ে হার মাপলে কোনো তথ্য ফেলা হয় না, আর কোনো অজানা সংখ্যা বানাতে হয় না।

৪ · Kaplan–Meier — হাতে, Greenwood আর statsmodels

১৯৫৮ সালে Edward Kaplan আর Paul Meier যে Kaplan–Meier estimator প্রকাশ করেন, তা উপরের ধারণার সরাসরি রূপ। ঘটনার দিনগুলো t1 < t2 < … সাজান। প্রতিটি tj-এ nj = তখনো ঝুঁকিতে থাকা মানুষ (risk set: যাঁদের সময় ≥ tj), আর dj = সেদিন চলে যাওয়া মানুষ। তাহলে

Ŝ(t) = ∏tj ≤ t (১ − dj/nj)

প্রতিটি গুণক “ওই দিনে যারা ছিল, তাদের কত ভাগ টিকল”। Censored মানুষ যতদিন দেখা গেছে ততদিন n-এ গোনা হন, তারপর নিঃশব্দে সরে যান। অনিশ্চয়তার জন্য Major Greenwood ১৯২৬ সালে যে সূত্র দেন, তা আজও আদর্শ: Greenwood formula

Var[Ŝ(t)] ≈ Ŝ(t)² · ∑tj ≤ t dj / (nj(nj − dj))

নিচের stepper ছয়জন ক্রেতার ছোট একটি দলে পুরো হিসাব করে, যার দুজন censored. সময় মাসে: ২, ৪+, ৫, ৫, ৭+, ৯ (“+” মানে censored).

সময় (মাস): 2, 4+, 5, 5, 7+, 9 · Greenwood ∑ = ∑ d/(n(n − d))
এখন দেখা হচ্ছে
ঝুঁকিতে n, চলে গেছে d
গুণক 1 − d/n
Ŝ(t)
Greenwood ∑, SE
  1. শুরু। ছয়জনই ঝুঁকিতে, Ŝ = ১. সময়গুলো সাজানো; censored দুজন (৪+ আর ৭+) আলাদা চিহ্নে।
  2. t = ২. ছয়জনের একজন চলে গেলেন। গুণক ৫/৬, Ŝ = ০.৮৩৩৩. Greenwood: ০.৮৩৩৩ × √(১/৩০) = ০.১৫২১.
  3. t = ৪, censored. এখানে কোনো ঘটনা নেই, তাই Ŝ বদলায় না। কিন্তু এই ক্রেতা এখন থেকে আর ঝুঁকির হিসাবে নেই: পরের ঘটনার দিনে n হবে ৪, ৫ নয়।
  4. t = ৫. চারজন ঝুঁকিতে, দুজন চলে গেলেন: গুণক ০.৫, Ŝ = ০.৪১৬৭. Median = প্রথম যে সময়ে Ŝ ≤ ০.৫, অর্থাৎ ৫ মাস। SE ০.২২১৮, তাই সাধারণ ৯৫% পরিসর ০.৪১৭ ± ০.৪৩৫ = [−০.০১৮, ০.৮৫১]: ঋণাত্মক সম্ভাবনা! ছোট দলে এই কারণে log(−log) রূপান্তরের পরিসর ভালো।
  5. t = ৭, censored. আবার Ŝ বদলায় না; ঝুঁকিতে থাকেন শুধু ৯ মাসের ক্রেতা।
  6. t = ৯. শেষজন চলে গেলেন, Ŝ = ০; n − d = ০ বলে Greenwood-এর সংজ্ঞা নেই। তুলনা: censored দুজনকে বাদ দিলে চারজনের মধ্যে ৫ মাসে Ŝ = ১/৪ = ০.২৫, অনেক হতাশাজনক; censored-দের “৪+” আর “৭+” তথ্য ফেলে দেওয়ার দাম।

এবার ৮০০ জনে। দ্বিতীয় ব্লক Kaplan–Meier আর Greenwood নিজে লেখে, তারপর statsmodels-এর SurvfuncRight-এর সঙ্গে মেলায়, তিনটি দিনে S আর ৯৫% পরিসর ছাপায়, Basic আর Plus দলের ধাপ-আকারের বক্ররেখা আঁকে, আর দুই দলকে log-rank test-এ (অংশ ৫-এ ব্যাখ্যা) তুলনা করে।

import numpy as np
import matplotlib.pyplot as plt
from statsmodels.duration.survfunc import SurvfuncRight, survdiff
from bazar_surv import customers

def km(time, event):                 # Kaplan-Meier + Greenwood, নিজে লেখা
    t = np.unique(time[event == 1])
    n = np.array([(time >= u).sum() for u in t])           # ঝুঁকিতে
    d = np.array([((time == u) & (event == 1)).sum() for u in t])
    S = np.cumprod(1 - d / n)
    se = S * np.sqrt(np.cumsum(d / (n * (n - d))))         # Greenwood
    return t, S, se

df = customers()
T, E = df.time.to_numpy(), df.event.to_numpy()
t, S, se = km(T, E)
sf = SurvfuncRight(T, E)
print("max |S - statsmodels|  =", f"{np.max(np.abs(S - sf.surv_prob)):.1e}")
print("max |se - statsmodels| =", f"{np.max(np.abs(se - sf.surv_prob_se)):.1e}")
for day in [90, 365, 730]:
    i = np.searchsorted(t, day, side="right") - 1
    print(f"S({day}) = {S[i]:.3f}, 95% CI [{S[i] - 1.96*se[i]:.3f},"
          f" {S[i] + 1.96*se[i]:.3f}]")
print("median (days):", sf.quantile(0.5))
for p, name in [(0, "Basic"), (1, "Plus")]:
    g = df[df.plan == p]
    tg, Sg, _ = km(g.time.to_numpy(), g.event.to_numpy())
    print(name, "n =", len(g), "median =", SurvfuncRight(g.time, g.event).quantile(0.5))
    plt.step(np.r_[0, tg], np.r_[1, Sg], where="post", label=name)
chi2, pval = survdiff(T, E, df.plan)
print(f"log-rank: chi2 = {chi2:.1f}, p = {pval:.1e}")
plt.xlabel("days since sign-up"); plt.ylabel("S(t): still a customer")
plt.legend(); plt.show()

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

max |S - statsmodels|  = 4.4e-16
max |se - statsmodels| = 2.1e-17
S(90) = 0.831, 95% CI [0.805, 0.857]
S(365) = 0.506, 95% CI [0.468, 0.543]
S(730) = 0.221, 95% CI [0.170, 0.273]
median (days): 371.0
Basic n = 468 median = 276.0
Plus n = 332 median = 497.0
log-rank: chi2 = 25.7, p = 4.0e-07

মিল: নিজের S আর SE statsmodels-এর সঙ্গে ১০−১৫-এর ঘরে মেলে, অর্থাৎ SurvfuncRight-এর SE ঠিক Greenwood. সত্যের সঙ্গে: Ŝ(৩৬৫) = ০.৫০৬, তাই এক বছরে churn ≈ ০.৪৯৪, যা simulation-এর সত্য ০.৪৯১-এর পরিসরের মধ্যে। Median ৩৭১ দিন, সত্য ৩৭৬. প্রথম ব্লকের ২১২ বা ২৮৫ দিনের সঙ্গে তুলনা করুন: একই ডেটা, শুধু censoring সৎভাবে সামলানো। পরিসর দূরের সময়ে চওড়া (৭৩০ দিনে ±০.০৫১), কারণ সেখানে ঝুঁকিতে মানুষ কম; দুই বছর দেখা হয়েছে কেবল ২০২৪-এর শুরুর দিকের ক্রেতাদের। দলে: ৪৬৮ জন Basic ক্রেতার median ২৭৬ দিন, ৩৩২ জন Plus-এর ৪৯৭ দিন। ছবিতে Plus-এর বক্ররেখা পুরো সময় উপরে, আর log-rank-এর χ² = ২৫.৭ (p ≈ ৪ × ১০−৭) বলে এই ফাঁক এলোমেলো নয়। তবে এটি plan-এর “প্রভাব” প্রমাণ নয়: Plus যাঁরা নেন তাঁরা হয়তো আগে থেকেই আলাদা।

৫ · দুটি দল তুলনা — log-rank test

দুটি বক্ররেখা আলাদা দেখালেই পার্থক্য সত্য নয়; এলোমেলো ওঠানামাও হতে পারে। Log-rank test প্রশ্নটি এভাবে করে: null hypothesis “দুই দলের hazard সব সময়ে সমান” সত্য হলে, প্রতিটি ঘটনার দিনে dj টি ঘটনা দুই দলের মধ্যে তাদের ঝুঁকিতে থাকা মানুষের অনুপাতে ভাগ হওয়ার কথা। দল ১-এর প্রত্যাশিত ঘটনা E1j = dj · n1j/nj. সব দিনে দেখা O1 আর প্রত্যাশিত E1 যোগ করে, (O1 − E1)²/V-কে chi-square বণ্টনের (১ degree of freedom) সঙ্গে তুলনা করা হয়, যেখানে V হলো hypergeometric variance-এর যোগফল।

ছোট উদাহরণ: এক দিনে ১০০ জন ঝুঁকিতে, Basic ৬০ আর Plus ৪০, আর সেদিন ৫ জন চলে গেলেন। Null-এ Basic-এর প্রত্যাশা ৫ × ৬০/১০০ = ৩. বাস্তবে ৪ জন Basic হলে O − E = +১. এমন শত শত দিনের যোগফল বড় হলে পার্থক্য এলোমেলো নয়। বাজারঘরে χ² = ২৫.৭, p-value ৪ × ১০−৭: Basic আর Plus-এর churn-এর ছাঁদ সত্যিই আলাদা। Nathan Mantel ১৯৬৬ সালে এই ধরনের test প্রস্তাব করেন, আর Richard Peto ও Julian Peto ১৯৭২ সালে “log-rank” নাম দেন।

সীমা: log-rank সবচেয়ে শক্তিশালী যখন একটি দলের hazard সব সময়ে অন্যটির নির্দিষ্ট গুণিতক। দুটি বক্ররেখা কাটাকাটি করলে (শুরুতে এক দল বেশি চলে যায়, পরে অন্য দল) ধনাত্মক আর ঋণাত্মক O − E কাটাকাটি হয়ে test পার্থক্য না-ও দেখাতে পারে। কুপনের ক্রেতাদের ক্ষেত্রে ঠিক এটি ঘটবে (অংশ ৭)। আর এটি শুধু “আলাদা কি না” বলে, “কতটা” নয়; “কতটা” বলতে লাগে Cox model.

৬ · Cox proportional hazards — hazard ratio আর concordance

অনেকগুলো বৈশিষ্ট্য একসঙ্গে দেখতে চাইলে দল ভাগ করে চলে না। David Cox ১৯৭২ সালে Journal of the Royal Statistical Society, Series B-তে যে model প্রস্তাব করেন, তা আজ survival analysis-এর সবচেয়ে ব্যবহৃত regression: Cox proportional hazards model

h(t | x) = h0(t) · exp(x1β1 + … + xpβp)

h0(t) হলো baseline hazard, সব x = ০ হলে hazard; তার আকার সম্পূর্ণ অজানা রাখা হয়। বৈশিষ্ট্যগুলো শুধু তাকে একটি স্থির গুণক দিয়ে ওঠায়-নামায়। তাই দুজন ক্রেতার hazard-এর অনুপাত সময়ের ওপর নির্ভর করে না: এটিই “proportional hazards”. eβ হলো hazard ratio (HR): x এক একক বাড়লে hazard যত গুণ হয়। একটি অংশ অজানা আর অন্যটি parametric বলে Cox model-কে বলা হয় semi-parametric.

h0 না জেনে β শেখা যায় কীভাবে? Cox-এর কৌশল partial likelihood. প্রতিটি ঘটনার দিনে প্রশ্ন করুন: “ঝুঁকিতে থাকা সবার মধ্যে ঠিক এই ক্রেতাই চলে গেলেন, এর সম্ভাবনা কত?” উত্তর তাঁর hazard ভাগ সবার hazard-এর যোগফল, আর h0(t) উপরে-নিচে কেটে যায়:

L(β) = ∏ঘটনা i exiβ / ∑j ∈ R(ti) exjβ

R(ti) হলো সেই দিনের risk set. লক্ষ করুন, ঘটনার সময়গুলোর শুধু ক্রম কাজে লাগে, মান নয়। Censored ক্রেতারা কোনো গুণক যোগ করেন না, কিন্তু যতদিন ছিলেন ততদিন হরে থাকেন। তারপর পর্ব ১৮-এর মতো log L-কে Newton পদ্ধতিতে (পর্ব ১৫) সর্বোচ্চ করা হয়। একই দিনে একাধিক ঘটনা (tie) হলে আনুমানিক রূপ লাগে: Breslow-এর সরল রূপ, বা Efron-এর একটু নিখুঁত রূপ। এখন HR পড়তে শিখুন।

Cox model: “অভিযোগ আছে” (1/0)HR = 1.8, 95% CI [1.3, 2.5]

            
  1. সংজ্ঞা। HR ১.৮ মানে β = ০.৫৮৭৮. প্রতিটি দিনে, যাঁরা তখনো আছেন তাঁদের মধ্যে, অভিযোগ-ওয়ালা ক্রেতার চলে যাওয়ার তাৎক্ষণিক হার ১.৮ গুণ, অন্য বৈশিষ্ট্য সমান রেখে।
  2. সম্ভাবনা নয়। Survival-এ HR ঘাত হিসেবে বসে: S1 = S0১.৮. বারো মাসে S0 = ০.৬০ হলে S1 = ০.৩৯৯; churn ০.৪০ থেকে ০.৬০, অর্থাৎ ১.৫ গুণ।
  3. বিরল ঘটনায়। এক মাসে churn ০.০৫ থেকে ০.০৮৮, অনুপাত ১.৭৬: ঘটনা বিরল হলে HR প্রায় ঝুঁকির অনুপাতের সমান, ঘটনা সাধারণ হলে নয়।
  4. সময়ের ভাষায়। HR সরাসরি “কত দিন” বলে না। Weibull-এর আকার k = ১.৩ জানা থাকলে median-এর অনুপাত ১.৮−১/১.৩ = ০.৬৩৬: median প্রায় ৩৬% ছোট।
  5. পরিসর আর একক। CI-তে ১ নেই, তাই ৫% স্তরে পার্থক্য তাৎপর্যপূর্ণ, কিন্তু আসল মান ১.৩ থেকে ২.৫ যেকোনো কিছু হতে পারে। x যদি অভিযোগের সংখ্যা হয়, দুটি অভিযোগে HR ১.৮² = ৩.২৪.

Cox model-এর মূল্যায়নে accuracy বা RMSE চলে না, কারণ censored সারির সঠিক উত্তর অজানা। প্রধান মাপ concordance index (Harrell's C), যা Frank Harrell ও সহকর্মীরা ১৯৮২ সালে JAMA-তে ব্যবহার করেন। ধারণাটি ক্রমের: দুজন ক্রেতার জোড়া নিন, যাঁদের মধ্যে কে আগে চলে গেছেন তা নিশ্চিত জানা যায় (তুলনাযোগ্য জোড়া: প্রথমজনের ঘটনা দেখা গেছে, আর দ্বিতীয়জন তখনো ছিলেন)। Model যদি আগে চলে যাওয়া জনকে বেশি ঝুঁকি দেয়, জোড়াটি concordant. C = concordant জোড়া / তুলনাযোগ্য জোড়া; ঝুঁকি সমান হলে আধা নম্বর। C = ০.৫ মানে মুদ্রা ছোড়া, ১ মানে নিখুঁত ক্রম। এটি AI-এর ভিত্তি কোর্সের পর্ব ৬০-এর ROC AUC-এর censoring-সহ আত্মীয়।

A: 2, ঘটনা, 0.9 · B: 4+, 0.3C: 5, ঘটনা, 0.6 · D: 8+, 0.7

            
  1. নিয়ম। চারজনের ছয়টি জোড়া। একটি জোড়া তখনই গোনা হয়, যখন একজনের ঘটনা দেখা গেছে আর অন্যজন তার পরেও ছিলেন।
  2. A-এর জোড়া। A সবার আগে (২ মাসে) চলে গেছেন, আর তাঁর ঝুঁকি সবচেয়ে বেশি, ০.৯. তিনটি জোড়াই তুলনাযোগ্য আর concordant.
  3. B-এর জোড়া। B censored ৪ মাসে; C চলে গেছেন ৫ মাসে। B হয়তো ৪.৫ মাসে চলে যেতেন, হয়তো ১০-এ: ক্রম অজানা, তাই বাদ। B–D-তে দুজনই censored, বাদ।
  4. C–D. C ৫ মাসে চলে গেছেন, D ৮ মাসেও ছিলেন। কিন্তু model D-কে বেশি ঝুঁকি (০.৭) দিয়েছে: discordant.
  5. ফল। চারটি তুলনাযোগ্য জোড়ার তিনটি ঠিক: C = ০.৭৫. ঝুঁকি সমান হলে আধা নম্বর, তখন ৩.৫/৪ = ০.৮৭৫.

তৃতীয় ব্লক statsmodels-এর PHReg দিয়ে তিনটি বৈশিষ্ট্যে Cox model চালায় (default ties="breslow", এখানে স্পষ্ট লেখা), HR আর ৯৫% পরিসর ছাপায়, Harrell's C নিজে গোনে, আর শেষে একটি প্রশ্ন করে: কুপনের ক্রেতাদের HR কি সময়ের সঙ্গে স্থির? তার জন্য দুটি আলাদা fit: প্রথম ১৮০ দিন (১৮০-তে সবাইকে censor করে), আর ১৮০ দিনের পর যাঁরা ছিলেন তাঁদের নিয়ে। দ্বিতীয়টিতে entry বলে দেয় ক্রেতা ১৮০ দিন থেকে ঝুঁকির হিসাবে ঢুকেছেন (left truncation).

import numpy as np
import pandas as pd
from statsmodels.duration.hazard_regression import PHReg
from bazar_surv import customers

df = customers()
cols = ["plan", "complaints", "promo"]
X, T, E = df[cols].to_numpy(), df.time.to_numpy(), df.event.to_numpy()
res = PHReg(T, X, status=E, ties="breslow").fit()
ci = np.exp(res.conf_int())
print(pd.DataFrame({"HR": np.exp(res.params), "lo95": ci[:, 0], "hi95": ci[:, 1],
                    "p": res.pvalues}, index=cols).round(3))
print("truth: plan 0.600, complaints 1.350")

def c_index(time, event, risk):      # Harrell's C, নিজে লেখা
    num = den = 0.0
    for i in np.flatnonzero(event):  # i চলে গেছে; j তখনও ছিল
        later = time > time[i]
        den += later.sum()
        num += (risk[i] > risk[later]).sum() + 0.5 * (risk[i] == risk[later]).sum()
    return num / den

print(f"C-index, Cox xb:          {c_index(T, E, X @ res.params):.3f}")
print(f"C-index, complaints only: {c_index(T, E, X[:, 1]):.3f}")
print(f"C-index, random scores:   {c_index(T, E, np.random.default_rng(1).random(len(T))):.3f}")

# promo-র HR কি সময়ের সঙ্গে স্থির? 180 দিনের আগে আর পরে আলাদা fit
early = PHReg(np.minimum(T, 180), X, status=E * (T <= 180), ties="breslow").fit()
m = T > 180                          # 180 দিন পর্যন্ত যারা টিকে ছিল
late = PHReg(T[m], X[m], status=E[m], entry=np.full(m.sum(), 180.0),
             ties="breslow").fit()
print("promo HR, day 0-180:", round(np.exp(early.params[2]), 2),
      "| after day 180:", round(np.exp(late.params[2]), 2))

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

              HR   lo95   hi95      p
plan        0.60  0.493  0.730  0.000
complaints  1.37  1.242  1.511  0.000
promo       1.18  0.977  1.425  0.086
truth: plan 0.600, complaints 1.350
C-index, Cox xb:          0.619
C-index, complaints only: 0.575
C-index, random scores:   0.522
promo HR, day 0-180: 1.97 | after day 180: 0.63

HR: Plus সদস্যের hazard Basic-এর ০.৬০ গুণ (৯৫% পরিসর ০.৪৯–০.৭৩), প্রতিটি অভিযোগে ১.৩৭ গুণ (১.২৪–১.৫১); simulation-এর সত্য ০.৬০ আর ১.৩৫, দুটিই পরিসরের ভেতরে। p-এর ০.০০০ মানে ০.০০০৫-এর কম। কুপন: HR ১.১৮, p = ০.০৮৬, পরিসরে ১ আছে। সরল ব্যাখ্যা “কুপনে তেমন কিছু যায় আসে না”। কিন্তু শেষ লাইন বলে উল্টো গল্প: প্রথম ১৮০ দিনে কুপনের ক্রেতাদের hazard প্রায় দ্বিগুণ (১.৯৭), আর তার পরে ০.৬৩ গুণ। দুটি বিপরীত প্রভাব গড় হয়ে ১.১৮ হয়েছে; একটি স্থির HR এখানে অর্থহীন। C-index: Cox-এর ০.৬১৯, শুধু অভিযোগের সংখ্যায় ০.৫৭৫, এলোমেলো স্কোরে ০.৫২২ (০.৫-এর কাছে; ৫০০টির কাছাকাছি ঘটনায় এমন ওঠানামা স্বাভাবিক)। ০.৬২ সাধারণ শোনায়, কিন্তু মাত্র তিনটি বৈশিষ্ট্যে আর এই অনেক এলোমেলো Weibull সময়ে এর চেয়ে অনেক বেশি আশা করা যায় না; simulation-এর আসল ওজন (Plus ০.৬, প্রতি অভিযোগে ১.৩৫) দিয়ে স্কোর বানালেও C মাত্র ০.৬০৭ (ডেস্কটপে গোনা)। C-এর ছাদ ঠিক করে ডেটার এলোমেলোতা, model নয়।

৭ · অনুমান যাচাই আর parametric model — log(−log S), Weibull আর stratification

Proportional hazards অনুমান ভাঙলে HR একটি সময়-গড় মাত্র, আর তার ব্যাখ্যা ভুল দিকে যায়। ধরার তিনটি পথ। প্রথম, log(−log S) plot: PH সত্য হলে S1 = S0HR, তাই log(−log S1(t)) = log(−log S0(t)) + log HR: দুই দলের Kaplan–Meier থেকে আঁকা বক্ররেখা সমান্তরাল হওয়ার কথা। Weibull-এ log(−log S) = k·log t − k·log λ, অর্থাৎ log t-এর বিপরীতে সরলরেখা, ঢাল k. ঢাল আলাদা মানে hazard-এর আকার আলাদা, PH ভাঙা। দ্বিতীয়, Schoenfeld residual: প্রতিটি ঘটনায় “যিনি চলে গেলেন তাঁর x” বিয়োগ “risk set-এর ঝুঁকি-ওজনের গড় x”. PH সত্য হলে এই Schoenfeld residual সময়ের সঙ্গে কোনো ধারা দেখায় না; ধারা থাকলে β সময়ের সঙ্গে বদলাচ্ছে। Patricia Grambsch আর Terry Therneau ১৯৯৪ সালে এর ওপর একটি test দেন। তৃতীয়, সময় ভাগ করে fit: তৃতীয় ব্লকের শেষ লাইনের মতো।

প্রতিকার কয়েকটি। (১) Stratification: কুপন আর সাধারণ ক্রেতার জন্য আলাদা baseline hazard h0,s(t), কিন্তু অন্য বৈশিষ্ট্যের β একই। দাম: কুপনের নিজের কোনো HR পাওয়া যায় না, শুধু অন্যদের HR “কুপন সামলে”। (২) সময়ভেদে প্রভাব: x × g(t) পদ যোগ, বা সময় ভাগ করে আলাদা HR (যেমন ০–১৮০ আর ১৮০+). (৩) Parametric model, যেখানে hazard-এর আকার নিজেই শেখা হয়।

শেষটি দেখা যাক। Weibull-এর likelihood-এ censoring বসে স্বাভাবিকভাবে: ঘটনা দেখা গেলে density f(t) = h(t)S(t), censored হলে শুধু S(t) (“অন্তত t পর্যন্ত টিকেছেন”)। log নিলে

log L(k, λ) = ∑i [ δi log h(ti) − H(ti) ],  H(t) = (t/λ)k

এটি পর্ব ১৮-এর maximum likelihood, শুধু censored সারিতে density-র বদলে survival. পর্ব ২০-এর Poisson-এর সঙ্গে মিলটাও লক্ষ করুন: exponential-এ (k = ১) MLE λ̂ = ∑ti / ∑δi, “মোট দেখার সময় ভাগ ঘটনার সংখ্যা”, ঠিক exposure-সহ হারের মতো। চতুর্থ ব্লক scipy-এর minimize দিয়ে দুই দলে Weibull শেখে (k আর λ লগ-স্কেলে, যাতে ধনাত্মক থাকে), median মেলায় Kaplan–Meier-এর সঙ্গে, censored সারি ফেলে দিলে কী হয় দেখায়, log(−log S) আঁকে, আর শেষে কুপন দিয়ে stratified Cox চালায়।

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
from statsmodels.duration.survfunc import SurvfuncRight
from statsmodels.duration.hazard_regression import PHReg
from bazar_surv import customers

def weibull_nll(p, t, d):            # censoring-সহ: d log h(t) - H(t)
    k, lam = np.exp(p)               # লগ-স্কেলে, যাতে দুটিই ধনাত্মক
    z = t / lam
    return -np.sum(d * (np.log(k / lam) + (k - 1) * np.log(z)) - z**k)

df = customers()
print("group    k_hat  lam_hat  median_W  median_KM  median_dropC")
for g, name in [(0, "organic"), (1, "promo")]:
    s = df[df.promo == g]
    t, d = s.time.to_numpy(), s.event.to_numpy()
    k, lam = np.exp(minimize(weibull_nll, [0.0, 6.0], args=(t, d)).x)
    kd, ld = np.exp(minimize(weibull_nll, [0.0, 6.0],
                             args=(t[d == 1], d[d == 1])).x)
    sf = SurvfuncRight(t, d)
    print(f"{name:8} {k:5.2f} {lam:8.0f} {lam * np.log(2) ** (1 / k):9.0f}"
          f" {sf.quantile(0.5):10.0f} {ld * np.log(2) ** (1 / kd):11.0f}")
    keep = (sf.surv_prob > 0) & (sf.surv_prob < 1)
    plt.plot(np.log(sf.surv_times[keep]), np.log(-np.log(sf.surv_prob[keep])),
             drawstyle="steps-post", label=name)
plt.xlabel("log(days)"); plt.ylabel("log(-log S(t))"); plt.legend(); plt.show()

X = df[["plan", "complaints"]]
st = PHReg(df.time, X, status=df.event, strata=df.promo, ties="breslow").fit()
print("stratified by promo, HR:", np.exp(st.params).round(3))

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

group    k_hat  lam_hat  median_W  median_KM  median_dropC
organic   1.27      505       379        392         209
promo     0.65      513       292        282          94
stratified by promo, HR: [0.6   1.369]

আকার: সাধারণ ক্রেতাদের শেখা k = ১.২৭ (সত্য ১.৩), কুপনের ০.৬৫ (সত্য ০.৬): একটির hazard সময়ের সঙ্গে বাড়ে, অন্যটির কমে। দুটি দলের Weibull-এ k আলাদা মানে proportional hazards অসম্ভব। ছবিতে log(−log S)-এর দুটি রেখার ঢাল আলাদা (প্রায় ১.৩ আর ০.৬৫), আর তারা প্রায় log(দিন) ≈ ৬.৩-এ, অর্থাৎ ৫৫০ দিনের কাছে, কাটাকাটি করে: সমান্তরাল নয়। Median: Weibull ৩৭৯ আর ২৯২ দিন, Kaplan–Meier ৩৯২ আর ২৮২: কাছাকাছি। পার্থক্যের একটি কারণ, প্রতিটি দলে Plus আর অভিযোগের মিশ্রণ আছে, তাই দলটি হুবহু একটি Weibull নয়। Censored ফেলে দিলে median ২০৯ আর ৯৪ দিন: প্রায় অর্ধেক আর এক-তৃতীয়াংশ। Likelihood-এ S(t) পদটিই এই তথ্য ধরে রাখে। Stratified Cox: Plus ০.৬০০, অভিযোগ ১.৩৬৯, আগের fit-এর প্রায় সমান, কারণ এখানে কুপন অন্য বৈশিষ্ট্যের সঙ্গে সম্পর্কহীন। লাভটা অন্যখানে: কুপনের জন্য আর কোনো বিভ্রান্তিকর একক HR রিপোর্ট হয় না।

Parametric না Cox? Cox কম অনুমান করে, তাই β-এর জন্য নিরাপদ, আর ঘটনার ক্রম ছাড়া কিছু চায় না। Parametric model ডেটার শেষ সীমার বাইরে extrapolate করতে পারে (যেমন পাঁচ বছরের জীবনকাল, যা customer lifetime value-এর হিসাবে লাগে), আর ছোট ডেটায় বেশি স্থির; কিন্তু ভুল আকার বেছে নিলে extrapolation আত্মবিশ্বাসী ভুল দেয়। বাস্তব পথ: Kaplan–Meier-এর সঙ্গে parametric বক্ররেখা মিলিয়ে দেখা, কয়েকটি বণ্টন (exponential, Weibull, log-normal, log-logistic) তুলনা করা, আর ডেটার বাইরের অংশকে অনুমান বলে স্পষ্ট লেখা।

৮ · ML survival model — random survival forest, lifelines আর scikit-survival

Cox model-এ x-এর প্রভাব log-hazard-এ রৈখিক আর সময়ে স্থির। Interaction বা বাঁকা সম্পর্ক থাকলে ML পদ্ধতি সাহায্য করতে পারে। Random survival forest (Hemant Ishwaran ও সহকর্মীরা, Annals of Applied Statistics, ২০০৮) পর্ব ৩৩-এর random forest-এর মতোই, শুধু গাছ ভাগ হয় log-rank পরিসংখ্যান দিয়ে আর প্রতিটি পাতায় থাকে একটি cumulative hazard-এর অনুমান। Gradient-boosted Cox পর্ব ৩৫-এর boosting-কে partial likelihood-এর loss দিয়ে চালায়; XGBoost-এ এর জন্য survival:cox objective আছে (পর্ব ৩৬)। Neural network-ভিত্তিক model-ও আছে (যেমন DeepSurv); সেগুলো deep learning-এর কোর্সে আসবে।

এগুলো পাতার Python-এ নেই, তাই ডেস্কটপে একটি আলাদা প্রকল্পে চালানো হলো। uv দিয়ে নতুন প্রকল্প (AI-এর ভিত্তি কোর্সের পর্ব ৭ দেখুন), একই bazar_surv.py. Python দুটি library-তে একই ডেটা: lifelines-এ Cox আর Grambsch–Therneau ধাঁচের proportional hazards test, তারপর scikit-survival-এ ৭০/৩০ ভাগে Cox বনাম random survival forest-এর test C-index.

~/bazarghar-survival$ uv init --bare --vcs none --author-from none
~/bazarghar-survival$ uv add numpy scipy pandas statsmodels matplotlib scikit-learn lifelines scikit-survival
 …
 + lifelines==0.30.3
 + pandas==2.3.3
 + scikit-learn==1.9.1
 + scikit-survival==0.28.0
 + scipy==1.18.1
 + statsmodels==0.15.0
 …
from lifelines import CoxPHFitter
from lifelines.statistics import proportional_hazard_test
from sksurv.util import Surv
from sksurv.linear_model import CoxPHSurvivalAnalysis
from sksurv.ensemble import RandomSurvivalForest
from sklearn.model_selection import train_test_split
from bazar_surv import customers

df = customers()[["plan", "complaints", "promo", "time", "event"]]
cph = CoxPHFitter().fit(df, "time", "event")
print(cph.summary[["exp(coef)", "p"]].round(3))
pht = proportional_hazard_test(cph, df, time_transform="rank")
print(pht.summary[["test_statistic", "p"]].round(4))

tr, te = train_test_split(df, test_size=0.3, random_state=55)
y_tr = Surv.from_dataframe("event", "time", tr)
y_te = Surv.from_dataframe("event", "time", te)
X_tr, X_te = tr.iloc[:, :3], te.iloc[:, :3]
cox = CoxPHSurvivalAnalysis().fit(X_tr, y_tr)
rsf = RandomSurvivalForest(n_estimators=300, min_samples_leaf=15,
                           random_state=55).fit(X_tr, y_tr)
print("test C-index  Cox:", round(cox.score(X_te, y_te), 3),
      " RSF:", round(rsf.score(X_te, y_te), 3))

সত্যিকারের run (ডেস্কটপ, Python 3.14.4; লেখার সময়ে lifelines 0.30.3, scikit-survival 0.28.0, scikit-learn 1.9.1, pandas 2.3.3, NumPy 2.5.4):

~/bazarghar-survival$ uv run python desktop_survival.py
            exp(coef)      p
covariate
plan            0.600  0.000
complaints      1.371  0.000
promo           1.180  0.085
            test_statistic       p
complaints          3.2966  0.0694
plan                0.1039  0.7472
promo              47.1825  0.0000
test C-index  Cox: 0.634  RSF: 0.639

পড়া: lifelines-এর HR statsmodels-এর সঙ্গে মেলে (অভিযোগে ১.৩৭১ বনাম ১.৩৬৯-এর ছোট পার্থক্য tie সামলানোর ভিন্ন default থেকে: lifelines Efron, এখানে statsmodels Breslow). PH test কুপনে statistic ৪৭.২, p প্রায় শূন্য: অংশ ৭-এর ছবির সংখ্যাগত রূপ। Plus-এ কোনো লক্ষণ নেই (p = ০.৭৫); অভিযোগে p = ০.০৭, সীমার কাছে কিন্তু আসল model-এ এটি proportional, তাই এটি এলোমেলো ওঠানামা হিসেবেই পড়া উচিত। Forest বনাম Cox: test-এ ০.৬৩৯ বনাম ০.৬৩৪, একটি ভাগে একটি run. এই পার্থক্য সম্ভবত noise-এর মধ্যে; পর্ব ২২-এর মতো কয়েকটি ভাগ বা bootstrap ছাড়া “forest ভালো” বলা যায় না। ডেটা যেখানে প্রায় Cox-এর মতো, সেখানে forest-এর বাড়তি সুযোগ কম।

আরও গভীরে: Harrell's C censoring-এর ধরনের ওপর নির্ভর করে, আর দূরের সময়ের জোড়াকে প্রায় উপেক্ষা করে; Hajime Uno ও সহকর্মীরা ২০১১ সালে censoring-এর inverse-probability ওজন দিয়ে একটি বিকল্প দেন, যা scikit-survival-এ concordance_index_ipcw নামে আছে। Ranking ছাড়া probability-র মান মাপতে time-dependent Brier score (Graf ও সহকর্মীরা, ১৯৯৯) ব্যবহৃত হয়, যা পর্ব ২৬-এর calibration-এর survival রূপ। একাধিক ধরনের শেষ (যেমন “চলে গেছেন” বনাম “অ্যাকাউন্ট বন্ধ করেছেন”) থাকলে competing risks লাগে, যেখানে Kaplan–Meier-এর ১ − Ŝ ঝুঁকি বাড়িয়ে দেখায়; Fine আর Gray ১৯৯৯ সালে এর জন্য একটি regression দেন। Customer lifetime value-তে survival বক্ররেখার নিচের ক্ষেত্রফল (প্রত্যাশিত জীবনকাল) মাসিক মুনাফা দিয়ে গুণ করা হয়; পর্ব ৬৫ (Customer analytics — RFM, retention, churn আর lifetime value) আর পর্ব ৭২ (ক্যাপস্টোন — বাজারঘরের churn আর lifetime value প্রকল্প) এটি কাজে লাগাবে।

পরীক্ষা ও ইন্টারভিউতে: (১) Right censoring কী, আর censored সারি বাদ দিলে কী হয়? আসল সময় দেখা সময়ের চেয়ে বড়; বাদ দিলে জীবনকাল কম দেখায়। (২) Kaplan–Meier সূত্র? Ŝ(t) = ∏(১ − dj/nj), nj = তখনো ঝুঁকিতে। (৩) HR = ১.৮ মানে? প্রতিটি সময়ে তাৎক্ষণিক hazard ১.৮ গুণ, অন্য x সমান রেখে; সম্ভাবনা ১.৮ গুণ নয়। (৪) Cox model baseline hazard ছাড়া কীভাবে fit হয়? Partial likelihood-এ h0(t) কেটে যায়। (৫) C-index ০.৫ আর ১ মানে? এলোমেলো ক্রম আর নিখুঁত ক্রম; শুধু তুলনাযোগ্য জোড়া গোনা হয়।

৯ · AI-এর যুগে — churn আর survival কোড যাচাই

“বাজারঘরের churn predict করো” বললে AI assistant প্রায়ই সোজা একটি classifier দেয়: label = “চলে গেছে কি না”, accuracy ৮০%, শেষ। এই পর্বের প্রথম ব্লক দেখিয়েছে, সেই label-এ কী মিশে থাকে। নিচের ভুলগুলো প্রায়ই চোখে পড়ে।

AI প্রায়ই যা করেকেন সমস্যাকীভাবে ধরবেন
Censored সারি বাদ দেয় বা “churned = 0” ধরে classifier বানায়label দেখার সময়ের ওপর নির্ভর করে; জীবনকাল কম দেখায়যোগদানের cohort ধরে label-এর হার দেখুন; survival model বা fixed horizon
Event-এর সংকেত উল্টো দেয় (1 = censored)পুরো বক্ররেখা উল্টো অর্থ দেয়library-র docs-এ status/event-এর অর্থ পড়ুন; ৫টি সারি হাতে মেলান
HR-কে “সম্ভাবনা ১.৮ গুণ” বা কারণ হিসেবে লেখেHR হার, সম্ভাবনা নয়; পর্যবেক্ষণের ডেটায় কারণ প্রমাণ হয় নাS0HR দিয়ে সম্ভাবনায় রূপান্তর; কারণের দাবি আলাদা নকশা চায়
Proportional hazards যাচাই করে নাকাটাকাটি করা hazard-এ HR একটি বিভ্রান্তিকর গড়log(−log S) plot, Schoenfeld test, সময় ভাগ করে fit
ভবিষ্যতের তথ্য feature-এ ঢোকায় (যেমন শেষ ৩০ দিনের অর্ডার)ঘটনার পরের তথ্য দিয়ে ঘটনা “অনুমান”প্রতিটি feature যোগদানের সময়ে বা prediction-এর দিনে জানা ছিল কি না দেখুন
পুরোনো বা বানানো API লেখেযেমন lifelines আর scikit-survival-এর নাম মিশিয়ে ফেলানিজের version-এর docs; ছোট ডেটায় চালিয়ে statsmodels-এর সঙ্গে মেলান

AI-কে কোডের সঙ্গে অনুমান আর যাচাইও চাইতে বলুন:

আমার কাছে বাজারঘরের ক্রেতাদের একটি টেবিল আছে: যোগদানের তারিখ, plan, প্রথম মাসের অভিযোগ, কুপন, শেষ অর্ডারের তারিখ। ডেটা কাটার দিন ২০২৬-০৩-৩১; ৯০ দিন অর্ডার না থাকলে churn. ১. প্রতিটি ক্রেতার time আর event কলাম কীভাবে বানাবে, তার কোড দাও; censored কারা, স্পষ্ট লেখো। ২. statsmodels দিয়ে Kaplan–Meier আর log-rank, তারপর PHReg দিয়ে Cox model চালাও; প্রতিটি parameter-এর নাম কোন version-এর docs থেকে, তা লেখো। ৩. প্রতিটি hazard ratio-কে সম্ভাবনার ভাষায় নয়, hazard-এর ভাষায় ব্যাখ্যা করো, আর ১২ মাসে survival-এর একটি উদাহরণ দাও। ৪. proportional hazards যাচাই করো (log(−log S) plot আর সময় ভাগ করে fit); ভাঙলে কী করবে, লেখো। ৫. যোগদানের সময় ধরে ভাগ করা আলাদা test সেটে C-index দাও, একটি সরল baseline-সহ। কোনো warning চাপা দিয়ো না।

AI-এর দেওয়া survival কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১জীবন-সারণি থেকে Kaplan–Meier আর Cox: চিকিৎসার পরিসংখ্যানের পদ্ধতি কীভাবে ব্যবসার churn-এ এল?

“কত দিন বাঁচে” প্রশ্নের পরিসংখ্যান বহু পুরোনো। সতেরো শতকে John Graunt লন্ডনের মৃত্যু-তালিকা থেকে একটি প্রাথমিক জীবন-সারণি বানান, আর পরে বিমা কোম্পানিগুলো প্রিমিয়াম ঠিক করতে actuarial life table ব্যবহার করে। এসব সারণি সময়কে ভাগে (যেমন বছরে) ভাগ করে প্রতিটি ভাগে মৃত্যুর হার গুনত। সমস্যা ছিল ভাগের মাঝে হারিয়ে যাওয়া মানুষ, যাঁদের আধা ভাগ ধরে নেওয়ার মতো সমঝোতা করতে হতো।

বিশ শতকে চিকিৎসা-গবেষণায় প্রশ্নটি তীব্র হলো: ক্যানসারের রোগীরা বিভিন্ন সময়ে গবেষণায় ঢোকেন, আর বিশ্লেষণের দিনে অনেকে বেঁচে থাকেন। Major Greenwood ১৯২৬ সালে ক্যানসারের স্বাভাবিক সময়কাল নিয়ে একটি প্রতিবেদনে জীবন-সারণির অনিশ্চয়তার সূত্র দেন, যা আজ Greenwood formula. ১৯৫৮ সালে Edward Kaplan আর Paul Meier Journal of the American Statistical Association-এ ভাগ ছাড়া, প্রতিটি ঘটনার সময়ে গুণফলের পদ্ধতি দেন। প্রচলিত বর্ণনা অনুযায়ী, দুজন প্রায় একই বিষয়ে আলাদা প্রবন্ধ জমা দিয়েছিলেন আর সম্পাদকের পরামর্শে একসঙ্গে লেখেন; গল্পটি বহুবার বলা হয়, তবে খুঁটিনাটিতে সূত্রভেদে পার্থক্য আছে। প্রবন্ধটি পরিসংখ্যানের সবচেয়ে বেশি উদ্ধৃত লেখাগুলোর একটি হয়ে ওঠে।

১৯৭২ সালে David Cox-এর প্রবন্ধ দুটি বড় সমস্যা একসঙ্গে সমাধান করে: অনেক বৈশিষ্ট্য একসঙ্গে, আর baseline hazard না জেনে। ১৯৭৫ সালে তিনি Biometrika-তে partial likelihood-এর তত্ত্ব আলাদাভাবে ব্যাখ্যা করেন। এরপর counting process-এর গণিত (১৯৭০ আর ১৯৮০-এর দশকে Odd Aalen, Per Kragh Andersen, Richard Gill ও সহকর্মীদের কাজ) পুরো ক্ষেত্রটিকে দৃঢ় ভিত্তি দেয়।

ব্যবসায় আসার পথ সরল: গঠন এক। রোগীর “মৃত্যু” হলো ক্রেতার “চলে যাওয়া”, গবেষণার শেষ দিন হলো ডেটা কাটার দিন, চিকিৎসা হলো plan বা কুপন। প্রকৌশলে একই পদ্ধতির নাম reliability analysis (যন্ত্র কখন বিকল হবে), অর্থনীতিতে duration analysis (বেকারত্ব কত দিন), আর সমাজবিজ্ঞানে event history analysis. ভাষা আলাদা হলেও censoring-এর সমস্যা আর তার সমাধান এক।

মূল উপলব্ধি: Survival analysis চিকিৎসার পরিসংখ্যানে জন্মেছে অসম্পূর্ণ পর্যবেক্ষণকে সৎভাবে ব্যবহারের প্রয়োজনে; churn, যন্ত্রের বিকল হওয়া আর চাকরির সময়কাল একই গঠনের সমস্যা বলে পদ্ধতিটি হুবহু খাটে।

প্র ০২Hazard ratio কেন এত জনপ্রিয়, আর “the hazards of hazard ratios” সমালোচনাটি কী বলে?

HR জনপ্রিয়তার কারণ বোঝা সহজ। Cox model যেকোনো baseline-এ চলে, ফল একটি সংখ্যায় আর একটি পরিসরে আসে, আর বহু বৈশিষ্ট্য একসঙ্গে সামলানো যায়। চিকিৎসা-গবেষণার রিপোর্টে প্রায় সব survival ফল HR আকারে লেখা হয়।

কিন্তু Miguel Hernán ২০১০ সালে Epidemiology-তে “The hazards of hazard ratios” নামে একটি প্রবন্ধে দুটি সমস্যা স্পষ্ট করেন। প্রথম, HR সময়ের সঙ্গে বদলাতে পারে, আর একটি সংখ্যা সেই গল্প লুকায়। এই পর্বের কুপনের উদাহরণ ঠিক এটি: প্রথম ১৮০ দিনে ১.৯৭, পরে ০.৬৩, আর একক HR ১.১৮, যা কোনো সময়ের সত্যই নয়। দ্বিতীয়, এবং আরও সূক্ষ্ম, একটি “বেছে নেওয়ার পক্ষপাত” তৈরি হয়। ১৮০ দিন পর যে কুপন-ক্রেতারা টিকে আছেন, তাঁরা কুপন-ক্রেতাদের মধ্যে বাছাই করা অনুগতরা। সাধারণ ক্রেতাদের মধ্যে টিকে থাকা দল ততটা বাছাই হয়নি। তাই পরের সময়ের HR দুটি ভিন্নভাবে ছাঁকা দলের তুলনা; এমনকি এলোমেলো পরীক্ষাতেও (A/B test) সময়ভেদে HR-কে “প্রভাব কমে গেছে” হিসেবে পড়া বিপজ্জনক। লুকানো ভিন্নতা (frailty) থাকলে, আসল ব্যক্তিগত প্রভাব স্থির হলেও দলগত HR সময়ের সঙ্গে ১-এর দিকে সরে যায়।

বিকল্প কী? Survival বক্ররেখা দুটি সরাসরি দেখানো, নির্দিষ্ট সময়ে (যেমন ১২ মাসে) ঝুঁকির পার্থক্য, আর restricted mean survival time (RMST): একটি নির্দিষ্ট সীমা পর্যন্ত বক্ররেখার নিচের ক্ষেত্রফল, অর্থাৎ “প্রথম দুই বছরে গড়ে কত দিন থাকেন”। RMST দিনে মাপা, ব্যবসার ভাষায় সরাসরি অনুবাদযোগ্য, আর proportional hazards অনুমান চায় না। Patrick Royston ও Mahesh Parmar-সহ অনেকে ২০১০-এর দশকে এটিকে HR-এর পরিপূরক হিসেবে প্রস্তাব করেছেন।

বাজারঘরের ব্যবস্থাপকের জন্য তাই “কুপনের HR ১.১৮” রিপোর্ট নয়, বরং “কুপনের ক্রেতাদের ২৮% প্রথম ছয় মাসে চলে যান, কিন্তু যাঁরা থাকেন তাঁরা পরে সাধারণ ক্রেতার চেয়ে কম চলে যান” ধরনের বাক্য, সঙ্গে দুটি বক্ররেখা। (শতাংশটি এখানে কাল্পনিক উদাহরণ।)

মূল উপলব্ধি: HR একটি সুবিধাজনক সারাংশ, কিন্তু সময়ভেদে বদলালে আর টিকে থাকার বাছাইয়ে তা বিভ্রান্ত করে; বক্ররেখা, নির্দিষ্ট সময়ের ঝুঁকি আর RMST দিয়ে পরিপূরক করুন।

প্র ০৩Non-informative censoring অনুমান বাস্তবে কোথায় ভাঙে, আর তখন Kaplan–Meier কী ভুল বলে?

Kaplan–Meier-এর যুক্তি ছিল: censored ক্রেতা চলে যাওয়ার পরে ঝুঁকির হিসাব থেকে সরলেও বাকিরা তাঁর প্রতিনিধি। অর্থাৎ যাঁরা censored হলেন আর যাঁরা থেকে গেলেন, তাঁদের ভবিষ্যৎ ঝুঁকি একই রকম। ডেটা কাটার দিন থেকে আসা administrative censoring-এ এটি প্রায় সবসময় ঠিক, কারণ কাটার দিন ক্রেতার আচরণ জানে না। একটি সূক্ষ্মতা আছে: পুরোনো আর নতুন cohort-এর আচরণ যদি সময়ের সঙ্গে বদলায় (ধরুন ২০২৫-এ ডেলিভারি ভালো হয়েছে), তাহলে দূরের সময়ের অনুমান শুধু পুরোনো cohort থেকে আসে, আর তা নতুনদের জন্য খাটে না।

অনুমান ভাঙার উদাহরণ সহজেই ভাবা যায়। ধরুন বাজারঘর প্রতারণার সন্দেহে কিছু অ্যাকাউন্ট বন্ধ করে, আর বিশ্লেষক সেগুলো “censored” লেখেন। এই ক্রেতারা হয়তো এমনিতেই শিগগির চলে যেতেন, কিংবা উল্টোটা। আবার ধরুন একটি ক্রেতা-জরিপে শুধু সন্তুষ্ট ক্রেতারা উত্তর দেন, আর অসন্তুষ্টরা “হারিয়ে যান”। চিকিৎসায় এর ধ্রুপদি রূপ: গুরুতর অসুস্থ রোগীরা গবেষণা ছেড়ে যান। এসব ক্ষেত্রে censored-দের ঝুঁকি বাকিদের চেয়ে আলাদা, তাই Kaplan–Meier ভুল দিকে সরে: ঝুঁকিপূর্ণরা আগে censored হলে survival বেশি দেখায়।

ডেটা থেকে এই অনুমান যাচাই করা প্রায় অসম্ভব, কারণ censored-দের ভবিষ্যৎ দেখা যায় না। তাই কাজটি নকশার আর যুক্তির। প্রথম, censoring-এর প্রতিটি কারণ আলাদা করে লেখা। দ্বিতীয়, যে “শেষ” আসলে আরেকটি ঘটনা (অ্যাকাউন্ট বন্ধ), তাকে competing risk হিসেবে মডেল করা। তৃতীয়, censoring-এর সম্ভাবনা যদি জানা বৈশিষ্ট্যের ওপর নির্ভর করে, তাহলে inverse probability of censoring weighting (IPCW) দিয়ে ওজন দেওয়া। চতুর্থ, sensitivity analysis: “censored-রা সবাই সঙ্গে সঙ্গে চলে গেলে” আর “কেউই না গেলে” দুই চরমে ফল কতটা বদলায়, দেখানো।

মূল উপলব্ধি: Survival পদ্ধতির নির্ভরযোগ্যতা censoring-এর কারণের ওপর দাঁড়িয়ে; administrative censoring নিরাপদ, কিন্তু আচরণ-নির্ভর censoring-এ নকশা, competing risks আর sensitivity analysis লাগে।

প্র ০৪Churn-এর জন্য survival model না fixed-horizon classifier: ML দলে কোনটি বেছে নেবেন, আর কেন?

দুটিই বৈধ, যদি ঠিকভাবে বানানো হয়। Fixed-horizon classifier প্রশ্ন করে: “আজ থেকে ৯০ দিনের মধ্যে এই ক্রেতা চলে যাবেন কি না?” Label বানাতে লাগে আজ থেকে ৯০ দিন পর্যন্ত পুরো দেখা, তাই training-এ শুধু সেই ক্রেতারা আসেন যাঁদের ৯০ দিন দেখা হয়েছে। এই নকশায় gradient boosting, calibration আর threshold-এর সব হাতিয়ার (পর্ব ৩৫, ২৬) সরাসরি চলে, আর ব্যবসার প্রশ্ন (“এই সপ্তাহে কাকে ছাড়ের কুপন দেব”) প্রায়ই ঠিক এই আকারের। প্রথম ব্লকের ভুল এড়াতে শর্ত দুটি: label-এর জানালা সবার জন্য সমান, আর feature শুধু prediction-এর দিনের আগের তথ্য।

Survival model-এর সুবিধা অন্যখানে। এক model থেকে যেকোনো সময়ের ঝুঁকি (৩০, ৯০, ৩৬৫ দিন) পাওয়া যায়; সাম্প্রতিক ক্রেতারাও censored হিসেবে training-এ থাকেন, তাই ডেটা কম নষ্ট হয়; আর প্রত্যাশিত জীবনকাল বা RMST, যা lifetime value-র ভিত্তি, সরাসরি আসে। অসুবিধা: ML-এর পরিচিত টুল কম, মূল্যায়ন (C-index, time-dependent Brier) অপরিচিত, আর proportional hazards-এর মতো অনুমান যাচাই করতে হয়।

বাস্তবে অনেক দল দুটি একসঙ্গে ব্যবহার করে। Survival বক্ররেখা দিয়ে ব্যবসার বড় প্রশ্ন (কোন cohort কতদিন থাকে, কোন plan ধরে রাখে), আর একটি fixed-horizon classifier দিয়ে প্রতিদিনের পদক্ষেপ। আরেকটি পথ discrete-time survival: সময়কে সপ্তাহে ভাগ করে প্রতিটি “ক্রেতা × সপ্তাহ” সারিতে “এই সপ্তাহে চলে গেলেন কি না” label, যেখানে একজন ক্রেতা শুধু যতদিন ছিলেন ততদিনের সারি দেন। এটি একটি সাধারণ classifier দিয়েই সঠিক hazard শেখে, আর censoring সঠিকভাবে সামলায়।

একটি জিনিস দুটির কোনোটিই দেয় না: কুপন দিলে ক্রেতা থাকবেন কি না। ঝুঁকি অনুমান আর হস্তক্ষেপের প্রভাব আলাদা প্রশ্ন; দ্বিতীয়টির জন্য A/B test (পর্ব ৬২, ৬৩) বা causal inference (পর্ব ৬৪) লাগে।

মূল উপলব্ধি: প্রশ্নের আকার দেখে বাছুন: একটি নির্দিষ্ট জানালায় পদক্ষেপ হলে সমান জানালার classifier, সময়ের পুরো বক্ররেখা বা জীবনকাল দরকার হলে survival model; দুটিতেই censoring আর leakage সামলানো বাধ্যতামূলক।

প্র ০৫Random survival forest আর deep survival model কি Cox-কে ছাড়িয়ে যায়? প্রমাণ কী বলে, আর তুলনা কীভাবে সৎ রাখবেন?

Hemant Ishwaran, Udaya Kogalur, Eugene Blackstone আর Michael Lauer ২০০৮ সালে random survival forest প্রকাশ করেন: প্রতিটি গাছ bootstrap নমুনায় বড় হয়, ভাগ বাছাই হয় দুই সন্তান-নোডের log-rank পার্থক্য সর্বোচ্চ করে, আর পাতায় Nelson–Aalen ধাঁচের cumulative hazard থাকে। এটি interaction আর বাঁকা সম্পর্ক নিজে খোঁজে, আর proportional hazards অনুমান চায় না। পরে gradient-boosted Cox, আর neural network-ভিত্তিক DeepSurv (Jared Katzman ও সহকর্মীরা, ২০১৮) ও DeepHit-এর মতো model এসেছে।

প্রমাণ মিশ্র। কিছু ডেটায়, বিশেষত বহু বৈশিষ্ট্য আর জটিল interaction থাকলে, এসব model ভালো C-index দেখায়। আবার বহু তুলনামূলক গবেষণায় মাঝারি আকারের, কম বৈশিষ্ট্যের tabular ডেটায় সুবিন্যস্ত Cox (প্রয়োজনে spline আর interaction-সহ) প্রায় সমান থাকে; পার্থক্য প্রায়ই noise-এর মধ্যে পড়ে। এই পর্বের ডেস্কটপ run-এ forest ০.৬৩৯ আর Cox ০.৬৩৪, একটি ভাগে; ডেটা যেহেতু প্রায় Cox-এর মতো করে বানানো, তাই এটি প্রত্যাশিত। পর্ব ৩৮-এ tabular ডেটায় model বাছাই নিয়ে যে সতর্কতা দেখেছেন, এখানেও তা খাটে।

সৎ তুলনার নিয়ম কয়েকটি। (১) সময় ধরে ভাগ: পুরোনো cohort-এ train, নতুন cohort-এ test, কারণ বাস্তবে model ভবিষ্যতের ক্রেতায় চলবে। (২) একাধিক মাপ: Harrell's C ছাড়াও Uno's C, আর নির্দিষ্ট সময়ে Brier score বা calibration, কারণ ক্রম ঠিক থাকলেও সম্ভাবনা ভুল হতে পারে। (৩) কয়েকটি ভাগ বা bootstrap দিয়ে পার্থক্যের অনিশ্চয়তা (পর্ব ২১, ২২)। (৪) Tuning শুধু training-এর ভেতরের validation-এ। (৫) একটি সরল baseline, যেমন শুধু Kaplan–Meier (সবার জন্য একই বক্ররেখা) আর একটি বৈশিষ্ট্যের Cox.

আর ব্যাখ্যার প্রশ্নও আছে। Cox-এর HR সরাসরি পড়া যায়; forest-এর জন্য permutation importance বা SHAP-এর survival রূপ লাগে, যার ধারণা পর্ব ৫৬ আর ৫৭-এ আসবে।

মূল উপলব্ধি: ML survival model জটিল সম্পর্কে সাহায্য করতে পারে, কিন্তু মাঝারি tabular ডেটায় Cox প্রায়ই সমান থাকে; সময়-ভিত্তিক ভাগ, একাধিক মাপ আর অনিশ্চয়তাসহ তুলনা ছাড়া জয় ঘোষণা করবেন না।

অনুশীলন

হিসাবগুলো খাতায় করুন, তারপর উত্তর মেলান। চাইলে পাতার ব্লকগুলোতে সংখ্যা বদলে যাচাই করুন।

  1. পূর্ণ numerical: আটজন ক্রেতার সময় (মাস): ১, ৩, ৩+, ৪, ৬, ৬, ৮+, ১০ (“+” = censored). (ক) Kaplan–Meier টেবিল বানান আর median বের করুন। (খ) t = ৪-এ Greenwood SE গুনুন। (গ) censored দুজনকে বাদ দিলে Ŝ(৬) কত হতো? (ঘ) একটি model এই আটজনকে ঝুঁকি দিয়েছে: ০.৯, ০.৫, ০.৮, ০.৬, ০.৭, ০.৪, ০.২, ০.৩ (একই ক্রমে)। Harrell's C কত? (জোড়া তুলনাযোগ্য শুধু যখন আগের জনের ঘটনা দেখা গেছে আর অন্যজনের সময় কঠোরভাবে বড়।)

    (ক) সময় ৩-এ একটি ঘটনা আর একটি censored: প্রচলিত নিয়মে censored ক্রেতা সেই দিনের ঝুঁকিতে গোনা হন।

     t   n   d   1 − d/n    Ŝ(t)
     1   8   1   7/8       0.875
     3   7   1   6/7       0.750
     4   5   1   4/5       0.600
     6   4   2   2/4       0.300
    10   1   1   0         0

    Median = প্রথম যে সময়ে Ŝ ≤ ০.৫, অর্থাৎ ৬ মাস. (খ) Greenwood ∑ = ১/(৮·৭) + ১/(৭·৬) + ১/(৫·৪) = ০.০১৭৯ + ০.০২৩৮ + ০.০৫ = ০.০৯১৭; SE = ০.৬ × √০.০৯১৭ = ০.১৮১৭. (গ) Censored দুজন বাদ দিলে ছয়জন (১, ৩, ৪, ৬, ৬, ১০), ৬ মাসের পর থাকেন একজন: Ŝ(৬) = ১/৬ = ০.১৬৭, Kaplan–Meier-এর ০.৩০০-এর প্রায় অর্ধেক। (ঘ) তুলনাযোগ্য জোড়া (i-এর ঘটনা দেখা গেছে, tj > ti): t = ১ (০.৯): ৭টি, সব concordant; t = ৩ (০.৫): পরের ৫ জনের ঝুঁকি ০.৬, ০.৭, ০.৪, ০.২, ০.৩, concordant ৩; t = ৪ (০.৬): ৪টি, concordant ৩; t = ৬-এর দুজন (০.৭ আর ০.৪): প্রত্যেকের ২টি (৮+ আর ১০), সব concordant; t = ১০: কেউ পরে নেই। মোট ১৭/২০, C = ০.৮৫. একই দিনের ঘটনা-জোড়া (৬, ৬) আর ৩-এর censored জোড়া এই কঠোর নিয়মে গোনা হয়নি। সংখ্যাগুলো ডেস্কটপে statsmodels আর নিজের কোডে যাচাই করা।

  2. সমালোচনা ও নকশা: একজন বিশ্লেষক লিখেছেন: “২০২৪-২০২৫-এর সব ক্রেতা নিয়ে label বানালাম: আজ পর্যন্ত চলে গেছেন = 1, নইলে 0. Feature: plan, অভিযোগ, কুপন, আর ‘শেষ অর্ডার থেকে কত দিন’। Gradient boosting-এ test accuracy ৯৩%! ‘শেষ অর্ডার থেকে কত দিন’ সবচেয়ে গুরুত্বপূর্ণ feature. Plus-এর HR ০.৬, তাই Plus দিলে churn ৪০% কমবে।” কী কী ভুল, আর আপনার নকশা কী?

    সমস্যা: (১) Label দেখার সময়ের ওপর নির্ভর করে: পুরোনো ক্রেতারা বেশি “1”, যেমন প্রথম ব্লকে ৭১% বনাম ৪২%. (২) “শেষ অর্ডার থেকে কত দিন” label-এর সংজ্ঞারই অংশ (৯০ দিন অর্ডার নেই = churn): এটি target leakage, তাই ৯৩% অর্থহীন। (৩) Accuracy censoring উপেক্ষা করে আর সম্ভাবনার মান বলে না। (৪) HR ০.৬ মানে hazard ০.৬ গুণ, “churn ৪০% কম” নয়; এক বছরে S0 = ০.৫ হলে Plus-এর S = ০.৫০.৬ = ০.৬৬, churn ০.৫০ থেকে ০.৩৪, অর্থাৎ ৩২% কম। (৫) পর্যবেক্ষণের ডেটা: Plus যাঁরা নেন তাঁরা হয়তো আগে থেকেই অনুগত; “Plus দিলে” একটি হস্তক্ষেপের দাবি, যার জন্য A/B test লাগে। নকশা: time আর event কলাম, censored-রা training-এ; feature শুধু যোগদানের প্রথম মাসের তথ্য; Kaplan–Meier আর log-rank দিয়ে শুরু, তারপর Cox, PH যাচাই, প্রয়োজনে stratification; পুরোনো cohort-এ train, নতুন cohort-এ C-index আর ১২ মাসের Brier score; ব্যবসার জন্য ১২ মাসের survival আর বক্ররেখা; আর Plus-এর প্রভাব জানতে আলাদা পরীক্ষা।

  3. আউটপুট অনুমান করুন: চালানোর আগে তিনটি লাইন লিখুন।
    import numpy as np
    t = np.array([2, 3, 3, 5, 7, 7, 9])
    e = np.array([1, 1, 0, 1, 0, 1, 1])
    u = np.unique(t[e == 1])
    n = np.array([(t >= s).sum() for s in u])
    d = np.array([((t == s) & (e == 1)).sum() for s in u])
    print(u, n, d)
    print(np.round(np.cumprod(1 - d / n), 4))
    print(round(t.sum() / e.sum(), 2))

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

    [2 3 5 7 9] [7 6 4 3 1] [1 1 1 1 1]
    [0.8571 0.7143 0.5357 0.3571 0.    ]
    7.2

    ঘটনার সময় ২, ৩, ৫, ৭, ৯ (৩-এর censored সারি ঘটনা নয়, তাই u-তে একবারই ৩)। t = ৫-এ n = ৪, কারণ ৩-এর censored ক্রেতা আর নেই। t = ৭-এ ঘটনা আর censored দুটিই আছে, তাই n = ৩: censored ক্রেতাও সেদিন ঝুঁকিতে গোনা হন। Ŝ: ৬/৭ = ০.৮৫৭১, × ৫/৬ = ০.৭১৪৩, × ৩/৪ = ০.৫৩৫৭, × ২/৩ = ০.৩৫৭১, × ০ = ০. শেষ লাইন ৩৬/৫ = ৭.২: censoring-সহ exponential model-এর λ̂ (গড় জীবনকাল), “মোট দেখার সময় ভাগ ঘটনার সংখ্যা”।

  4. কোডিং: একটি function লিখুন exp_mle(t, d), যা censoring-সহ exponential model-এর λ̂ = ∑t / ∑d আর তার থেকে median λ̂·ln ২ ফেরত দেয়। বাজারঘরের ৮০০ ক্রেতায় (customers()) Basic আর Plus দলে আলাদা চালান, Kaplan–Meier median-এর (SurvfuncRight) সঙ্গে ছাপান, আর censored সারি বাদ দিয়ে (শুধু ঘটনার গড়) কী হয়, তাও ছাপান। S(৬০) আর S(৬০০)-ও দুই পথে তুলনা করুন। Exponential কতটা মেলে, আর সেই মিল কেন বিভ্রান্তিকর হতে পারে?
    import numpy as np
    from statsmodels.duration.survfunc import SurvfuncRight
    from bazar_surv import customers
    
    def exp_mle(t, d):
        lam = t.sum() / d.sum()          # মোট দেখার সময় / ঘটনা
        return lam, lam * np.log(2)
    
    df = customers()
    for p, name in [(0, "Basic"), (1, "Plus")]:
        g = df[df.plan == p]
        t, d = g.time.to_numpy(), g.event.to_numpy()
        lam, med = exp_mle(t, d)
        sf = SurvfuncRight(t, d)
        _, med_drop = exp_mle(t[d == 1], d[d == 1])
        print(f"{name}: exp median {med:.0f}, KM median {sf.quantile(0.5):.0f}, "
              f"events-only median {med_drop:.0f}")
        for day in [60, 600]:
            i = np.searchsorted(sf.surv_times, day, side="right") - 1
            print(f"   S({day}): exp {np.exp(-day / lam):.3f}, KM {sf.surv_prob[i]:.3f}")

    ডেস্কটপে সত্যিকারের run (Python 3.14.4, NumPy 2.5.4, statsmodels 0.15.0) ছাপে:

    Basic: exp median 288, KM median 276, events-only median 142
       S(60): exp 0.866, KM 0.840
       S(600): exp 0.236, KM 0.244
    Plus: exp median 478, KM median 497, events-only median 158
       S(60): exp 0.917, KM 0.913
       S(600): exp 0.419, KM 0.412

    এক সারির λ̂-ই exponential-এর পুরো MLE (পর্ব ১৮-এর মতো derivative শূন্য করলে আসে)। Median আর দুটি সময়ের S Kaplan–Meier-এর বেশ কাছে: Basic-এ ১২ দিন বেশি, Plus-এ ১৯ দিন কম। শুধু ঘটনা নিলে median ১৪২ আর ১৫৮, প্রায় অর্ধেক আর এক-তৃতীয়াংশ। কিন্তু ভালো মিল বিভ্রান্তিকর: প্রতিটি দলে কুপনের ক্রেতা (কমতে থাকা hazard) আর সাধারণ ক্রেতা (বাড়তে থাকা hazard) মিশে আছেন, আর দুটি উল্টো ছাঁদ মিলে মোটামুটি স্থির hazard দেখায়। অংশ ৭-এর Weibull আর log(−log S) plot দেখিয়েছে, ভেতরের দল দুটি মোটেই স্মৃতিহীন নয়। মোট বক্ররেখা মেলা মানে উপদলের গল্প ঠিক, এমন নয়।

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

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

ক্রমCensoring-সহ ডেটায় হাতে Kaplan–Meier বক্ররেখা বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. প্রতিটি ক্রেতার time আর event (1 = ঘটনা, 0 = censored) ঠিক করা
  2. ঘটনার আলাদা আলাদা সময়গুলো ছোট থেকে বড় সাজানো
  3. প্রতিটি ঘটনার সময়ে ঝুঁকিতে থাকা n আর চলে যাওয়া d গোনা
  4. প্রতিটি সময়ের গুণক ১ − d/n বের করা
  5. গুণকগুলো পরপর গুণ করে Ŝ(t) পাওয়া
  6. Greenwood দিয়ে SE গুনে পরিসর আর median পড়া

ডেটা কাটার দিনে ৪০% ক্রেতা এখনো সক্রিয় (censored). একজন বিশ্লেষক শুধু চলে যাওয়া ক্রেতাদের গড় সময় দিয়ে “গড় জীবনকাল” রিপোর্ট করলেন। ফলটি সাধারণত কী হবে?

  • প্রায় সঠিক, কারণ censored-রা এলোমেলোভাবে বাদ যান
  • আসল জীবনকালের চেয়ে কম
  • আসল জীবনকালের চেয়ে বেশি
  • কোনো দিকে বলা যায় না

পাঁচজন ক্রেতার সময় (মাস): ২, ৩+, ৪, ৪, ৬ (“+” = censored). Kaplan–Meier অনুযায়ী Ŝ(৪) কত?

  • ০.২০
  • ০.২৫
  • ০.২৬৭
  • ০.৫০

একটি Cox model-এ Plus সদস্যতার hazard ratio ০.৬০. Basic ক্রেতার ১২ মাসে survival ০.৫০ হলে, একই অন্য বৈশিষ্ট্যের Plus ক্রেতার ১২ মাসে survival প্রায় কত (proportional hazards ধরে)?

  • প্রায় ০.৬৬
  • ০.৭০
  • ০.৩০
  • ০.৫০, কারণ HR সময়ের ওপর নির্ভর করে না

তিনজন ক্রেতা: A ৩ মাসে চলে গেছেন (ঝুঁকি স্কোর ০.৪), B ৫ মাসে censored (০.৯), C ৭ মাসে চলে গেছেন (০.২)। Harrell's C কত?

  • ১/৩
  • ১
  • ২/৩
  • ১/২

দুই দলের Kaplan–Meier থেকে আঁকা log(−log Ŝ(t)) বনাম log t রেখা দুটি একে অন্যকে কাটে, ঢালও আলাদা। কোন সিদ্ধান্ত সবচেয়ে যুক্তিসংগত?

  • Proportional hazards ঠিক আছে, একক HR রিপোর্ট করা যায়
  • Proportional hazards ভাঙা; ওই চলকে stratify বা সময়ভেদে প্রভাব মডেল করা উচিত
  • দুই দলের survival-এ কোনো পার্থক্য নেই
  • ডেটা কোনো Weibull model মানে না

Censoring-সহ Weibull-এর log-likelihood-এ একজন censored ক্রেতা (সময় t) কোন পদ যোগ করেন?

  • log f(t)
  • কিছুই না, তাদের বাদ দেওয়া হয়
  • log S(t) = −(t/λ)k
  • log(১ − S(t))

শব্দকোষ

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

মনে রাখুন

  1. Censoring: label = (সময়, ঘটনা-সূচক); censored মানে “অন্তত এত দিন”। বাদ দিলে বা ঘটনা ধরলে জীবনকাল কম দেখায়, আর churned/not label দেখার সময় শেখে।
  2. S আর h: S(t) = P(T > t), h(t) = তাৎক্ষণিক হার, S = exp(−H). Weibull-এ k < ১ মানে hazard কমে, k > ১ মানে বাড়ে।
  3. Kaplan–Meier: Ŝ(t) = ∏(১ − d/n), Greenwood SE; দুই দলে log-rank test.
  4. Cox: h0(t)·exβ, partial likelihood; HR হার, সম্ভাবনা নয়: S1 = S0HR. মূল্যায়ন C-index, শুধু তুলনাযোগ্য জোড়ায়।
  5. যাচাই: log(−log S) সমান্তরাল কি না, Schoenfeld, সময় ভাগ করে fit; ভাঙলে stratify বা parametric (censoring-সহ MLE: ঘটনায় f, censored-এ S).

পরের পর্বে: মডিউল ১০ এখানে শেষ। পর্ব ৫৬ (Model বোঝা — permutation importance, PDP আর ICE) দিয়ে শুরু হবে model-এর আচরণ বোঝার পালা: কোন feature কতটা কাজে লাগে, আর অনুমান feature-এর সঙ্গে কীভাবে বদলায়।

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

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