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

Decision tree গভীরে — CART, impurity, pruning আর missing value

Decision Trees in Depth — Recursive Splitting, Gini and Entropy, Regression Trees, CART from Scratch, Cost-Complexity Pruning, Instability and Missing Values

Taught in Bangla, this lesson builds decision trees from the inside: Gini, entropy and misclassification error worked by hand, best thresholds on sorted values, regression trees, a thirty-line CART that matches scikit-learn split for split, cost-complexity pruning chosen by cross-validation, bootstrap instability, and native NaN handling. Real in-browser and desktop scikit-learn runs on fictional churn data.

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

  • একটি node-এর Gini আর entropy, আর একটি split-এর ওজনসহ impurity হাতে হিসাব করা
  • ছয়টি সাজানো মানে সেরা threshold বের করা, আর পুরো গাছ বানানোর খরচ মেপে দেখা
  • প্রায় ত্রিশ লাইনের নিজে লেখা CART বানিয়ে DecisionTreeClassifier-এর সঙ্গে প্রতিটি split মিলিয়ে দেখা
  • Weakest-link pruning-এর α হাতে বের করা, আর CV দিয়ে ccp_alpha বনাম max_depth বাছা
  • গাছের অস্থিরতা, সিঁড়ির মতো সীমা, categorical feature আর missing value-এর ব্যবহারিক দিক বোঝা

মডিউল ৬ শেষ হয়েছিল পর্ব ৩১-এ একটি সতর্ক কথায়: অনেক categorical আর মিশ্র কলামের tabular ডেটায় SVM-এর চেয়ে boosting প্রায়ই ভালো করে। এই মডিউল সেই boosting আর forest-এর দিকে যাবে, কিন্তু সবগুলোর ইট একটিই: একটি decision tree. AI-এর ভিত্তি কোর্সের পর্ব ৬৬-এ গাছের সঙ্গে প্রথম দেখা হয়েছে: Gini দিয়ে প্রথম প্রশ্ন বাছা আর max_depth দিয়ে গাছ ছোট রাখা। এখানে ভেতরে ঢুকব: প্রতিটি সংখ্যা হাতে, তারপর নিজে লেখা গাছ, তারপর scikit-learn-এর গাছের সঙ্গে হুবহু মিল। চালানো যায় এমন চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); প্রথমবার scikit-learn নামতে প্রায় দশ সেকেন্ড লাগে।

১ · গাছ কীভাবে প্রশ্ন বাছে — greedy, ওপর থেকে নিচে

পাড়ার অভিজ্ঞ ডাক্তার রোগী দেখে প্রথমেই সবচেয়ে কাজের প্রশ্নটি করেন: “জ্বর কয় দিন?” উত্তর শুনে রোগীরা দুই দলে ভাগ হয়ে যায়, আর প্রতিটি দলের জন্য তাঁর পরের প্রশ্ন আলাদা। তিনি পুরো প্রশ্নমালা আগে থেকে সাজিয়ে রাখেন না; প্রতিবার শুধু ভাবেন, “এই মুহূর্তে কোন প্রশ্ন রোগীদের সবচেয়ে পরিষ্কার দুই দলে ভাগ করবে?” Decision tree ঠিক এভাবেই শেখে।

বাজারঘরের (একটি কাল্পনিক অনলাইন দোকান) প্রশ্ন: কোন গ্রাহক চলে যাবেন, অর্থাৎ churn করবেন? প্রতিটি গ্রাহকের চারটি সংখ্যা: শেষ অর্ডারের পর কত দিন (days), গত ছয় মাসে অর্ডার (orders), দেরিতে পৌঁছানো ডেলিভারি (late) আর গড় ঝুড়ির দাম (basket, টাকা)। সব ডেটা seed দিয়ে বানানো, কোনো আসল দোকানের নয়।

CART (Classification And Regression Trees) গাছ বানায় recursive binary splitting দিয়ে:

  1. সব train সারি মূল node-এ। যদি সব এক শ্রেণির হয়, বা কোনো থামার নিয়ম পূরণ হয়, এটি পাতা.
  2. না হলে প্রতিটি feature j আর প্রতিটি সম্ভাব্য threshold t-এর জন্য প্রশ্ন “xj ≤ t?” সারিগুলোকে বাঁ আর ডান দুই দলে ভাগ করে। প্রতিটি প্রশ্নের জন্য সন্তানদের impurity-র ওজনসহ গড় হিসাব।
  3. যে (j, t) সেই গড় সবচেয়ে কম দেয়, সেটিই এই node-এর প্রশ্ন।
  4. বাঁ আর ডান দলে ধাপ ১ থেকে আবার, যেন প্রতিটি দল একটি নতুন ছোট ডেটাসেট।

দুটি শব্দ মনে রাখুন। Greedy: প্রতিটি ধাপে এখনকার সেরা প্রশ্ন, ভবিষ্যতের কথা না ভেবে (DSA কোর্সের greedy algorithm-এর মতো)। Top-down: মূল থেকে শুরু করে নিচে। কেন সেরা পুরো গাছ সরাসরি খোঁজা হয় না? Laurent Hyafil আর Ronald Rivest ১৯৭৬ সালে দেখান, একটি নির্দিষ্ট অর্থে সবচেয়ে ছোট নির্ভুল binary decision tree খোঁজা NP-complete. তাই বাস্তবে greedy, আর তার ভুল আংশিক সারানো হয় pruning আর ensemble দিয়ে।

একটি গাছ মানে feature-এর জায়গাকে আয়তক্ষেত্রে কাটা

ওপরে প্রশ্নের গাছ, নিচে একই গাছ days–orders সমতলে

A depth-2 decision tree and the axis-aligned regions it creates days ≤ 45.5 ? হ্যাঁ না পাতা: থাকবেন orders ≤ 6.5 ? পাতা: চলে যাবেন থাকবেন থাকবেন থাকবেন চলে যাবেন 45.5 0 90 days (শেষ অর্ডারের পর দিন) → 6.5 orders ↑
ছবি ৩২.১ — প্রতিটি প্রশ্ন একটি অক্ষের সমান্তরাল রেখা টানে। দুই স্তরের এই গাছ সমতলকে তিনটি আয়তক্ষেত্রে কাটে, প্রতিটি আয়তক্ষেত্র একটি পাতা।

নতুন গ্রাহক এলে তাঁকে মূল থেকে প্রশ্ন ধরে নিচে নামানো হয়, যতক্ষণ না কোনো পাতায় পৌঁছান। Classification-এ পাতা জানায় সেখানকার train সারিগুলোর শ্রেণি-অনুপাত (সেটিই predict_proba) আর সংখ্যাগরিষ্ঠ শ্রেণি। Prediction-এর খরচ শুধু গাছের গভীরতা, সারির সংখ্যা নয়।

২ · Impurity — Gini, entropy আর misclassification error

“সবচেয়ে পরিষ্কার ভাগ” মাপতে লাগে একটি node কতটা মেশানো তার সংখ্যা: impurity. দুই শ্রেণিতে, p হলো node-এ churn করা গ্রাহকের অনুপাত।

মাপসূত্র (K শ্রেণি)দুই শ্রেণি, p = ০.৪সর্বোচ্চ (দুই শ্রেণি)
Gini impurity১ − ∑k pk²১ − (০.১৬ + ০.৩৬) = ০.৪৮০.৫
Entropy−∑k pk log₂ pk০.৯৭১০ bit১ bit
Misclassification error১ − maxk pk০.৪০.৫

Gini-র একটি সুন্দর অর্থ আছে: node থেকে এলোমেলোভাবে একটি গ্রাহক নিয়ে তাঁকে node-এর অনুপাত অনুযায়ী এলোমেলো label দিলে ভুল হওয়ার সম্ভাবনা। Entropy আর তার কমা, অর্থাৎ information gain, AI-এর ভিত্তি কোর্সের পর্ব ৩৮-এ বিস্তারিত দেখেছেন; এখানে শুধু ব্যবহার। একটি split-এর মান:

Isplit = (nL/n) · I(L) + (nR/n) · I(R)

এখানে nL, nR হলো বাঁ আর ডান সন্তানের সারি, I যেকোনো একটি impurity. লক্ষ্য Isplit সবচেয়ে কম করা, অর্থাৎ decrease = I(parent) − Isplit সবচেয়ে বেশি করা। Entropy দিয়ে এই decrease-এর নামই information gain.

Gini = 1 − ∑p²1 = churn, 0 = থাকবেন

            
  1. ডেটা। দশজন গ্রাহক, শেষ অর্ডারের পর দিন অনুযায়ী সাজানো। চারজন চলে গেছেন, ছয়জন আছেন; p = ০.৪.
  2. মূলের Gini. ০.৪² আর ০.৬² যোগ করে এক থেকে বাদ: ০.৪৮. দুই শ্রেণি আধাআধি হলে হতো ০.৫, তাই node বেশ মেশানো।
  3. একটি প্রশ্ন। “days ≤ ৩১.৫?” (২৯ আর ৩৪-এর মাঝখান)। বাঁয়ে ছয়জনের মধ্যে একজন churn, ডানে চারজনের মধ্যে তিনজন।
  4. দুই সন্তানের Gini. বাঁ দল প্রায় খাঁটি, তাই ০.২৭৭৮; ডান দলে তিন-এক, তাই ০.৩৭৫. দুটিই মূলের ০.৪৮-এর চেয়ে কম।
  5. ওজনসহ গড়। বড় দলের ওজন বেশি: ০.৬ আর ০.৪. ফল ০.৩১৬৭, তাই এই প্রশ্ন Gini ০.১৬৩৩ কমায়। অংশের শেষের ব্লকে দেখবেন, এটি সেরা প্রশ্ন নয়।
  6. Entropy দিয়ে। একই ভাগে information gain ০.২৫৬৫ bit. সংখ্যা আলাদা, কিন্তু কোন প্রশ্ন ভালো সেই ক্রম প্রায় সবসময় Gini-র সঙ্গে মেলে।

কেন misclassification error দিয়ে গাছ বাড়ানো হয় না? দুই শ্রেণির ৮০০ গ্রাহক, ৪০০ আর ৪০০. Split A দেয় (৩০০ থাকবেন, ১০০ churn) আর (১০০, ৩০০); Split B দেয় (২০০, ৪০০) আর (২০০, ০). দুটিতেই ২০০টি ভুল, error ০.২৫. কিন্তু B একটি পুরো খাঁটি দল বানিয়েছে, যা পরের ধাপে আর ভাগ করতে হবে না। Gini সেটি ধরে: A-তে ০.৩৭৫, B-তে ০.৩৩৩. কারণটি জ্যামিতিক: Gini আর entropy p-এর কঠোরভাবে বাঁকা (strictly concave) ফাংশন, তাই সন্তানদের p যতই বাবার p থেকে দূরে সরে, ওজনসহ গড় ততই কমে। Error রেখাটি টুকরো টুকরো সোজা; সন্তান দুজন একই দিকে সংখ্যাগরিষ্ঠ থাকলে error একটুও কমে না, যদিও ভাগটি কাজের। Breiman ও সহলেখকেরা CART বইয়ে এই যুক্তিই দিয়েছিলেন। Error তবু কাজে লাগে: pruning-এর পুরোনো রূপে আর শেষ মূল্যায়নে।

নিচের ব্লক তিনটি মাপ নিজে লেখে, দশজনের টেবিলে সব নয়টি সম্ভাব্য threshold চেষ্টা করে, A আর B split তুলনা করে, আর তিনটি রেখা আঁকে।

import numpy as np
import matplotlib.pyplot as plt

def gini(y):    p = np.mean(y); return 1 - p**2 - (1 - p)**2
def entropy(y):
    p = np.mean(y)
    return 0.0 if p in (0, 1) else -(p*np.log2(p) + (1-p)*np.log2(1-p))
def miscls(y):  p = np.mean(y); return min(p, 1 - p)

days  = np.array([3, 8, 12, 17, 25, 29, 34, 47, 60, 78])
churn = np.array([0, 0, 0, 1, 0, 0, 0, 1, 1, 1])
print(f"root: gini {gini(churn):.4f}  entropy {entropy(churn):.4f}  error {miscls(churn):.2f}")
print(" threshold  left  right  w-gini  w-entropy  w-error")
for t in (days[:-1] + days[1:]) / 2:            # পাশাপাশি দুই মানের মাঝখান
    L, R = churn[days <= t], churn[days > t]
    w = lambda f: (len(L)*f(L) + len(R)*f(R)) / len(churn)
    print(f"{t:9.1f}  {len(L):4d}  {len(R):5d}  {w(gini):.4f}  {w(entropy):9.4f}  {w(miscls):7.2f}")

# দুই split, misclassification error একই, Gini আলাদা
for name, (a, b) in {"A": ((300, 100), (100, 300)), "B": ((200, 400), (200, 0))}.items():
    ys = [np.r_[np.zeros(s[0]), np.ones(s[1])] for s in (a, b)]
    g = sum(len(y)*gini(y) for y in ys) / 800
    e = sum(len(y)*miscls(y) for y in ys) / 800
    print(f"split {name}: weighted gini {g:.4f}  weighted error {e:.4f}")

p = np.linspace(0.001, 0.999, 200)
H = -(p*np.log2(p) + (1-p)*np.log2(1-p))
fig, ax = plt.subplots(figsize=(6, 3.6))
ax.plot(p, 1 - p**2 - (1-p)**2, label="Gini 1 - p^2 - (1-p)^2")
ax.plot(p, H / 2, label="entropy / 2 (bits)")
ax.plot(p, np.minimum(p, 1 - p), label="misclassification error")
ax.set(xlabel="p = share of churners in a node", ylabel="impurity",
       title="Three impurity measures (two classes)")
ax.legend(fontsize=8); fig.subplots_adjust(bottom=0.15)

আসল আউটপুট (ব্রাউজারের Python), নিচে ছবি:

root: gini 0.4800  entropy 0.9710  error 0.40
 threshold  left  right  w-gini  w-entropy  w-error
      5.5     1      9  0.4444     0.8920     0.40
     10.0     2      8  0.4000     0.8000     0.40
     14.5     3      7  0.3429     0.6897     0.30
     21.0     4      6  0.4500     0.9245     0.40
     27.0     5      5  0.4000     0.8464     0.30
     31.5     6      4  0.3167     0.7145     0.20
     40.5     7      3  0.1714     0.4142     0.10
     53.5     8      2  0.3000     0.6490     0.20
     69.0     9      1  0.4000     0.8265     0.30
split A: weighted gini 0.3750  weighted error 0.2500
split B: weighted gini 0.3333  weighted error 0.2500

নয়টি threshold-এর মধ্যে ৪০.৫ (৩৪ আর ৪৭-এর মাঝখান) জেতে: ওজনসহ Gini ০.১৭১৪, entropy ০.৪১৪২, error ০.১০. Stepper-এর ৩১.৫ ভালো ছিল (০.৩১৬৭), কিন্তু সেরা নয়। লক্ষ্য করুন, ২৭.০-তে error ০.৩০ আর ১৪.৫-তেও ০.৩০, কিন্তু Gini বলছে ১৪.৫ ভালো (০.৩৪২৯ বনাম ০.৪০০০); error অনেক জায়গায় সমান দেখায়, সিদ্ধান্ত নিতে পারে না। A আর B split-এ error দুটিতেই ০.২৫০০, Gini B-কে (০.৩৩৩৩) A-এর (০.৩৭৫০) ওপরে রাখে। ছবিতে entropy-কে দুই দিয়ে ভাগ করা হয়েছে যাতে তিনটি রেখার চূড়া ০.৫-এ মেলে: Gini আর entropy প্রায় একই আকারের মসৃণ বাঁক, error একটি ত্রিভুজ। এজন্যই বাস্তবে Gini আর entropy-র ফল খুব কাছাকাছি; Gini-তে log নেই বলে একটু সস্তা, আর scikit-learn-এর ডিফল্ট criterion="gini".

৩ · সংখ্যার feature-এ সেরা threshold — সাজানো, মাঝখান আর খরচ

একটি সংখ্যার feature-এ অসীম সংখ্যক threshold সম্ভব, কিন্তু ভাগ বদলায় শুধু তখন, যখন threshold কোনো train মান পার হয়। তাই মানগুলো সাজিয়ে নিয়ে পাশাপাশি দুটি আলাদা মানের মাঝখানে কাটলেই সব আলাদা ভাগ পাওয়া যায়: n টি আলাদা মানে n − ১টি প্রার্থী। মাঝখান কেন, কিনারা নয়? দুটি মানের মাঝে যেকোনো জায়গায় কাটলে train-এ একই ফল, আর মাঝখান নতুন ডেটার জন্য দুই দিকে সমান জায়গা রাখে। scikit-learn-ও মাঝখান নেয়, আর ভেতরে X-কে float32 করে নেয়।

ওজনসহ Gini = (nL·GL + nR·GR) / 6মূল Gini = 0.5

            
  1. সাজানো। ছয়টি আলাদা মান, তাই পাঁচটি মাঝখান। এর বাইরে কোনো threshold নতুন ভাগ দেয় না।
  2. ৮.৫. বাঁয়ে একজন, খাঁটি। ডানে পাঁচজনে তিনজন churn: Gini ০.৪৮. ওজনসহ ০.৪০, মূলের ০.৫ থেকে সামান্য কম।
  3. ১৬.০. বাঁয়ে দুজন খাঁটি, ডানে চারজনে তিনজন churn. ওজনসহ ০.২৫, এখন পর্যন্ত সেরা।
  4. ২৬.৫. দুই দিকেই তিনজনে একটি আর দুটি churn; দুই দিক প্রায় সমান মেশানো, ওজনসহ ০.৪৪৪৪.
  5. ৩৭.০. ডানে দুজন খাঁটি churn, বাঁয়ে চারজনে একজন। ওজনসহ আবার ০.২৫, ১৬.০-এর সমান।
  6. ৪৯.৫. শুধু শেষজন ডানে, খাঁটি; বাঁয়ে পাঁচজনে দুজন। ওজনসহ ০.৪০.
  7. সিদ্ধান্ত। ১৬.০ আর ৩৭.০ সমান সেরা। scikit-learn বাঁ থেকে ডানে খোঁজে আর শুধু কঠোরভাবে ভালো হলে বদলায়, তাই প্রথম পাওয়া ১৬.০ থাকে; ভিন্ন feature-এর মধ্যে সমান হলে কোনটি জিতবে তা random_state-এর feature-ক্রমের ওপর নির্ভর করে।

খরচ। প্রতিটি threshold-এ গোড়া থেকে Gini হিসাব করলে একটি feature-এ n² কাজ। চালাক উপায়: সাজানো ক্রমে একটি করে সারি বাঁ দিকে সরান আর শ্রেণির গোনা হালনাগাদ করুন; প্রতিটি নতুন threshold-এ Gini তখন O(1). ফলে একটি node-এ একটি feature-এর খরচ সাজানোর O(n log n) আর স্ক্যানের O(n); p টি feature-এ O(p · n log n). গাছ মোটামুটি ভারসাম্যপূর্ণ হলে প্রতিটি স্তরে সব node মিলে n টি সারি, স্তর প্রায় log n টি, তাই পুরো গাছ প্রায় O(p · n log² n); খুব একপেশে গাছে স্তর n-এর কাছাকাছি হতে পারে, আর খরচও বাড়ে। scikit-learn-এর docs সবচেয়ে খারাপ ক্ষেত্রের সীমা আরও বড় লেখে। Big-O-এর ভাষা DSA কোর্সের Big-O পর্বে আছে।

মেপে দেখা যাক। ডেস্কটপে সত্যিকারের run (একটি সাধারণ ল্যাপটপ, Python 3.14.4, scikit-learn 1.8.0; একটি run, সাধারণ ফল নয়)। একই ডেটা-নিয়মে চারটি feature, n প্রতিবার দ্বিগুণ; সমান মান ভাঙতে সামান্য noise যোগ করা:

~/bazarghar-ml$ python tree_time.py
Python 3.14.4, scikit-learn 1.8.0
        n   depth-8 fit (s)   full-tree fit (s)  leaves
   50,000           0.11               0.16     4,843
  100,000           0.24               0.37     9,557
  200,000           0.54               0.88    19,257
  400,000           1.17               2.11    37,831
  800,000           2.93               5.65    75,768

n দ্বিগুণ হলে সময় প্রায় ২.১ থেকে ২.৭ গুণ: দ্বিগুণের সামান্য বেশি, n log n-এর সঙ্গে মানানসই, পর্ব ৩১-এর SVC-এর প্রায় চার গুণের সঙ্গে তুলনা করুন। আট লাখ সারিতে পুরো গাছ ছয় সেকেন্ডের কম। কিন্তু শেষ কলামটি দেখুন: কোনো সীমা না দিলে গাছ প্রায় প্রতি দশ-এগারোটি সারিতে একটি পাতা বানিয়েছে, ৭৫,৭৬৮টি পাতা। এটি শেখা নয়, মুখস্থ। সেই সমস্যায় অংশ ৬-এ ফিরব।

৪ · Regression tree — squared error আর variance কমানো

লক্ষ্য যদি সংখ্যা হয়, যেমন বাজারঘরের ডেলিভারিতে কত ঘণ্টা লাগবে, তাহলে “মেশানো” মাপার বদলে মাপি “ছড়ানো”। একটি পাতার অনুমান সেখানকার y-গুলোর গড় ȳ, আর node-এর ভুল হলো squared error-এর যোগফল SSE = ∑(yi − ȳ)²; এটি n × variance-এর সমান। সেরা split সন্তানদের SSE-এর যোগ সবচেয়ে কম করে, অর্থাৎ variance reduction সবচেয়ে বেশি করে। Squared error-এ গড়ই সেরা ধ্রুবক অনুমান, তাই পাতায় গড় (পর্ব ০২-এর loss আর তার সেরা ধ্রুবক)।

ছয়টি কাল্পনিক অর্ডার, গুদাম থেকে দূরত্ব (km) আর ডেলিভারির সময় (ঘণ্টা): (২, ১০), (৪, ১২), (৫, ১১), (৯, ৩০), (১২, ৩৪), (১৫, ৩২). মূলের গড় ২১.৫, SSE ৬৭১.৫.

threshold (km)বাঁয়ের গড়ডানের গড়সন্তানদের SSE-এর যোগ
৩.০১০.০২৩.৮৫১২.৮
৪.৫১১.০২৬.৭৫৩৪০.৭৫
৭.০১১.০৩২.০১০.০
১০.৫১৫.৭৫৩৩.০২৭৪.৭৫
১৩.৫১৯.৪৩২.০৫৩৯.২

৭ km-এ ভাগ করলে SSE ৬৭১.৫ থেকে ১০-এ নামে: কাছের তিনটি অর্ডার গড়ে ১১ ঘণ্টা, দূরের তিনটি ৩২ ঘণ্টা। DecisionTreeRegressor-এর ডিফল্ট criterion="squared_error" ঠিক এটিই করে; "absolute_error" পাতায় median নেয় (outlier-এ শক্ত, কিন্তু অনেক ধীর), "poisson" গণনার লক্ষ্যের জন্য (পর্ব ২০), আর "friedman_mse" একটি সংশোধিত রূপ যা পর্ব ৩৫-এর gradient boosting-এ কাজে লাগে। একটি সীমা মনে রাখুন: regression tree-এর অনুমান ধাপের মতো, আর train-এ দেখা y-এর পরিসরের বাইরে কখনো যায় না। ২০ km-এর অর্ডারেও উত্তর ৩২ ঘণ্টা। এই extrapolation-এর অক্ষমতা পর্ব ৩৩ (Bagging আর random forest) একটি ছবিতে দেখাবে।

৫ · নিজে CART লেখা — আর scikit-learn-এর সঙ্গে হুবহু মেলানো

এবার পুরো বাজারঘর ডেটা: ৩০০ জন গ্রাহক, চারটি feature. Churn-এর সম্ভাবনা বাড়ে দিন আর দেরির ডেলিভারির সঙ্গে, কমে অর্ডারের সঙ্গে; ঝুড়ির দামের কোনো ভূমিকা নেই (একটি অকাজের feature রাখা হয়েছে ইচ্ছা করে)। প্রায় ৪৩–৪৭% গ্রাহক churn করেন।

# bazar_churn.py — বাজারঘরের কাল্পনিক গ্রাহক, কে চলে যাবে (seed দিয়ে বানানো)
import numpy as np
import pandas as pd

def make_churn(n=400, seed=32):
    rng = np.random.default_rng(seed)
    days = rng.integers(1, 91, n)                  # শেষ অর্ডারের পর কত দিন
    orders = rng.poisson(4, n)                     # গত ৬ মাসে অর্ডার
    late = rng.binomial(orders, 0.25)              # দেরিতে পৌঁছানো ডেলিভারি
    basket = rng.gamma(4, 300, n).round()          # গড় ঝুড়ি (টাকা)
    score = 0.05 * (days - 45) - 0.5 * (orders - 4) + 0.9 * late - 1.0
    p = 1 / (1 + np.exp(-3 * score))
    churn = (rng.random(n) < p).astype(int)
    return pd.DataFrame({"days": days, "orders": orders, "late": late,
                         "basket": basket, "churn": churn})

নিচের grow ফাংশনটিই পুরো CART: প্রতিটি feature সাজায়, প্রতিটি বৈধ জায়গায় কাটে, সবচেয়ে কম ওজনসহ Gini রাখে, তারপর নিজেকেই দুই দলে ডাকে (recursion, DSA কোর্সের recursion পর্ব)। থামার নিয়ম scikit-learn-এর মতো: গভীরতা ৩, প্রতিটি পাতায় অন্তত ৫টি সারি (min_samples_leaf), অথবা node খাঁটি। পড়ার সুবিধার জন্য Gini গোড়া থেকে হিসাব হয়, তাই কোডটি O(n²); গতি নয়, স্বচ্ছতা লক্ষ্য। শেষে গাছ sk.tree_ থেকে প্রতিটি split পড়ে দুটি গাছ মেলানো হয়।

import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text
from bazar_churn import make_churn

def gini(y): p = y.mean(); return 2 * p * (1 - p)
def grow(X, y, depth=0, max_depth=3, leaf=5):
    best = (np.inf, None, None)                    # (weighted gini, feature, threshold)
    for j in range(X.shape[1]):
        o = np.argsort(X[:, j], kind="stable"); xs, ys = X[o, j], y[o]
        for i in range(leaf, len(y) - leaf + 1):   # বাঁয়ে i টি সারি
            if xs[i - 1] == xs[i]: continue        # সমান মানের মাঝে কাটা যায় না
            w = (i * gini(ys[:i]) + (len(y) - i) * gini(ys[i:])) / len(y)
            if w < best[0]: best = (w, j, (xs[i - 1] + xs[i]) / 2)
    w, j, t = best
    if depth == max_depth or gini(y) == 0 or j is None:
        return {"p": y.mean()}                     # পাতা: churn-এর অনুপাত
    m = X[:, j] <= t
    return {"j": j, "t": t, "L": grow(X[m], y[m], depth + 1),
            "R": grow(X[~m], y[~m], depth + 1)}
def predict(node, x):
    while "j" in node: node = node["L"] if x[node["j"]] <= node["t"] else node["R"]
    return int(node["p"] > 0.5)
def splits(node):
    return [] if "j" not in node else [(node["j"], node["t"])] + splits(node["L"]) + splits(node["R"])

df = make_churn(300); cols = ["days", "orders", "late", "basket"]
X, y = df[cols].to_numpy(float), df["churn"].to_numpy()
mine = grow(X, y)
sk = DecisionTreeClassifier(max_depth=3, min_samples_leaf=5, random_state=0).fit(X, y)
tr = sk.tree_; sk_splits = [(f, t) for f, t in zip(tr.feature, tr.threshold) if f >= 0]
print("root  mine:", cols[mine["j"]], "<=", mine["t"], "  sklearn:", cols[tr.feature[0]], "<=", tr.threshold[0])
print("all splits equal:", splits(mine) == sk_splits, " internal nodes:", len(sk_splits))
print("same predictions:", all(predict(mine, x) == p for x, p in zip(X, sk.predict(X))))
print(export_text(sk, feature_names=cols, show_weights=True))

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

root  mine: days <= 45.5   sklearn: days <= 45.5
all splits equal: True  internal nodes: 7
same predictions: True
|--- days <= 45.50
|   |--- late <= 1.50
|   |   |--- orders <= 2.50
|   |   |   |--- weights: [26.00, 9.00] class: 0
|   |   |--- orders >  2.50
|   |   |   |--- weights: [79.00, 3.00] class: 0
|   |--- late >  1.50
|   |   |--- days <= 28.00
|   |   |   |--- weights: [22.00, 4.00] class: 0
|   |   |--- days >  28.00
|   |   |   |--- weights: [3.00, 10.00] class: 1
|--- days >  45.50
|   |--- orders <= 6.50
|   |   |--- days <= 51.50
|   |   |   |--- weights: [9.00, 10.00] class: 1
|   |   |--- days >  51.50
|   |   |   |--- weights: [8.00, 91.00] class: 1
|   |--- orders >  6.50
|   |   |--- late <= 2.50
|   |   |   |--- weights: [12.00, 5.00] class: 0
|   |   |--- late >  2.50
|   |   |   |--- weights: [0.00, 9.00] class: 1

দুটি গাছের মূল একই: days <= 45.5. শুধু মূল নয়, সাতটি ভেতরের node-এর প্রত্যেকটির feature আর threshold হুবহু মিলেছে, আর ৩০০ জনের প্রত্যেকের prediction একই। tree_.feature-এ পাতার জন্য −২ থাকে, তাই f >= 0 দিয়ে শুধু ভেতরের node নেওয়া হয়েছে; দুটি তালিকাই একই preorder ক্রমে (মূল, বাঁ উপগাছ, ডান উপগাছ)। export_text-এর weights: [79.00, 3.00] মানে সেই পাতায় ৭৯ জন থাকবেন আর ৩ জন churn. গাছটি পড়া যায়: ৪৫ দিনের মধ্যে অর্ডার করেছেন আর দেরির ডেলিভারি একটির বেশি নয়, এমন গ্রাহক প্রায় সবাই থাকেন; ৫১ দিনের বেশি নিখোঁজ আর অর্ডার ছয়টি বা কম, তাঁদের ৯৯ জনের ৯১ জন চলে যান। অকাজের basket এই ছোট গাছে একবারও আসেনি। একই ডেটায় সীমাহীন গাছ বানালে (আমাদের একটি ডেস্কটপ run-এ) ৫৫টি পাতা, আর ৫৪টি প্রশ্নের ১৯টিই basket নিয়ে: ছোট দলে noise-ও “ভালো প্রশ্ন” মনে হয়। এটি পরের অংশের সমস্যার সূচনা। ছবিতে গাছ দেখতে চাইলে sklearn.tree.plot_tree(sk, feature_names=cols, filled=True); লেবেল ইংরেজিতে রাখুন।

মিল সবসময় হুবহু না-ও হতে পারে। দুটি ভিন্ন feature একদম সমান impurity কমালে scikit-learn feature-গুলো এলোমেলো ক্রমে দেখে (random_state সেই ক্রম ঠিক করে) আর প্রথম কঠোরভাবে ভালোটি রাখে; আমাদের কোড সবসময় কলামের ক্রমে দেখে। তখন গাছ আলাদা হতে পারে, যদিও দুটিই সমান “সঠিক”। আর scikit-learn X-কে float32 করে নেয়, তাই খুব কাছাকাছি দশমিক মানে threshold-এ সামান্য পার্থক্য আসতে পারে।

৬ · থামার নিয়ম বনাম cost-complexity pruning

সীমা ছাড়া গাছ প্রতিটি পাতা খাঁটি না হওয়া পর্যন্ত ভাগ করে; train accuracy ১০০%, কিন্তু নতুন ডেটায় খারাপ, অর্থাৎ overfitting (AI-এর ভিত্তি কোর্সের পর্ব ৬১). থামানোর দুই পথ।

আগে থামা (pre-pruning)

max_depth, min_samples_leaf, min_samples_split, max_leaf_nodes, min_impurity_decrease. সস্তা, কিন্তু অদূরদর্শী: একটি ভাগ এখন সামান্য লাভ দেয় অথচ তার নিচের ভাগগুলো দারুণ, এমন ক্ষেত্রে আগেই থেমে যায়। XOR-এর মতো ডেটায় প্রথম ভাগ প্রায় শূন্য লাভ দেয়।

পরে ছাঁটা (post-pruning)

পুরো গাছ বাড়ান, তারপর নিচ থেকে সেই শাখাগুলো কাটুন যারা জটিলতার তুলনায় কম লাভ দেয়। CART বইয়ের minimal cost-complexity pruning এটিই; scikit-learn-এ ccp_alpha.

গাছ T-এর দাম:

Rα(T) = R(T) + α · |T|

R(T) হলো পাতাগুলোর ভুল (CART বইয়ে misclassification rate; scikit-learn-এ প্রতিটি পাতার impurity গুণ সেই পাতার সারির অনুপাত, সবার যোগ), |T| পাতার সংখ্যা, আর α ≥ ০ প্রতিটি পাতার জরিমানা। α = ০ হলে পুরো গাছ সেরা; α বিশাল হলে শুধু মূল। একটি ভেতরের node t-কে পাতা বানালে R বাড়ে, কিন্তু পাতা কমে। দুটি সমান হয় যে α-তে:

αeff(t) = (R(t) − R(Tt)) / (|Tt| − ১)

এখানে R(t) হলো t একা পাতা হলে তার ভুল, Tt হলো t-এর নিচের উপগাছ। যার αeff সবচেয়ে ছোট, সে-ই weakest link: প্রতিটি বাড়তি পাতা দিয়ে সবচেয়ে কম লাভ আনে। তাকে কাটুন, আবার হিসাব করুন। এভাবে বাড়তে থাকা α-এর একটি ধারা আর ক্রমশ ছোট গাছের একটি শিকল পাওয়া যায়; Breiman ও সহলেখকেরা দেখান, প্রতিটি α-র সেরা ছাঁটা গাছ এই শিকলেরই কোনো একটি।

R = (node-এর সারি / N) × GiniN = 100 train সারি

            
  1. উপগাছ। ১০০টি train সারির গাছে একটি node t, তাতে ২০ জন। তার নিচে দুটি প্রশ্নে তিনটি পাতা A, B, C.
  2. t একা। Gini ০.৪৮; সারির অনুপাত ০.২ দিয়ে গুণ করে R(t) = ০.০৯৬.
  3. পাতাদের Gini. A প্রায় খাঁটি (০.১৮), B-তে ছয়জনে পাঁচজন churn (০.২৭৭৮), C আধাআধি (০.৫০).
  4. উপগাছের ভুল। প্রতিটি পাতার Gini তার সারির অনুপাত দিয়ে গুণ করে যোগ: ০.০৫৪৭. উপগাছ রাখলে ভুল ০.০৯৬ থেকে ০.০৫৪৭.
  5. αeff. ভুলের কমা ০.০৪১৩ ভাগ বাড়তি পাতার সংখ্যা দুই: ০.০২০৭. প্রতিটি বাড়তি পাতা গড়ে এতটুকু ভুল কমায়।
  6. সিদ্ধান্ত। scikit-learn সবচেয়ে ছোট αeff-এর node বারবার কাটে, যতক্ষণ তা ccp_alpha-এর সমান বা ছোট। ccp_alpha = ০.০১-এ এই শাখা থাকে, ০.০৩-এ কাটা পড়ে (যদি তার আগে অন্য কাটার পরও এটি টিকে থাকে)।

কোন α? এটি একটি hyperparameter, তাই train ডেটার ভেতরে cross-validation দিয়ে (AI-এর ভিত্তি কোর্সের পর্ব ৬৬). cost_complexity_pruning_path পুরো গাছের সব αeff এক ডাকে দেয়; সেগুলোই প্রার্থী। নিচের ব্লক ৪০০ জনকে ৭০/৩০ ভাগ করে, তিনটি গাছ তুলনা করে: সীমাহীন, ৫-fold CV-তে বাছা max_depth, আর CV-তে বাছা ccp_alpha. Test ভাগ শুধু শেষ মাপে।

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold
from bazar_churn import make_churn

df = make_churn(400); cols = ["days", "orders", "late", "basket"]
X_tr, X_te, y_tr, y_te = train_test_split(df[cols], df["churn"], test_size=0.3,
                                          stratify=df["churn"], random_state=0)
path = DecisionTreeClassifier(random_state=0).cost_complexity_pruning_path(X_tr, y_tr)
alphas = path.ccp_alphas[:-1]                      # শেষটি শুধু মূল (একটি পাতা)
print(f"{len(alphas)} candidate alphas, from {alphas[0]:.4f} to {alphas[-1]:.4f}")

cv = StratifiedKFold(5, shuffle=True, random_state=0)
grids = {"full tree": {"ccp_alpha": [0.0]},
         "max_depth (CV)": {"max_depth": list(range(1, 11))},
         "ccp_alpha (CV)": {"ccp_alpha": list(alphas)}}
for name, grid in grids.items():
    gs = GridSearchCV(DecisionTreeClassifier(random_state=0), grid, cv=cv).fit(X_tr, y_tr)
    m, bp = gs.best_estimator_, {k: float(round(v, 4)) if isinstance(v, float) else v
                                  for k, v in gs.best_params_.items()}
    print(f"{name:15s} {bp}  leaves {m.get_n_leaves():3d}  "
          f"train {m.score(X_tr, y_tr):.3f}  test {m.score(X_te, y_te):.3f}")

tr, te, lv = [], [], []
for a in alphas:
    m = DecisionTreeClassifier(random_state=0, ccp_alpha=a).fit(X_tr, y_tr)
    tr.append(m.score(X_tr, y_tr)); te.append(m.score(X_te, y_te)); lv.append(m.get_n_leaves())
fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.6))
a1.step(alphas, tr, where="post", label="train"); a1.step(alphas, te, where="post", label="test")
a1.set(xlabel="ccp_alpha", ylabel="accuracy", title="Pruning: accuracy")
a1.legend(fontsize=8)
a2.step(alphas, lv, where="post"); a2.set(xlabel="ccp_alpha", ylabel="leaves", title="Pruning: tree size")
fig.subplots_adjust(bottom=0.16, wspace=0.3)

আসল আউটপুট (ব্রাউজারের Python), নিচে দুটি ছবি:

26 candidate alphas, from 0.0000 to 0.0248
full tree       {'ccp_alpha': 0.0}  leaves  49  train 1.000  test 0.775
max_depth (CV)  {'max_depth': 3}  leaves   8  train 0.864  test 0.808
ccp_alpha (CV)  {'ccp_alpha': 0.0116}  leaves   8  train 0.879  test 0.825

সীমাহীন গাছ ২৮০ জন train গ্রাহকের জন্য ৪৯টি পাতা বানিয়েছে, train-এ ১.০০০, test-এ ০.৭৭৫: মুখস্থের চেনা চেহারা। CV বেছেছে max_depth=3 (৮ পাতা, test ০.৮০৮) আর ccp_alpha ≈ ০.০১১৬ (তাও ৮ পাতা, কিন্তু অন্য আকারের গাছ, test ০.৮২৫). বাঁয়ের ছবিতে α বাড়লে train accuracy সিঁড়ির মতো নামে, test প্রথমে ওঠে, লম্বা সমতল থাকে, তারপর α ≈ ০.০২২-এর পরে খুব ছোট গাছে পড়ে যায়; ডানের ছবিতে পাতা ৪৯ থেকে ২-এ নামে। সতর্কতা: test-এ মাত্র ১২০ জন, তাই ০.৮০৮ আর ০.৮২৫-এর পার্থক্য দুজন গ্রাহক। এই একটি split থেকে “pruning depth-এর চেয়ে ভালো” বলা যায় না; সৎ তুলনা চাইলে কয়েকটি seed বা repeated CV আর পর্ব ২২-এর paired test. যা বলা যায়: দুটি পথই মুখস্থ গাছের চেয়ে স্পষ্ট ভালো, আর pruning পাতা-প্রতি বিচার করে বলে একপেশে গাছ রাখতে পারে, যা max_depth পারে না।

৭ · গাছ কেন অস্থির — bootstrap, সিঁড়ির সীমা আর missing value

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

এটি greedy আর hierarchical গঠনের ফল: ওপরের একটি ছোট পরিবর্তন নিচে ছড়িয়ে পড়ে। মাপতে পর্ব ২১-এর bootstrap: একই ৪০০ জন থেকে ফেরত রেখে ৪০০ জন টেনে নতুন গাছ, ছয়বার। ছবির জন্য শুধু দুটি feature, days আর orders. ব্লকের শেষ অংশ ১৫% গ্রাহকের days মুছে দিয়ে দেখে, গাছ NaN নিজে সামলায় কি না।

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeClassifier
from bazar_churn import make_churn

df = make_churn(400); cols = ["days", "orders"]       # শুধু দুটি feature, ছবির জন্য
X, y = df[cols].to_numpy(float), df["churn"].to_numpy()
rng = np.random.default_rng(0)
gx, gy = np.meshgrid(np.linspace(0, 92, 200), np.linspace(-0.5, 13.5, 200))
grid = np.c_[gx.ravel(), gy.ravel()]
fig, axes = plt.subplots(1, 3, figsize=(10, 3.4), sharey=True)
preds = []
for b in range(6):
    idx = rng.integers(0, len(y), len(y))             # bootstrap: ফেরত রেখে টানা
    t = DecisionTreeClassifier(max_depth=4, random_state=0).fit(X[idx], y[idx])
    f, th = t.tree_.feature, t.tree_.threshold
    print(f"sample {b}: root {cols[f[0]]} <= {th[0]:5.1f}  left child: "
          f"{cols[f[1]]} <= {th[1]:4.1f}, right child: {cols[f[t.tree_.children_right[0]]]}")
    preds.append(t.predict(grid))
    if b < 3:
        ax = axes[b]; ax.contourf(gx, gy, preds[-1].reshape(gx.shape), alpha=0.3, cmap="coolwarm")
        ax.scatter(X[:, 0], X[:, 1], c=y, s=6, cmap="coolwarm")
        ax.set(title=f"bootstrap tree {b}", xlabel="days since last order")
axes[0].set_ylabel("orders in 6 months"); fig.subplots_adjust(bottom=0.17)
P = np.array(preds)
print(f"grid points where the 6 trees disagree: {np.mean(P.min(0) != P.max(0)):.0%}")

# missing value: 15% 'days' খালি, scikit-learn-এর tree নিজেই সামলায়
Xm = X.copy(); Xm[rng.random(len(y)) < 0.15, 0] = np.nan
tm = DecisionTreeClassifier(max_depth=3, random_state=0).fit(Xm, y)
go_left = tm.tree_.missing_go_to_left[0]
print(f"NaN in days: {np.isnan(Xm[:, 0]).sum()} rows; root {cols[tm.tree_.feature[0]]} <= "
      f"{tm.tree_.threshold[0]:.1f}, NaN rows go {'left' if go_left else 'right'}")
print("prediction for [NaN, 2]:", tm.predict([[np.nan, 2.0]]))

আসল আউটপুট (ব্রাউজারের Python), নিচে প্রথম তিনটি গাছের সীমা:

sample 0: root days <=  37.5  left child: days <= 17.5, right child: orders
sample 1: root days <=  55.5  left child: days <= 40.5, right child: days
sample 2: root days <=  63.5  left child: days <= 29.5, right child: orders
sample 3: root days <=  33.5  left child: days <= 19.5, right child: orders
sample 4: root days <=  53.5  left child: days <= 33.5, right child: orders
sample 5: root days <=  63.5  left child: days <= 24.5, right child: orders
grid points where the 6 trees disagree: 35%
NaN in days: 68 rows; root days <= 37.5, NaN rows go left
prediction for [NaN, 2]: [0]

অস্থিরতা: ছয়টি গাছের মূল সবসময় days, কিন্তু threshold ৩৩.৫ থেকে ৬৩.৫ পর্যন্ত ঘোরে; ৩০ দিনের ফারাক মানে “নিষ্ক্রিয় গ্রাহক”-এর সংজ্ঞাই আলাদা। একটি গাছে মূলের ডান সন্তানের প্রশ্ন days, বাকিদের orders. সমতলের ৩৫% বিন্দুতে ছয়টি গাছ একমত নয়। এটিই উচ্চ variance: একই উৎসের ডেটা, অনেক আলাদা model. পর্ব ৩৩ (Bagging আর random forest) এই দুর্বলতাকেই শক্তিতে বদলাবে: অনেক অস্থির গাছের গড় নিলে variance কমে।

সিঁড়ির সীমা: ছবিতে প্রতিটি সীমা আনুভূমিক বা উল্লম্ব রেখার টুকরো, কারণ প্রতিটি প্রশ্ন একটি feature দেখে (axis-aligned split). আমাদের ডেটা-নিয়মে churn-এর আসল সীমা days আর orders-এ একটি তির্যক রেখা (score-এ দুটির রৈখিক যোগ); গাছ সেটি সিঁড়ি দিয়ে আন্দাজ করে, আর প্রতিটি গাছের সিঁড়ি আলাদা জায়গায়। Logistic regression এক রেখায় যা পারে, গাছের তার জন্য অনেক পাতা লাগে। প্রয়োজনে “days − ১০ × orders”-এর মতো feature হাতে বানানো (AI-এর ভিত্তি কোর্সের পর্ব ৬৩) গাছকে একটি প্রশ্নেই সেই রেখা দেয়।

Missing value: ৪০০ জনের ৬৮ জনের days খালি, তবু কোনো error বা warning নেই। মূল প্রশ্ন একই রকম (days <= 37.5), আর গাছ শিখেছে NaN সারিগুলো বাঁয়ে পাঠাতে, অর্থাৎ “সাম্প্রতিক” দলে। [NaN, 2]-এর অনুমান ০ (থাকবেন)। এই দিকটি ডেটা দেখে শেখা, নিচে ব্যাখ্যা।

৮ · Missing value, categorical feature আর ID3 থেকে CART

scikit-learn-এ missing value. scikit-learn-এর docs অনুযায়ী DecisionTreeClassifier আর DecisionTreeRegressor splitter="best"-এ NaN সরাসরি সামলায় (এই সুবিধা এসেছে 1.3 সংস্করণে, পরে random splitter আর forest-এও; নিজের version-এর docs মিলিয়ে নিন)। পদ্ধতি সরল: প্রতিটি সম্ভাব্য split-এ NaN সারিগুলো একবার বাঁয়ে আর একবার ডানে রেখে impurity দেখা, যেটি ভালো সেটি রাখা; সেই দিক tree_.missing_go_to_left-এ জমা থাকে। Train-এ কোনো feature-এ NaN না থাকলে prediction-এর সময় সেই feature-এ NaN এলে সারিটি যে সন্তানে বেশি সারি, সেখানে যায়। Criterion হতে হবে "gini", "entropy", "log_loss" (classification) বা "squared_error", "friedman_mse", "poisson" (regression-এর ক্ষেত্রে)।

আরেকটি বড় সুবিধা: “খালি” নিজেই তথ্য হতে পারে। যে গ্রাহক কখনো ঠিকানা দেননি, তিনি হয়তো কম আগ্রহী। গড় দিয়ে ভরাট (imputation) করলে সেই সংকেত হারায়; গাছ NaN-কে নিজের দিকে পাঠিয়ে তা রাখতে পারে। তবে imputation করলে তা pipeline-এর ভেতরে, শুধু train-এ শিখে (AI-এর ভিত্তি কোর্সের পর্ব ৫৬-এর leakage-এর সতর্কতা)।

পুরোনো সমাধান। CART বই NaN-এর জন্য surrogate split প্রস্তাব করে: মূল প্রশ্নের feature না থাকলে অন্য একটি feature-এর প্রশ্ন, যা মূল ভাগকে সবচেয়ে ভালো নকল করে। R-এর rpart এটি করে; scikit-learn করে না। Quinlan-এর C4.5 অজানা মানের সারিকে ভগ্নাংশ ওজনে সব শাখায় পাঠায়।

Categorical feature. scikit-learn-এর docs সোজাসুজি বলে, তাদের গাছ এখনো categorical কলাম সরাসরি নেয় না; সংখ্যায় রূপান্তর করে দিতে হয়। বিকল্পগুলোর দাম আলাদা:

পদ্ধতিগাছে কী হয়কখন
One-hotপ্রতিটি প্রশ্ন “জেলা = সিলেট কি না”; একটি বিভাগ বনাম বাকি সব। বহু বিভাগে গাছ গভীর আর পাতলা হয়অল্প বিভাগ
Ordinal সংখ্যা“জেলা-কোড ≤ ৭.৫” একটি এলোমেলো ক্রমের ভাগ; ক্রমের অর্থ না থাকলেও গাছ কয়েকটি প্রশ্নে ভাগগুলো আলাদা করতে পারেঅনেক বিভাগ, গভীর গাছ বা ensemble
Target encodingপ্রতিটি বিভাগের গড় churn দিয়ে বদল; শক্তিশালী, কিন্তু leakage-এর ঝুঁকি, তাই cross-fitting লাগেখুব বেশি বিভাগ
নিজস্ব categorical splitবিভাগগুলোকে দুই দলে ভাগ; দুই শ্রেণিতে বিভাগগুলো churn-হার দিয়ে সাজালে সেরা ভাগ খোঁজা দ্রুত হয় (CART বইয়ের ফল)scikit-learn-এর HistGradientBoosting*, LightGBM; পর্ব ৩৬-এ

ইতিহাস, সংক্ষেপে। সামাজিক বিজ্ঞানের ডেটায় ১৯৬০-এর দশকে James Morgan আর John Sonquist-এর AID-কে প্রায়ই প্রথম regression tree বলা হয়। পরিসংখ্যানে Leo Breiman, Jerome Friedman, Richard Olshen আর Charles Stone-এর বই Classification and Regression Trees (১৯৮৪) binary split, Gini, cost-complexity pruning আর surrogate split একসঙ্গে দাঁড় করায়। প্রায় একই সময়ে machine learning-এ Ross Quinlan-এর ID3 (প্রবন্ধ ১৯৮৬, আগের কাজ ১৯৭০-এর দশকের শেষে) information gain দিয়ে বহু-শাখার গাছ বানায়, আর তার উত্তরসূরি C4.5 (বই ১৯৯৩) সংখ্যার feature, অজানা মান আর pruning যোগ করে; C5.0 তার বাণিজ্যিক পরবর্তী রূপ। দুই সম্প্রদায় মোটামুটি স্বাধীনভাবে একই ধারণায় পৌঁছায়। scikit-learn-এর docs নিজেদের পদ্ধতিকে CART-এর একটি optimized রূপ বলে।

আরও গভীরে: Greedy-র বদলে পুরো গাছ একসঙ্গে খোঁজার চেষ্টা আবার ফিরছে: Dimitris Bertsimas আর Jack Dunn (Machine Learning, ২০১৭) mixed-integer optimization দিয়ে “optimal classification tree” খোঁজেন, আর পরে Cynthia Rudin-এর দলসহ অনেকে dynamic programming আর branch-and-bound দিয়ে ছোট, প্রমাণিতভাবে সেরা গাছ খোঁজার পদ্ধতি দেন। ছোট গভীরতায় এরা greedy-র চেয়ে কয়েক শতাংশ ভালো হতে পারে, তবে খরচ অনেক বেশি। অন্য দিকে oblique tree (প্রতিটি প্রশ্নে কয়েকটি feature-এর রৈখিক যোগ) সিঁড়ির সমস্যা কমায়, কিন্তু ব্যাখ্যা কঠিন করে।

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

  • ৪ জন churn আর ৬ জন থাকা node-এর Gini আর entropy? Gini = ১ − (০.১৬ + ০.৩৬) = ০.৪৮; entropy ≈ ০.৯৭১ bit.
  • Misclassification error দিয়ে গাছ বাড়ানো হয় না কেন? এটি কঠোরভাবে concave নয়, টুকরো টুকরো রৈখিক; অনেক কাজের split-এ error একটুও কমে না, Gini বা entropy কমে।
  • n টি আলাদা মানের সংখ্যার feature-এ কয়টি threshold পরীক্ষা হয়? n − ১টি, পাশাপাশি মানের মাঝখানে; সাজানোর পরে একবার স্ক্যানে O(n).
  • Cost-complexity pruning-এর সূত্র? Rα(T) = R(T) + α|T|; αeff(t) = (R(t) − R(Tt))/(|Tt| − ১), সবচেয়ে ছোটটি আগে কাটা; α বাছা CV দিয়ে।
  • গাছ কেন high variance? Greedy আর hierarchical: ওপরের একটি split বদলালে নিচের পুরো গাছ বদলায়; bootstrap নমুনায় মূল পর্যন্ত বদলে যায়। সমাধান bagging বা random forest.

৯ · AI-এর যুগে — গাছের কোড আর ব্যাখ্যা যাচাই

AI assistant-কে “churn-এর জন্য decision tree বানিয়ে দাও” বললে সাধারণত চলে এমন কোড আসে। ভুল থাকে সূক্ষ্ম জায়গায়, আর গাছ “পড়া যায়” বলে ভুল ব্যাখ্যাও সহজে বিশ্বাসযোগ্য মনে হয়। পরিচিত ভুলগুলো:

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
সীমা ছাড়া গাছ, train accuracy ১০০% দেখিয়ে “চমৎকার model”মুখস্থ; নতুন ডেটায় অনেক খারাপCV আর তালাবন্ধ test; পাতার সংখ্যা দেখুন
ccp_alpha বা max_depth test set দেখে বাছাTest-এ tuning মানে test আর নিরপেক্ষ নয়শুধু train-এর ভেতরে CV; test একবারই
পুরোনো argument: criterion="mse", max_features="auto", presortএগুলো বহু আগে বাদ গেছে; error দেয়নিজের version-এর docs; কোড চালিয়ে দেখুন
“scikit-learn-এর গাছ categorical কলাম সরাসরি নেয়” বা “NaN থাকলে আগে ভরাট করতেই হবে”প্রথমটি ভুল; দ্বিতীয়টি 1.3-এর পরে আর সত্য নয়docs-এর Decision Trees অংশ; ছোট ডেটায় NaN দিয়ে চালান
“মূল feature-ই churn-এর কারণ” বা impurity importance-কে কারণ বলাগাছ সম্পর্ক দেখে, কারণ নয়; মূলও bootstrap-এ বদলায়কয়েকটি bootstrap-এ গাছ তুলনা; কারণের দাবি আলাদা নকশায়

AI-কে শুধু কোড নয়, যাচাইয়ের কাজও দিন:

আমার কাছে বাজারঘরের প্রায় ২০,০০০ গ্রাহকের টেবিল আছে (scikit-learn 1.8.0): days, orders, late, basket সংখ্যার কলাম, district একটি categorical কলাম, কিছু ঘরে NaN; লক্ষ্য churn (০/১)। ১. প্রথমে stratified train/test ভাগ করো, test শেষ পর্যন্ত ছুঁয়ো না। ২. Pipeline দাও: district-এর জন্য OneHotEncoder(handle_unknown="ignore"), তারপর DecisionTreeClassifier; NaN-এর জন্য আলাদা imputation লাগবে কি না, 1.8.0-এর docs থেকে বলো। ৩. cost_complexity_pruning_path থেকে ccp_alpha প্রার্থী নিয়ে ৫-fold StratifiedKFold দিয়ে বাছো; max_depth-এর grid-এর সঙ্গে একই fold-এ তুলনা করো। ৪. প্রতিটি model-এর পাতার সংখ্যা, CV গড় ও standard deviation, আর শেষে একবার test accuracy একটি টেবিলে দাও; পাশে logistic regression baseline রাখো। ৫. পাঁচটি bootstrap নমুনায় গাছ বানিয়ে মূল split কতটা বদলায় দেখাও, আর গাছের ব্যাখ্যাকে কারণ হিসেবে উপস্থাপন কোরো না। ৬. যে argument ব্যবহার করছ তা 1.8.0-এ আছে কি না বলো; কোনো warning চাপা দিও না।

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

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

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

প্র ০১সবচেয়ে ভালো গাছ খোঁজা যদি NP-complete হয়, তাহলে greedy গাছ বাস্তবে এত ভালো কাজ করে কেন? Greedy কোথায় সত্যিই ব্যর্থ হয়?

Laurent Hyafil আর Ronald Rivest ১৯৭৬ সালে দেখান, একটি নির্দিষ্ট অর্থে সবচেয়ে কম গড় প্রশ্নের নির্ভুল binary decision tree খোঁজা NP-complete. অর্থাৎ ডেটা বড় হলে সব সম্ভাব্য গাছ পরীক্ষা করা কার্যত অসম্ভব। তাই CART আর C4.5 দুটিই greedy: প্রতিটি node-এ এখনকার সেরা প্রশ্ন, পেছনে ফিরে দেখা নেই।

তবু greedy ভালো কাজ করে তিনটি কারণে। এক, বাস্তবের বেশিরভাগ ডেটায় কয়েকটি শক্তিশালী feature থাকে, যাদের প্রভাব একা একাই দেখা যায়; প্রথম কয়েকটি প্রশ্নেই বেশিরভাগ তথ্য ধরা পড়ে। দুই, আমাদের আসল লক্ষ্য train-এ নিখুঁত গাছ নয়, নতুন ডেটায় ভালো অনুমান; সবচেয়ে ছোট নির্ভুল গাছ train-এর noise-ও নিখুঁতভাবে মুখস্থ করে, তাই সেটি খুঁজে পাওয়াও খুব কাজের হতো না। তিন, pruning আর ensemble greedy-র ভুলের অনেকটা ঢেকে দেয়: random forest আর boosting শত শত greedy গাছ মিলিয়ে একটি গাছের দুর্বলতা গড় করে।

Greedy সত্যিই ব্যর্থ হয় যেখানে দুটি feature একসঙ্গে দরকারি কিন্তু আলাদাভাবে অকেজো। XOR তার উদাহরণ: churn হয় যদি “অনেক দিন নিখোঁজ” আর “অনেক অর্ডার” ঠিক একটি সত্য হয়। প্রথম প্রশ্নে কোনো একটি feature একাই impurity প্রায় কমায় না, তাই greedy একটি এলোমেলো, প্রায়-অকাজের প্রশ্ন নেয়, বা min_impurity_decrease থাকলে একদম থেমে যায়। পরের স্তরে অবশ্য ঠিক প্রশ্ন আসতে পারে, যদি গাছকে বাড়তে দেওয়া হয়। এজন্যই আগে থামার নিয়মের চেয়ে পুরো গাছ বাড়িয়ে পরে ছাঁটা নিরাপদ।

সাম্প্রতিক কাজ, যেমন Bertsimas আর Dunn-এর optimal classification tree বা dynamic programming-ভিত্তিক পদ্ধতি, ছোট গভীরতায় সত্যিকারের সেরা গাছ খুঁজতে পারে। কয়েকটি ডেটাসেটে তারা greedy-র চেয়ে ভালো ফল দেখিয়েছে, কিন্তু খরচ অনেক বেশি আর লাভ ডেটাভেদে বদলায়; এটি এখনো সক্রিয় গবেষণার বিষয়।

মূল উপলব্ধি: Greedy একটি সচেতন আপস: গণনার অসম্ভবতা এড়িয়ে প্রায়-সমান ভালো গাছ, যার দুর্বলতা interaction-নির্ভর ডেটায়, আর যার ভুল সারায় pruning আর ensemble.

প্র ০২CART (পরিসংখ্যানবিদেরা) আর ID3/C4.5 (machine learning গবেষক) প্রায় একই সময়ে প্রায় একই ধারণায় পৌঁছায়। তাদের পার্থক্য কী, আর এই দুই ধারার মিলন আজকের ML-কে কী শিখিয়েছে?

১৯৮৪ সালে Breiman, Friedman, Olshen আর Stone-এর বই প্রকাশিত হয়। তাঁদের দৃষ্টি পরিসংখ্যানের: গাছ একটি অনুমান-যন্ত্র, যার ভুল সৎভাবে মাপতে হবে। তাই বইয়ের কেন্দ্রে binary split, classification আর regression দুটিই, cross-validation দিয়ে বাছা cost-complexity pruning, আর missing value-এর জন্য surrogate split. তাঁরা দেখান, গাছ যত বড় হয়, train-এর ভুল তত কমে, কিন্তু সেটি সৎ মাপ নয়; তাই আলাদা ডেটা বা CV দিয়ে গাছের আকার বাছতে হয়।

Ross Quinlan আসেন কৃত্রিম বুদ্ধিমত্তার দিক থেকে, যেখানে লক্ষ্য ছিল উদাহরণ থেকে মানুষের পড়ার মতো নিয়ম শেখা। তাঁর ID3 (১৯৮৬ সালের প্রবন্ধে বিস্তারিত) information gain দিয়ে categorical feature-এ বহু-শাখার গাছ বানায়। পরে দেখা যায়, information gain অনেক মানের feature-কে অন্যায্যভাবে পছন্দ করে (যেমন গ্রাহক-ID, যা প্রতিটি সারিকে আলাদা দলে ভাগ করে), তাই C4.5 gain ratio আনে। C4.5 pruning করে train ডেটার ওপর একটি নিরাশাবাদী (pessimistic) ভুলের অনুমান দিয়ে, আলাদা ডেটা ছাড়াই, আর গাছ থেকে নিয়মের তালিকাও বানায়।

পার্থক্যগুলো ব্যবহারিক: CART সবসময় দুই শাখা, ID3/C4.5 বহু শাখা; CART-এ Gini ডিফল্ট, C4.5-এ gain ratio; CART-এর pruning CV-নির্ভর, C4.5-এর পরিসংখ্যানিক অনুমান-নির্ভর। ফল সাধারণত কাছাকাছি।

বড় শিক্ষা হলো দুই ধারার মিলন। Breiman নিজে ২০০১ সালের “Statistical Modeling: The Two Cultures” প্রবন্ধে যুক্তি দেন, পরিসংখ্যানবিদদের অনুমানের নির্ভুলতাকে কেন্দ্রে আনা উচিত, যেমন machine learning করে। উল্টো দিকে ML শিখেছে পরিসংখ্যানের সততা: CV, bootstrap, অনিশ্চয়তার মাপ। Random forest (Breiman, ২০০১) আর gradient boosting (Friedman, ২০০১) এই মিলনেরই ফসল, যা পর্ব ৩৩ আর ৩৫-এ দেখবেন।

মূল উপলব্ধি: একই ধারণা দুই সম্প্রদায় থেকে এসেছে; পরিসংখ্যান দিয়েছে সৎ মূল্যায়ন আর pruning, ML দিয়েছে পড়ার মতো নিয়ম আর information-এর ভাষা, আর আধুনিক tree ensemble দুটিরই উত্তরাধিকারী।

প্র ০৩Bootstrap নমুনায় গাছের মূল split বদলে যায়। তাহলে একটি গাছের নিয়ম পড়ে “churn-এর আসল কারণ” বলা কতটা ঠিক? Breiman-এর “Rashomon effect” এখানে কী বলে?

অংশ ৭-এর ব্লকে ছয়টি bootstrap গাছের মূল threshold ৩৩.৫ থেকে ৬৩.৫ পর্যন্ত ঘুরেছে, আর সমতলের ৩৫% বিন্দুতে গাছগুলো একমত হয়নি। অথচ প্রতিটি গাছ একই উৎসের ডেটা থেকে, আর প্রতিটির accuracy কাছাকাছি। তাহলে কোন গাছের নিয়ম “সত্য”?

Leo Breiman ২০০১ সালের “Two Cultures” প্রবন্ধে এই ঘটনাকে Rashomon effect বলেন, আকিরা কুরোসাওয়ার চলচ্চিত্রের নামে, যেখানে একই ঘটনা চারজন সাক্ষী চারভাবে বলেন। মূল কথা: প্রায় সমান ভালো অনেক model থাকতে পারে, যারা ডেটাকে খুব আলাদা গল্পে বলে। এর আগে ১৯৯৬ সালের “Bagging predictors” প্রবন্ধে তিনি দেখান, গাছের মতো অস্থির পদ্ধতিতে অনেক bootstrap model-এর গড় নিলে নির্ভুলতা বাড়ে, ঠিক এই অস্থিরতার কারণেই।

ব্যাখ্যার জন্য এর মানে গুরুতর। প্রথমত, একটি গাছের নিয়ম ডেটার একটি সম্ভাব্য বর্ণনা, একমাত্র নয়। “৪৫ দিন” সংখ্যাটির ওপর ব্যবসার নীতি বানালে, আরেকটি নমুনায় তা “৬০ দিন” হতে পারত। দ্বিতীয়ত, গাছ সম্পর্ক দেখে, কারণ নয়। দেরির ডেলিভারি churn-এর সঙ্গে জড়িত, কিন্তু হয়তো দুটিরই কারণ একটি দূরের জেলা, যেখানে courier দুর্বল আর প্রতিযোগী দোকান শক্তিশালী। কারণ জানতে পরীক্ষা (যেমন কিছু গ্রাহককে দ্রুত ডেলিভারি দিয়ে তুলনা) বা কারণ-নির্ণয়ের নকশা লাগে; সে বিষয় এই কোর্সের পরের মডিউলে আসবে।

বাস্তব অভ্যাস তাই: গাছ বানিয়ে কয়েকটি bootstrap বা CV fold-এ মূল আর প্রথম স্তরের split কতটা স্থির দেখুন; থ্রেশহোল্ডের পরিসর জানান, একক সংখ্যা নয়; আর “এই feature গুরুত্বপূর্ণ” বলার আগে permutation importance-এর মতো স্থিতিশীল মাপ ব্যবহার করুন (পর্ব ৩৩-এ)। ব্যাখ্যার আরও গভীর পদ্ধতি এই কোর্সের ব্যাখ্যা ও ন্যায্যতার মডিউলে আসবে।

মূল উপলব্ধি: একটি গাছ পড়া যায় বলেই তার গল্প সত্য নয়; প্রায় সমান ভালো অনেক গাছ ভিন্ন গল্প বলে, তাই স্থিতিশীলতা মাপা আর সম্পর্ক ও কারণ আলাদা রাখা জরুরি।

প্র ০৪Decision tree-কে প্রায়ই “interpretable model” বলা হয়। এই দাবি কখন সত্য আর কখন বিভ্রান্তিকর? উচ্চ-ঝুঁকির সিদ্ধান্তে গাছ কতটা ভরসার?

দুই বা তিন স্তরের গাছ সত্যিই পড়া যায়: ব্যবস্থাপক নিজে আঙুল দিয়ে একটি গ্রাহককে মূল থেকে পাতা পর্যন্ত নিয়ে যেতে পারেন, আর প্রতিটি সিদ্ধান্তের কারণ দেখাতে পারেন। চিকিৎসায় অনেক পুরোনো স্কোরিং নিয়ম এমন ছোট গাছ বা তালিকার আকারে লেখা হয়েছে বলে জানা যায়, কারণ ডাক্তার বিছানার পাশে হিসাব করতে পারেন।

কিন্তু “interpretable” গুণটি গাছের আকারের ওপর নির্ভর করে, পদ্ধতির নামের ওপর নয়। অংশ ৩-এর সীমাহীন গাছে ৭৫,৭৬৮টি পাতা; কোনো মানুষ তা পড়তে পারে না। অংশ ৬-এর ৪৯ পাতার গাছও কার্যত অপঠ্য। আর random forest বা boosting শত শত গাছের মিশ্রণ, যা একক গাছের পঠনযোগ্যতা পুরো হারায়।

দ্বিতীয় সমস্যা অস্থিরতা (প্রশ্ন ০৩): পড়া যায় এমন গাছও bootstrap-এ বদলায়, তাই পাঠক একটি আকস্মিক গঠনকে নিয়ম ভাবতে পারেন। তৃতীয়, সিঁড়ির সীমা: একটি তির্যক সম্পর্ককে গাছ অনেক ধাপে ভাঙে, যা পড়লে মনে হয় জটিল নিয়ম, অথচ আসল সম্পর্ক সরল।

Cynthia Rudin ২০১৯ সালে Nature Machine Intelligence-এ যুক্তি দেন, উচ্চ-ঝুঁকির সিদ্ধান্তে (জামিন, ঋণ, চিকিৎসা) black-box model বানিয়ে পরে তার ব্যাখ্যা খোঁজার বদলে শুরু থেকেই পঠনযোগ্য model বানানো উচিত, আর অনেক tabular সমস্যায় ছোট, ভালোভাবে খোঁজা model প্রায় সমান নির্ভুল হতে পারে। এই অবস্থান প্রভাবশালী, তবে সর্বজনীন নয়: কিছু সমস্যায় জটিল model-এর নির্ভুলতার লাভ বড়, আর তখন আপস করতে হয়।

ব্যবহারিক নিয়ম: পঠনযোগ্যতা দাবি করলে গাছ ছোট রাখুন (max_leaf_nodes বা pruning), CV দিয়ে দেখুন বড় model-এর তুলনায় কতটা নির্ভুলতা ছাড়ছেন, আর স্থিতিশীলতা মাপুন।

মূল উপলব্ধি: গাছ তখনই interpretable যখন ছোট আর স্থির; বড়, অস্থির বা ensemble গাছের ক্ষেত্রে এই দাবি বিভ্রান্তিকর, আর উচ্চ-ঝুঁকির কাজে ছোট পঠনযোগ্য model-এর দাম মেপে সিদ্ধান্ত নেওয়া উচিত।

প্র ০৫Missing value সামলানোর তিনটি পথ: surrogate split (CART), ভগ্নাংশ ওজন (C4.5) আর NaN-এর জন্য শেখা দিক (scikit-learn). কোনটি কখন ভালো, আর “খালি ঘর” নিজে কখন তথ্য?

পরিসংখ্যানে Donald Rubin (১৯৭৬) missing ডেটাকে তিন ভাগে ভাবতে শিখিয়েছেন: সম্পূর্ণ এলোমেলোভাবে হারানো (MCAR), অন্য দেখা feature-এর ওপর নির্ভর করে হারানো (MAR), আর হারানো মানের নিজের ওপর নির্ভর করে হারানো (MNAR). বাস্তবে কোনটি ঘটছে, তা ডেটা থেকে পুরো প্রমাণ করা যায় না, কিন্তু এই ভাবনা পদ্ধতি বাছতে সাহায্য করে।

Surrogate split (CART, R-এর rpart): মূল প্রশ্নের feature না থাকলে অন্য একটি feature-এর প্রশ্ন, যা মূল ভাগকে সবচেয়ে ভালো নকল করে। “days” না থাকলে হয়তো “orders ≤ ২” প্রায় একই ভাগ দেয়। এটি MAR-ধরনের ক্ষেত্রে যুক্তিসঙ্গত, যেখানে অন্য feature হারানো মানের তথ্য বহন করে। দাম: প্রতিটি node-এ বাড়তি হিসাব, আর ব্যাখ্যা জটিল।

ভগ্নাংশ ওজন (C4.5): অজানা মানের সারিকে প্রতিটি শাখায় সেই শাখার সারির অনুপাতে ওজন দিয়ে পাঠানো, আর prediction-এ শাখাগুলোর ফল সেই ওজনে মেলানো। এটি একধরনের গড়, যা অনিশ্চয়তাকে সৎভাবে ধরে, তবে হারানোর পেছনের সংকেত ব্যবহার করে না।

শেখা দিক (scikit-learn 1.3 থেকে, আর XGBoost, LightGBM, HistGradientBoosting-এও এরকম): প্রতিটি split-এ NaN কোন দিকে গেলে impurity বেশি কমে, তা দেখে দিক ঠিক করা। এটি সস্তা আর MNAR-এ বিশেষ কাজের: যদি নতুন গ্রাহকদের “days” সিস্টেমে প্রায়ই খালি থাকে, তাহলে খালি ঘর নিজেই “নতুন গ্রাহক” সংকেত, আর গাছ তা ধরে। ঝুঁকিও এখানে: production-এ যদি অন্য কারণে খালি ঘর বাড়ে (যেমন একটি ডেটা পাইপলাইন ভেঙে গেল), গাছ সেই সারিগুলোকে পুরোনো অর্থে পাঠাবে, আর ভুল নিঃশব্দে বাড়বে।

তাই অভ্যাস: missing-এর হার train আর production-এ নজরে রাখুন, প্রয়োজনে একটি “missing indicator” কলাম যোগ করে সংকেতটি স্পষ্ট করুন, আর imputation করলে pipeline-এর ভেতরে।

মূল উপলব্ধি: কোনো একটি পদ্ধতি সবসময় সেরা নয়; খালি ঘর কেন খালি, সেই প্রশ্নই ঠিক করে surrogate, ভগ্নাংশ ওজন না শেখা দিক, আর production-এ missing-এর ধরন বদলালে model-ও নিঃশব্দে ভুল করে।

অনুশীলন

হিসাবগুলো খাতায় করুন, তারপর উত্তর মেলান। চাইলে অংশ ২, ৫ আর ৬-এর ব্লক বদলে যাচাই করুন।

  1. পূর্ণ numerical: একটি node-এ ১২ জন গ্রাহক, ৬ জন churn. প্রশ্ন “late ≤ ০.৫?” বাঁয়ে দেয় ৮ জন (২ জন churn), ডানে ৪ জন, চারজনই চলে গেছেন। (ক) মূলের Gini আর entropy. (খ) split-এর ওজনসহ Gini আর Gini decrease. (গ) Information gain. (ঘ) N = ৬০ train সারির গাছে এই node-কে উপগাছ ধরুন, যার পাতা এই দুটি সন্তান। scikit-learn-এর R দিয়ে αeff কত?

    (ক) p = ০.৫: Gini = ১ − (০.২৫ + ০.২৫) = ০.৫; entropy = ১ bit.

    (খ) বাঁ: p = ০.২৫, Gini = ১ − (০.০৬২৫ + ০.৫৬২৫) = ০.৩৭৫. ডান খাঁটি, Gini ০. ওজনসহ = (৮/১২) × ০.৩৭৫ = ০.২৫; decrease = ০.৫ − ০.২৫ = ০.২৫.

    (গ) বাঁয়ের entropy = −(০.২৫ log₂০.২৫ + ০.৭৫ log₂০.৭৫) = ০.৫ + ০.৩১১৩ = ০.৮১১৩; ডান ০. ওজনসহ = (৮/১২) × ০.৮১১৩ = ০.৫৪০৯; gain = ১ − ০.৫৪০৯ = ০.৪৫৯১ bit.

    (ঘ) R(t) = (১২/৬০) × ০.৫ = ০.১০. R(Tt) = (৮/৬০) × ০.৩৭৫ + ০ = ০.০৫. αeff = (০.১০ − ০.০৫)/(২ − ১) = ০.০৫. ccp_alpha ০.০৫ বা বেশি হলে (আর এটি তখন সবচেয়ে দুর্বল শাখা হলে) এই split কাটা পড়বে।

  2. সমালোচনা ও নকশা: একটি AI assistant এই পরিকল্পনা দিয়েছে: “বাজারঘরের churn-এর জন্য DecisionTreeClassifier() ডিফল্টে শেখাব; train accuracy ১০০% এসেছে, তাই model তৈরি। জেলার নাম LabelEncoder দিয়ে ১ থেকে ৬৪ বানাব, কারণ গাছ categorical সরাসরি নেয়। NaN-ওয়ালা সারি বাদ দেব। তারপর test set-এ ১০টি max_depth চেষ্টা করে সেরাটি রাখব, আর মূল split-কে churn-এর প্রধান কারণ হিসেবে রিপোর্ট করব।” কী কী সমস্যা, আর সঠিক নকশা কী?

    সমস্যা: (১) ডিফল্ট গাছ সীমাহীন; train ১০০% মানে মুখস্থ, কিছুই প্রমাণ করে না। (২) “গাছ categorical সরাসরি নেয়” scikit-learn-এ ভুল; LabelEncoder লক্ষ্যের জন্য বানানো, feature-এর জন্য OrdinalEncoder. Ordinal কোড গাছে চলে, কিন্তু ক্রমটি এলোমেলো, তাই অল্প বিভাগে one-hot ভালো হতে পারে; দুটিই CV-তে তুলনা করুন। (৩) NaN সারি বাদ দিলে তথ্য হারায় আর নমুনা পক্ষপাতী হতে পারে; 1.3-এর পর থেকে গাছ NaN নিজে সামলায়, আর খালি ঘর নিজে সংকেত হতে পারে। (৪) Test set-এ max_depth বাছা মানে test-এ tuning; শেষ মাপ আর নিরপেক্ষ নয়। (৫) মূল split কারণ নয়, সম্পর্ক; আর bootstrap-এ বদলাতে পারে।

    সঠিক নকশা: Stratified train/test ভাগ, test তালাবন্ধ। Pipeline(ColumnTransformer(OneHotEncoder(handle_unknown="ignore") জেলার জন্য, বাকি সংখ্যা যেমন আছে), DecisionTreeClassifier). cost_complexity_pruning_path থেকে ccp_alpha প্রার্থী আর max_depth-এর grid, একই StratifiedKFold-এ CV. পাশে logistic regression baseline. কয়েকটি bootstrap-এ মূল split-এর স্থিতি দেখা। শেষে test-এ একবার। রিপোর্টে “সম্পর্ক”, “কারণ” নয়।

  3. আউটপুট অনুমান করুন: চালানোর আগে লিখে ফেলুন নিচের কোড কোন তিনটি লাইন ছাপবে। (সংকেত: মাঝখান, পাতার সংখ্যা, আর train-এ NaN না থাকলে NaN কোথায় যায়।)
    import numpy as np
    from sklearn.tree import DecisionTreeClassifier
    X = np.array([[1.0], [2.0], [3.0], [4.0], [5.0]])
    y = np.array([0, 0, 0, 1, 1])
    t = DecisionTreeClassifier(random_state=0).fit(X, y)
    print(t.tree_.threshold[0], t.get_n_leaves(), t.get_depth())
    print(t.predict([[3.4], [np.nan]]))
    print(t.tree_.impurity.round(3))

    নমুনা নয়, আসল আউটপুট (scikit-learn 1.8.0, ডেস্কটপে চালানো):

    3.5 2 1
    [0 0]
    [0.48 0.   0.  ]

    ৩ আর ৪-এর মাঝখানে ৩.৫-এ কাটলে দুটি খাঁটি দল, তাই একটি প্রশ্নেই শেষ: পাতা ২, গভীরতা ১. ৩.৪ ≤ ৩.৫, তাই বাঁয়ে, শ্রেণি ০. Train-এ কোনো NaN ছিল না, তাই NaN যায় বেশি সারির সন্তানে: বাঁয়ে তিনটি, ডানে দুটি, তাই বাঁ, শ্রেণি ০. মূলের Gini = ১ − (০.৬² + ০.৪²) = ০.৪৮, দুটি পাতা খাঁটি, ০.

  4. কোড লেখা: অংশ ৫-এর grow ফাংশনকে regression tree বানান: Gini-র বদলে squared error-এর যোগ, পাতায় গড়। তারপর make_churn(300)-এর days, orders, late দিয়ে basket অনুমান করুন আর DecisionTreeRegressor(max_depth=3, min_samples_leaf=5)-এর সঙ্গে splits মিলিয়ে দেখুন। কী আশা করেন, আর কেন?

    দুটি লাইন বদলান: def sse(y): return ((y - y.mean())**2).sum(), আর লুপে w = sse(ys[:i]) + sse(ys[i:]) (n দিয়ে ভাগ না করলেও ক্রম একই)। থামার শর্তে gini(y) == 0-এর বদলে sse(y) == 0, পাতায় {"v": y.mean()}, আর predict ফেরত দেবে node["v"]. আমাদের একটি ডেস্কটপ run-এ (scikit-learn 1.8.0) সাতটি split-ই হুবহু মিলেছে, প্রথমটি orders <= 5.5. খুব কাছাকাছি দুটি split-এ float-এর গোলমালে কখনো পার্থক্য হতে পারে; তখন দুটির SSE প্রায় সমান কি না দেখুন।

    আশা: আমাদের ডেটা-নিয়মে basket অন্য কোনো feature-এর ওপর নির্ভর করে না, তাই গাছ যা পায় তা noise. Train-এ SSE কিছুটা কমবে (গাছ সবসময় কিছু একটা ভাগ খুঁজে পায়), কিন্তু CV-তে R² প্রায় শূন্য বা ঋণাত্মক হবে। সেই run-এ train R² ছিল ০.০৯, আর ৫-fold CV-র পাঁচটি R² −০.২৫৬ থেকে ০.০৩৪. এটি একটি ভালো সতর্কতা: সম্পর্ক না থাকলেও গাছ আত্মবিশ্বাসী নিয়ম বানায়, তাই CV ছাড়া কোনো গাছের নিয়ম বিশ্বাস করবেন না।

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

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

ক্রমএকটি CART classifier বানিয়ে ছেঁটে মূল্যায়ন করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ডেটা train আর test-এ ভাগ করা, test তালাবন্ধ রাখা
  2. প্রতিটি node-এ প্রতিটি feature সাজিয়ে পাশাপাশি মানের মাঝখানে threshold পরীক্ষা করা
  3. ওজনসহ Gini সবচেয়ে কম যে প্রশ্নে, সেটি নিয়ে দুই সন্তানে একই কাজ আবার করা
  4. পুরো গাছ থেকে cost_complexity_pruning_path দিয়ে α-এর প্রার্থী তালিকা নেওয়া
  5. Train-এর ভেতরে cross-validation দিয়ে ccp_alpha বাছা
  6. বাছা α-তে ছাঁটা গাছ তালাবন্ধ test-এ একবার মাপা

একটি node-এ ১০ জন গ্রাহক: ৩ জন churn, ৭ জন থাকেন। Node-এর Gini impurity কত?

  • ০.৩০
  • ০.৫৮
  • ০.৪২
  • ০.৮৮১

৮০০ জনের node (৪০০ বনাম ৪০০)। Split A: (৩০০, ১০০) আর (১০০, ৩০০); Split B: (২০০, ৪০০) আর (২০০, ০)। কোন কথাটি সঠিক?

  • Gini আর misclassification error দুটিই A আর B-কে সমান বলে
  • Error দুটিকে সমান বলে, Gini B-কে ভালো বলে
  • Gini A-কে ভালো বলে, কারণ দুই দল সমান আকারের
  • Error B-কে ভালো বলে, Gini A-কে

একটি সংখ্যার feature-এ ৯টি আলাদা মান আছে (কিছু মান একাধিক সারিতে)। CART-এর একটি node-এ এই feature-এর জন্য কয়টি আলাদা threshold পরীক্ষা করা দরকার?

  • সারির সংখ্যার চেয়ে একটি কম
  • ৯টি, প্রতিটি মানে একটি
  • অসীম, তাই এলোমেলো নমুনা নিতে হয়
  • ৮টি, পাশাপাশি আলাদা মানের মাঝখানে

একটি node t একা পাতা হলে R(t) = ০.০৮; তার নিচের উপগাছে ৪টি পাতা, R(Tt) = ০.০২. Minimal cost-complexity pruning-এ αeff(t) কত?

  • ০.০২
  • ০.০৬
  • ০.০১৫
  • ০.০৩

ছয়টি ডেলিভারি, দূরত্ব (km) আর সময় (ঘণ্টা): (২, ১০), (৪, ১২), (৫, ১১), (৯, ৩০), (১২, ৩৪), (১৫, ৩২)। Squared error-এর regression tree-র প্রথম split ৭ km-এ হলে, ২০ km দূরের নতুন অর্ডারের অনুমান কত?

  • প্রায় ৪৫ ঘণ্টা, প্রবণতা ধরে বাড়িয়ে
  • ২১.৫ ঘণ্টা
  • ৩২ ঘণ্টা
  • ৩৪ ঘণ্টা

scikit-learn 1.8-এ DecisionTreeClassifier(splitter="best") আর feature-এ NaN নিয়ে কোন কথাটি সঠিক?

  • NaN থাকলে fit error দেয়, আগে imputation বাধ্যতামূলক
  • প্রতিটি split-এ NaN কোন দিকে গেলে impurity বেশি কমে তা শিখে নেয়
  • CART-এর মতো surrogate split দিয়ে অন্য feature-এর প্রশ্ন ব্যবহার করে
  • NaN-কে চুপচাপ ০ ধরে নেয়

শব্দকোষ

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

মনে রাখুন

  1. CART: greedy, ওপর থেকে নিচে, সবসময় দুই ভাগ; প্রতিটি node-এ সব feature আর সব মাঝখান-threshold, ওজনসহ impurity সবচেয়ে কম যেটিতে।
  2. Impurity: Gini ১ − ∑p², entropy −∑p log₂p (কমা = information gain), error ১ − max p; প্রথম দুটি কঠোরভাবে concave বলে গাছ বাড়াতে ভালো।
  3. Threshold আর খরচ: n টি আলাদা মানে n − ১টি মাঝখান; সাজিয়ে একবার স্ক্যান, প্রতিটি node-এ প্রায় O(p · n log n). Regression-এ SSE কমানো, পাতায় গড়, পরিসরের বাইরে যায় না।
  4. Pruning: Rα(T) = R(T) + α|T|; αeff = (R(t) − R(Tt))/(|Tt| − ১), সবচেয়ে দুর্বল আগে কাটা; ccp_alpha CV দিয়ে।
  5. সীমা আর সুবিধা: অস্থির (bootstrap-এ মূলও বদলায়), সীমা অক্ষের সমান্তরাল সিঁড়ি; scikit-learn 1.8-এর গাছ NaN সামলায় কিন্তু categorical সরাসরি নয়।

পরের পর্বে: পর্ব ৩৩ (Bagging আর random forest — variance কমানো আর OOB error): এই পর্বের অস্থির গাছগুলোকে bootstrap নমুনায় অনেকবার বানিয়ে গড় নিলে variance কেন কমে, আর random forest কীভাবে গাছগুলোকে আরও আলাদা করে।

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

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