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

Model বাছাই — cross-validation আর hyperparameter search

Model Selection — Trees and Forests, Fair Comparison with cross_validate, Grid and Random Search, Nested Cross-Validation

Taught in Bangla, this lesson compares logistic regression, decision trees, random forests and gradient boosting fairly on the same stratified folds with cross_validate. It runs GridSearchCV and RandomizedSearchCV inside a pipeline, shows why best_score_ is optimistic using nested cross-validation and pure-noise data, and ends with a written model-selection report, with real in-browser and desktop runs.

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

  • Decision tree কীভাবে Gini impurity কমিয়ে প্রথম প্রশ্নটি বাছে, আর random forest ও gradient boosting কেন অনেক গাছ একসঙ্গে ব্যবহার করে
  • cross_val_score আর cross_validate-এর তফাত, একই fold-এ একাধিক metric আর train score দিয়ে model-দের ন্যায্য তুলনা, আর repeated CV-তে জোড়া পার্থক্য পড়া
  • Pipeline-সহ GridSearchCV আর RandomizedSearchCV, cv_results_, refit, best_estimator_ আর best_score_ কেন আশাবাদী
  • Nested cross-validation দিয়ে পুরো বাছাই-পদ্ধতির সৎ নম্বর, test একবার, খরচের হিসাব, আর একটি model-selection report লেখা

মডিউল ১১-এর এই পর্ব আগের দুটি পর্বের সুতো জোড়া দেয়। পর্ব ৫৬ (Train, validation আর test — data leakage-এর ফাঁদ)-এ k-fold cross-validation, stratify, Pipeline আর “test একবার” নিয়মের পরিচয় হয়েছে। পর্ব ৬১-এ validation curve দেখে degree, k আর ridge-এর λ বাছাই করেছেন, আর দেখেছেন বারবার বাছাই করলে validation-ও overfit হয়। এখানে সেই কাজটিই বাস্তবের মাপে: অনেক model, প্রতিটির অনেক সেটিং, সীমিত ডেটা। চালানো যায় এমন চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। চারটি ব্লক একই ডেটা ভাগ করে নেয় (data-shared), তাই পরের কোনো ব্লক আগে চালালে আগেরগুলো নিজে থেকেই আগে চলবে।

১ · বাছাইয়ের প্রশ্নটি আসলে কী — selection আর assessment

বাজারঘর (এই কোর্সের কাল্পনিক অনলাইন দোকান) জানতে চায় কোন অর্ডারের পণ্য ফেরত আসতে পারে। পাঠানোর আগে ঝুঁকিপূর্ণ অর্ডারে ফোন করে মাপ বা রং নিশ্চিত করতে চায়, তাই দরকার একটি ক্রম: কোন অর্ডার আগে। এই কাজের জন্য ঠিক মাপ ROC AUC (পর্ব ৬০): এলোমেলো একটি ফেরত-অর্ডার আর একটি না-ফেরত অর্ডার নিলে model প্রথমটিকে বেশি ঝুঁকি দেবে, তার সম্ভাবনা। ০.৫ মানে মুদ্রা টস, ১ মানে নিখুঁত ক্রম।

ডেটা পুরো বানানো: seed 42 দিয়ে ৮০০টি অর্ডার, সাতটি কলাম: দাম, ছাড়ের শতাংশ, ডেলিভারির দিন, গ্রাহকের আগের ফেরতের সংখ্যা, পণ্যের rating, পোশাক কি না, আর cash on delivery কি না। ফেরতের পেছনের নিয়মও আমাদের লেখা: ৫ দিন বা তার বেশি দেরি, কম rating-এর পোশাক, আর বড় ছাড়ের cash on delivery অর্ডারে ঝুঁকি বেশি, তার ওপর এলোমেলোতা। প্রায় ২৭% অর্ডার ফেরত।

হাতে প্রার্থী অনেক: logistic regression (পর্ব ৫৯), নানা গভীরতার decision tree, random forest, gradient boosting; আর প্রতিটির নিজস্ব নব (knob), যেমন C, গাছের গভীরতা, পাতায় ন্যূনতম অর্ডার। প্রশ্নটি দুই ভাগে ভাঙে, যা Hastie, Tibshirani আর Friedman-এর বই The Elements of Statistical Learning স্পষ্ট করে আলাদা করে:

  • Model selection: প্রার্থীদের মধ্যে কোনটি, কোন hyperparameter-সহ। এখানে আমরা তুলনা করি, আর তুলনার জন্য নম্বরগুলো আপেক্ষিকভাবে ঠিক হলেই চলে।
  • Model assessment: বেছে নেওয়া পদ্ধতি নতুন ডেটায় আসলে কেমন করবে। এখানে নম্বরটি নিজেই সৎ হতে হবে, কারণ এর ভিত্তিতে ব্যবসার সিদ্ধান্ত হবে।

একই নম্বর দিয়ে দুটি কাজ করলে বিজয়ীর নম্বর আশাবাদী হয়, কারণ সে কিছুটা ভাগ্যের জোরেও জিতেছে। তাই সবার আগে test set আলাদা: ৮০০টির ১৬০টি (২০%, stratify=y), যা শেষ ধাপ পর্যন্ত কেউ দেখবে না। বাছাইয়ের সব কাজ বাকি ৬৪০টিতে, cross-validation দিয়ে।

ক্রিকেট দল নির্বাচনের কথা ভাবুন। নির্বাচকেরা ঘরোয়া লিগের পারফরম্যান্স দেখে দল বাছেন (validation); নেটে কে কত ছক্কা মারলেন (train score) তা দিয়ে নয়, আর বিশ্বকাপ (test) খেলিয়ে দেখে তো নয়ই। ঘরোয়া লিগের শীর্ষ ব্যাটসম্যানের গড়েও খানিকটা ভাগ্য থাকে; পরের মৌসুমে গড় সাধারণত একটু নামে। “সেরা”-র নম্বর সবসময় একটু ফোলানো।

২ · প্রথম প্রার্থী: decision tree — প্রশ্ন করে করে ভাগ

এই কোর্সে decision tree এখনো পুরো শেখানো হয়নি; পর্ব ৩৮-এ শুধু একটি ভাগের information gain হিসাব করেছিলেন। এখানে প্রার্থী হিসেবে যতটুকু দরকার। একটি গাছ হ্যাঁ/না প্রশ্ন করে ডেটা ভাগ করে, যেমন “ডেলিভারির দিন ≤ ৪.৫?” প্রতিটি দলকে আবার প্রশ্ন, যতক্ষণ না থামার নিয়ম আসে। শেষের দলগুলোকে বলে leaf বা পাতা; নতুন অর্ডার একটি পাতায় পৌঁছায়, আর সেই পাতার train অর্ডারগুলোর ফেরতের অনুপাতই অনুমিত সম্ভাবনা।

প্রতিটি ধাপে গাছ সব feature আর সব সম্ভাব্য সীমা (পাশাপাশি দুটি মানের মাঝখান) চেষ্টা করে, আর সেই প্রশ্নটি বাছে যা দলগুলোকে সবচেয়ে “খাঁটি” করে। খাঁটি না হওয়ার মাপ scikit-learn-এর default-এ Gini impurity:

G = 1 − ∑k pk²

pk হলো দলে শ্রেণি k-এর অনুপাত। সব অর্ডার এক শ্রেণির হলে G = ০; দুই শ্রেণি আধাআধি হলে G = ০.৫, দুই শ্রেণির সর্বোচ্চ। ভাগের ভালো-মন্দ মাপা হয় সন্তান দলগুলোর আকার-ওজনসহ গড় G দিয়ে; মায়ের G থেকে যত বেশি কমে, প্রশ্নটি তত ভালো। criterion="entropy" দিলে পর্ব ৩৮-এর entropy আর information gain দিয়েই একই কাজ হয়; দুটির ফল প্রায়ই কাছাকাছি।

নিচের ১০টি কাল্পনিক অর্ডারে হাতে চেষ্টা করুন। দুটি feature: পোশাক কি না (১ = হ্যাঁ) আর ডেলিভারির দিন।

অর্ডার১২৩৪৫৬৭৮৯১০
পোশাক1111000001
দিন6253621534
ফেরত1110100000
Choosing the first split of a decision tree by Gini decrease ১০টি অর্ডার, ৪টি ফেরত Gini = 0.480
  1. ভাগের আগে। ১০টির ৪টি ফেরত, p = ০.৪. G = ১ − (০.১৬ + ০.৩৬) = ০.৪৮, দুই শ্রেণির সর্বোচ্চ ০.৫-এর কাছে। গাছের কাজ এমন প্রশ্ন খোঁজা যার পরে দলগুলো বেশি খাঁটি।
  2. প্রশ্ন: পোশাক? না-পোশাকের ৫টিতে ১টি ফেরত (G = ১ − ০.০৪ − ০.৬৪ = ০.৩২), পোশাকের ৫টিতে ৩টি (G = ০.৪৮). দুই দলই অর্ধেক আকার, তাই ওজনসহ গড় ০.৪০০; মায়ের ০.৪৮০ থেকে কমল ০.০৮০.
  3. প্রশ্ন: দিন ≤ ৩.৫? সংখ্যার feature-এ গাছ প্রতিটি পাশাপাশি মানের মাঝখান চেষ্টা করে: ১.৫, ২.৫, ৩.৫, ৪.৫, ৫.৫. দিন ≤ ৩.৫-এ অর্ডার আলাদা হলেও গোনা হুবহু আগের মতো (৫টিতে ১টি আর ৫টিতে ৩টি ফেরত), তাই একই ০.০৮০. (১.৫ আর ২.৫ কমায় মাত্র ০.০৩৬ আর ০.০০৪.)
  4. প্রশ্ন: দিন ≤ ৪.৫? বাঁয়ে ৬টির ১টি ফেরত (G = ১০/৩৬ = ০.২৭৮), ডানে ৪টির ৩টি (G = ০.৩৭৫). ওজনসহ ০.৬ × ০.২৭৮ + ০.৪ × ০.৩৭৫ = ০.৩১৭; কমল ০.১৬৩, এ পর্যন্ত সেরা।
  5. প্রশ্ন: দিন ≤ ৫.৫? ডানের দুটি অর্ডারই ফেরত: G = ০, একেবারে খাঁটি। বাঁয়ে ৮টির ২টি, G = ০.৩৭৫. ওজনসহ ০.৮ × ০.৩৭৫ = ০.৩০০; কমল ০.১৮০.
  6. বিজয়ী। ছয়টি প্রার্থী প্রশ্নের মধ্যে দিন ≤ ৫.৫ সবচেয়ে বেশি কমায়, তাই এটিই প্রথম প্রশ্ন। ডেস্কটপে এই ১০টি অর্ডারে DecisionTreeClassifier(max_depth=1) চালালে export_text-ও ঠিক days <= 5.50 দেখায়। পরের স্তরে বাঁয়ের ৮টি দলে একই খোঁজ আবার চলে; এই পুনরাবৃত্তিই গাছ।

গাছ প্রতিটি ধাপে শুধু সেই মুহূর্তের সেরা প্রশ্নটি নেয় (লোভী বা greedy খোঁজ); এতে সামগ্রিকভাবে সেরা গাছের নিশ্চয়তা নেই, কিন্তু খোঁজ দ্রুত আর বাস্তবে ভালো কাজ করে।

max_depth — গাছের complexity-র নব

কোনো সীমা না দিলে গাছ ততক্ষণ ভাগ করতে থাকে যতক্ষণ প্রতিটি পাতা খাঁটি না হয়, প্রায়ই প্রতি পাতায় এক-দুটি অর্ডার। তখন train ডেটা মুখস্থ: পর্ব ৬১-এর overfitting. প্রধান দুটি নব: max_depth (মূল থেকে পাতা পর্যন্ত সর্বোচ্চ কতটি প্রশ্ন) আর min_samples_leaf (প্রতিটি পাতায় অন্তত কতটি অর্ডার)। গভীরতা d হলে পাতা সর্বোচ্চ 2dটি। ছোট d মানে উচ্চ bias, বড় d মানে উচ্চ variance, ঠিক polynomial-এর degree-র মতো। বাজারঘরের ৬৪০টি train অর্ডারে গভীরতা ২-এর গাছ, সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.9.1):

~/bazarghar-ai$ python tree_text.py
|--- days <= 4.50
|   |--- fashion <= 0.50
|   |   |--- weights: [232.00, 27.00] class: 0
|   |--- fashion >  0.50
|   |   |--- weights: [101.00, 41.00] class: 0
|--- days >  4.50
|   |--- fashion <= 0.50
|   |   |--- weights: [104.00, 49.00] class: 0
|   |--- fashion >  0.50
|   |   |--- weights: [29.00, 57.00] class: 1

export_text(tree, feature_names=..., show_weights=True) গাছটি লেখায় দেখায়; weights হলো পাতার [না-ফেরত, ফেরত] গোনা। গাছ প্রথমেই আমাদের লুকোনো নিয়মের সবচেয়ে বড় অংশ ধরেছে: ৪.৫ দিনের বেশি দেরি। মূলের Gini ০.৩৯৬ থেকে প্রথম ভাগের পরে ওজনসহ ০.৩৬১. দেরির পোশাক-অর্ডারে ৮৬টির ৫৭টি ফেরত (৬৬%), দ্রুত ডেলিভারির না-পোশাকে ২৫৯টির মাত্র ২৭টি, অর্থাৎ ১০ শতাংশ। এই পাঠযোগ্যতা গাছের বড় গুণ।

৩ · অনেক গাছ একসঙ্গে — random forest আর gradient boosting

একটি গভীর গাছের সমস্যা অস্থিরতা: train ডেটার কয়েকটি সারি বদলালেই প্রথম প্রশ্ন বদলে যেতে পারে, আর তার নিচের পুরো গাছ। এটি উচ্চ variance. প্রতিকার পরিসংখ্যানের পুরোনো কৌশল: অনেক অস্থির অনুমানের গড়।

  • Bagging (bootstrap aggregating, Leo Breiman, ১৯৯৬): train ডেটা থেকে ফেরত রেখে একই আকারের অনেক bootstrap নমুনা নিন, প্রতিটিতে একটি গভীর গাছ শেখান, আর অনুমানগুলোর গড় (বা ভোট) নিন।
  • Random forest (Breiman, ২০০১, Machine Learning জার্নাল): bagging-এর সঙ্গে আরেকটি এলোমেলোতা: প্রতিটি ভাগে সব feature নয়, এলোমেলো একটি উপসেট থেকে প্রশ্ন বাছা। scikit-learn-এর RandomForestClassifier-এ default max_features="sqrt", অর্থাৎ ৭টি feature থেকে প্রতিবার ২টি, আর default গাছ ১০০টি।

দ্বিতীয় এলোমেলোতা কেন? গড় তখনই বেশি কাজে লাগে যখন গাছগুলো একে অপরের থেকে আলাদা ভুল করে। প্রতিটি গাছের অনুমানের variance σ², আর যেকোনো দুটি গাছের মধ্যে correlation ρ হলে B-টি গাছের গড়ের variance:

Var(গড়) = ρσ² + (1 − ρ)σ² / B

B বাড়লে দ্বিতীয় পদ শূন্যের দিকে যায়, কিন্তু ρσ² থেকে যায়। σ² = ০.০৪, ρ = ০.৩, B = ১০০ হলে ০.০১২ + ০.০০০২৮ = ০.০১২২৮, একটি গাছের তিন ভাগের এক ভাগেরও কম; ρ = ০.৮ হলে ০.০৩২০৮, লাভ সামান্য। feature-এর উপসেট ρ কমায়: সবচেয়ে জোরালো feature (এখানে “দিন”) সব গাছের মাথায় বসতে পারে না, তাই অনেক গাছ অন্য ধাঁচ খোঁজে।

Gradient boosting উল্টো পথে চলে। গাছগুলো ছোট (গভীরতা ৩-এর মতো) আর একটির পর একটি বানানো হয়: প্রতিটি নতুন গাছ এ পর্যন্ত সবার মিলিত অনুমানের ভুলের দিক (loss-এর gradient) শেখে, আর একটি ছোট ধাপে (learning_rate) যোগ হয়। ধারণাটির পূর্ণ রূপ Jerome Friedman-এর ২০০১ সালের প্রবন্ধে (Annals of Statistics); তার আগে Yoav Freund আর Robert Schapire-এর AdaBoost (১৯৯০-এর দশকের মাঝামাঝি) boosting-কে পরিচিত করে। scikit-learn-এর HistGradientBoostingClassifier প্রতিটি feature-কে অনধিক ২৫৫টি ভাগে (bin) ফেলে দ্রুত চলে; এর নকশা LightGBM থেকে অনুপ্রাণিত বলে scikit-learn-এর docs-এ লেখা। Random forest গড় নিয়ে variance কমায়; boosting ধাপে ধাপে bias কমায়, তাই বেশি ধাপ বা বড় learning rate-এ নিজেই overfit করতে পারে। দুটিকেই বলে ensemble: অনেক model-এর মিলিত সিদ্ধান্ত।

কঠিন রোগনির্ণয়ে মেডিকেল বোর্ড বসে। আলাদা বিশেষজ্ঞ আলাদা পরীক্ষার ফল আগে দেখলে তাঁদের ভুলগুলো কম মেলে, আর সম্মিলিত মত বেশি ভরসার: random forest. Boosting যেন খাতা দেখার কয়েক ধাপ, যেখানে প্রতিটি নতুন পরীক্ষক শুধু আগেরজনদের ভুলগুলো একটু একটু সংশোধন করেন।

আধুনিক decision tree-র দুটি ধারা: Leo Breiman, Jerome Friedman, Richard Olshen আর Charles Stone-এর ১৯৮৪ সালের বই Classification and Regression Trees (CART), আর Ross Quinlan-এর ID3 (১৯৮৬) ও C4.5; scikit-learn-এর গাছ CART-এর একটি অপটিমাইজ করা রূপ। এলোমেলো feature-উপসেটের ধারণা Tin Kam Ho-র ১৯৯০-এর দশকের কাজেও ছিল; Breiman ২০০১ সালে bagging-এর সঙ্গে মিলিয়ে আজকের random forest-এর রূপ দেন।

এই পর্বে গাছ শুধু প্রার্থী model; এদের ভেতরের গণিত, out-of-bag error আর feature importance-এর ফাঁদ পরের মেশিন লার্নিং কোর্সে।

৪ · ন্যায্য তুলনা — একই fold-এ cross_validate

পর্ব ৫৬-এ cross_val_score চালিয়েছেন: এটি একটি metric-এর k-টি fold-score-এর একটি array দেয়। তুলনার কাজে এর বড় ভাই cross_validate বেশি কাজের। এটি একটি dict দেয়: test_<metric> (প্রতিটি metric-এর জন্য), fit_time, score_time, আর চাইলে return_train_score=True দিয়ে train_<metric>, return_estimator=True দিয়ে প্রতিটি fold-এর fit করা model. একই fit থেকে কয়েকটি metric, তাই বাড়তি খরচ প্রায় নেই।

ন্যায্যতার প্রথম শর্ত: সব প্রার্থী একই fold-এ। StratifiedKFold প্রতিটি fold-এ ফেরতের অনুপাত প্রায় একই রাখে; ডেটা কোনো ক্রমে সাজানো থাকলে shuffle=True; আর random_state-এ পূর্ণসংখ্যা দিলে প্রতিবার split ডাকলে হুবহু একই ভাগ আসে, তাই একটি cv বস্তু সব model-কে দিলেই চলে। random_state=None দিলে প্রতিবার আলাদা ভাগ, তখন দুই model আলাদা প্রশ্নপত্রে পরীক্ষা দেয়। (সময়ের ডেটা বা একই গ্রাহকের অনেক অর্ডারে TimeSeriesSplit বা GroupKFold, পর্ব ৫৬.)

নিচের ব্লক ডেটা বানায়, test সরিয়ে রাখে, আর ছয়টি প্রার্থীকে একই ৫টি fold-এ মাপে: AUC আর accuracy, train আর validation দুটিই। Forest-এর ১০০টি গাছ × ৫ fold, তাই এক-দুই সেকেন্ড লাগতে পারে।

import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_validate
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier

rng = np.random.default_rng(42)              # বাজারঘরের ৮০০টি কাল্পনিক অর্ডার
n = 800
fashion = rng.random(n) < 0.35
price = np.round(rng.lognormal(7.2, 0.6, n), -1)
discount, days = rng.integers(0, 61, n), rng.integers(1, 8, n)
prev, rating = rng.poisson(0.6, n), np.round(rng.uniform(2.5, 5.0, n), 1)
cod = rng.random(n) < 0.55
z = (-3.4 + 1.0 * fashion + 0.7 * prev + 1.6 * (days >= 5) + 2.2 * (fashion & (rating < 3.5))
     + 1.8 * cod * (discount > 40) + 0.25 * np.log(price / 1300))
y = (rng.random(n) < 1 / (1 + np.exp(-z))).astype(int)          # 1 = পণ্য ফেরত এসেছে
X = pd.DataFrame({"price": price, "discount": discount, "days": days, "prev_returns": prev,
                  "rating": rating, "fashion": fashion.astype(int), "cod": cod.astype(int)})
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)  # সব model-এর জন্য একই ৫টি fold

models = {"logistic": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
          "tree d=2": DecisionTreeClassifier(max_depth=2, random_state=42),
          "tree d=4": DecisionTreeClassifier(max_depth=4, random_state=42),
          "tree full": DecisionTreeClassifier(random_state=42),
          "forest": RandomForestClassifier(100, min_samples_leaf=5, random_state=42),
          "hist gb": HistGradientBoostingClassifier(learning_rate=0.05, max_depth=3, random_state=42)}
print("train", X_tr.shape, "| test সরিয়ে রাখা", X_te.shape, "| ফেরতের হার", round(y_tr.mean(), 3))
for name, m in models.items():
    r = cross_validate(m, X_tr, y_tr, cv=cv, scoring=["roc_auc", "accuracy"], return_train_score=True)
    a = r["test_roc_auc"]
    print(f"{name:9s} train AUC {r['train_roc_auc'].mean():.3f} | val AUC {a.mean():.3f} ± {a.std():.3f}"
          f" | acc {r['test_accuracy'].mean():.3f}")

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

train (640, 7) | test সরিয়ে রাখা (160, 7) | ফেরতের হার 0.272
logistic  train AUC 0.767 | val AUC 0.752 ± 0.012 | acc 0.766
tree d=2  train AUC 0.732 | val AUC 0.723 ± 0.024 | acc 0.772
tree d=4  train AUC 0.824 | val AUC 0.702 ± 0.053 | acc 0.761
tree full train AUC 1.000 | val AUC 0.640 ± 0.066 | acc 0.717
forest    train AUC 0.954 | val AUC 0.773 ± 0.041 | acc 0.770
hist gb   train AUC 0.907 | val AUC 0.750 ± 0.032 | acc 0.761

গাছের সারি তিনটি পর্ব ৬১-এর validation curve-এর মতো পড়ুন। গভীরতা ২-এ train আর validation কাছাকাছি (০.৭৩২ আর ০.৭২৩); গভীরতা ৪-এ ফাঁক বাড়ে; পূর্ণ গাছের train AUC হুবহু ১.০০০, অথচ validation ০.৬৪০, সবার নিচে: মুখস্থ। সেই অস্থির পূর্ণ গাছগুলোকেই ১০০টি একসঙ্গে গড় করলে (প্রতি পাতায় অন্তত ৫টি অর্ডার রেখে) forest পায় ০.৭৭৩, তালিকার শীর্ষে। Logistic ০.৭৫২, আর তার ছড়ানো সবচেয়ে কম (± ০.০১২): সরল model প্রতিটি fold-এ প্রায় একই কাজ করে। HistGB ০.৭৫০.

Accuracy কলামটি দেখুন: সবচেয়ে ভালো accuracy গভীরতা ২-এর গাছের (০.৭৭২), অথচ তার AUC forest-এর চেয়ে অনেক কম। সব অর্ডারকে “ফেরত নয়” বললেই accuracy ১ − ০.২৭২ = ০.৭২৮; accuracy এখানে প্রায় কিছুই আলাদা করে না। Metric আগে ঠিক করুন, ফলাফল দেখে নয়।

গড়ের পাশে ছড়ানো, আর একই fold-এ জোড়া পার্থক্য

Forest (০.৭৭৩ ± ০.০৪১) কি logistic (০.৭৫২ ± ০.০১২)-এর চেয়ে সত্যিই ভালো? পার্থক্য ০.০২১, অথচ forest-এর fold-গুলোর ছড়ানোই ০.০৪১. পাঁচটি fold একবার ভাগ করার ফল; অন্য seed-এ ভাগ করলে গড়ও নড়বে। দুটি উপায়: (১) RepeatedStratifiedKFold দিয়ে k-fold কয়েকবার, প্রতিবার আলাদা এলোমেলো ভাগে; (২) প্রতিটি ভাগে দুই model-এর পার্থক্য দেখা, কারণ একই ভাগে দুজনই সহজ বা কঠিন প্রশ্নপত্র পায়। একে বলে জোড়া তুলনা। তিনটি প্রার্থী, ৫ fold × ৫ বার = ২৫টি একই ভাগ, সত্যিকারের run (ডেস্কটপ, scikit-learn 1.9.1):

~/bazarghar-ai$ python compare_repeated.py
logistic        AUC 0.753  sd 0.040  range 0.640-0.836
logistic+poly2  AUC 0.760  sd 0.052  range 0.638-0.874
forest          AUC 0.762  sd 0.044  range 0.642-0.859
forest - logistic       : mean +0.0090, sd 0.0272, forest ahead in 15/25 splits
forest - logistic+poly2 : mean +0.0020, sd 0.0323, forest ahead in 12/25 splits

২৫টি ভাগে forest-এর গড় নেমে ০.৭৬২; একটি ৫-fold-এর ০.৭৭৩ খানিকটা ভাগ্যবান ভাগ ছিল। Logistic-এর sd এখানে ০.০৪০, প্রথম ভাগের ০.০১২ নয়: পাঁচটি সংখ্যার sd নিজেই নড়বড়ে। “logistic+poly2” হলো দুই-দুই feature-এর গুণফল (interaction feature, যেমন পোশাক × rating) যোগ করা logistic, যা পরের অংশে grid search বেছে নেবে। Forest তার চেয়ে গড়ে মাত্র ০.০০২ এগিয়ে, ২৫টির ১২টিতে জেতে: কার্যত সমান। সাধারণ logistic-এর বিরুদ্ধে +০.০০৯, ১৫/২৫: জোরালো প্রমাণ নয়। ২৫টি সংখ্যা স্বাধীনও নয় (ভাবনার প্রশ্ন ৪)। সমান হলে সরল, দ্রুত আর ব্যাখ্যাযোগ্য model-টিকে প্রাধান্য দেওয়া যুক্তিসঙ্গত।

৫ · GridSearchCV — pipeline-এর ভেতরে সেটিং খোঁজা

প্রতিটি model-এর নব হাতে ঘুরিয়ে cross-validation চালানো ক্লান্তিকর আর ভুলপ্রবণ। GridSearchCV কাজটি স্বয়ংক্রিয় করে: আপনি প্রতিটি hyperparameter-এর কয়েকটি মান দেন, সে সব সমাহার (grid-এর প্রতিটি ঘর) একই cv-তে মাপে, গড় score অনুযায়ী ক্রম দেয়, আর সেরাটি নিয়ে পুরো train ডেটায় আবার fit করে।

Model একা নয়, পুরো pipeline-কে খোঁজে দিন, যাতে scaling-এর মতো ধাপও প্রতিটি fold-এর শুধু train অংশে fit হয় আর data leakage না ঘটে (পর্ব ৫৬)। বিস্তারিত পর্ব ৬৪ (scikit-learn pipeline — fit, transform আর leakage-মুক্ত কাজ)-এ। এখানে শুধু নামের নিয়মটি জরুরি: ধাপের নাম + দুটি underscore + parameter-এর নাম। ধাপের নাম "model" হলে তার C হলো model__C; বৈধ নামগুলোর পুরো তালিকা pipe.get_params().keys(); ভুল নাম দিলে fit-এর সময় “Invalid parameter” ধরনের ValueError.

নিচের pipeline-এ তিনটি ধাপ: PolynomialFeatures (degree 2 দিলে প্রতিটি feature-এর বর্গ আর প্রতিটি জোড়ার গুণফল যোগ হয়; ৭টি থেকে ৩৫টি কলাম), StandardScaler, আর logistic regression. খোঁজ দুটি ধাপের নবে একসঙ্গে: poly__degree ∈ {১, ২} আর model__C ∈ {০.০১, ০.১, ১, ১০} (C ছোট মানে বেশি regularization, পর্ব ৫৯ আর ৬১)। আগের ব্লকের X_tr আর cv এখানে আবার ব্যবহার হচ্ছে।

import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV

pipe = Pipeline([("poly", PolynomialFeatures(include_bias=False)),   # ধাপের নাম: poly, scale, model
                 ("scale", StandardScaler()),
                 ("model", LogisticRegression(max_iter=5000))])
grid = {"poly__degree": [1, 2],                 # ধাপের নাম + দুটি underscore + parameter
        "model__C": [0.01, 0.1, 1, 10]}
gs = GridSearchCV(pipe, grid, cv=cv, scoring="roc_auc", return_train_score=True)
gs.fit(X_tr, y_tr)              # ৮টি সমাহার × ৫ fold = ৪০টি fit, তারপর সেরাটি পুরো train-এ আরেকবার

res = pd.DataFrame(gs.cv_results_)
tab = res[["param_poly__degree", "param_model__C", "mean_train_score",
           "mean_test_score", "std_test_score", "rank_test_score"]]
tab.columns = ["degree", "C", "train", "val", "val_sd", "rank"]
print(tab.sort_values("rank").round(4).to_string(index=False))
print("best_params_:", gs.best_params_)
print("best_score_ :", round(gs.best_score_, 4))
print("best_estimator_-এর শেষ ধাপ:", gs.best_estimator_.named_steps["model"])
print("refit-এর পর predict_proba, প্রথম ৩টি অর্ডার:", gs.predict_proba(X_tr.iloc[:3])[:, 1].round(3))

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

degree     C  train    val  val_sd  rank
      2  1.00 0.8166 0.7637  0.0306     1
      2  0.10 0.7993 0.7629  0.0239     2
      2 10.00 0.8201 0.7588  0.0330     3
      2  0.01 0.7773 0.7561  0.0145     4
      1 10.00 0.7673 0.7523  0.0117     5
      1  1.00 0.7672 0.7522  0.0117     6
      1  0.10 0.7673 0.7520  0.0121     7
      1  0.01 0.7668 0.7512  0.0145     8
best_params_: {'model__C': 1, 'poly__degree': 2}
best_score_ : 0.7637
best_estimator_-এর শেষ ধাপ: LogisticRegression(C=1, max_iter=5000)
refit-এর পর predict_proba, প্রথম ৩টি অর্ডার: [0.184 0.211 0.145]

cv_results_ একটি dict: প্রতিটি key একটি কলাম (params, param_<নাম>, split0_test_score … split4_test_score, mean_test_score, std_test_score, rank_test_score, সময়), প্রতিটি সারি একটি সমাহার; DataFrame বানালেই টেবিল। তিনটি কথা পড়ুন। প্রথমত, degree 2-এর চারটিই degree 1-এর চারটির ওপরে: interaction কাজে লেগেছে, কারণ লুকোনো নিয়মেই “পোশাক এবং কম rating” আছে। দ্বিতীয়ত, degree 2-এ C বাড়ালে train AUC বাড়ে (০.৭৭৭৩ থেকে ০.৮২০১) কিন্তু validation C = ১-এর পরে নামে: পর্ব ৬১-এর regularization-এর ছবি। তৃতীয়ত, প্রথম আর দ্বিতীয়ের তফাত ০.০০০৮, অথচ val_sd ০.০২–০.০৩: “সেরা” এখানে সমানে সমানের মধ্যে একটু এগিয়ে থাকা।

এবার একটি grid search ভেতরে কী করে, তা fold ধরে দেখুন। শুধু degree 2-এর তিনটি C, সংখ্যাগুলো একই run-এর split0_test_score … থেকে। (scikit-learn আসলে সমাহার ধরে ঘোরে; প্রতিটি fit স্বাধীন, তাই ক্রমে ফল বদলায় না।)

degree 2, validation AUC, ৫ fold
C →0.1110
১
২
৩
৪
৫
গড়
sd
rank
  1. Fold ১. fold ১ বাদ রেখে বাকি চারটিতে তিনটি pipeline fit, তারপর fold ১-এ মাপা। C = ১ এগিয়ে (০.৮১২); এই fold সহজ, তিনটিই ০.৮-এর ওপরে।
  2. Fold ২. প্রতিটি pipeline আবার শূন্য থেকে fit. এই fold কঠিন: তিনটিই ০.৭৩-এর নিচে, জিতেছে C = ০.১. Fold-এর কঠিনতা সবাইকে একসঙ্গে নামায়; তাই একই fold-এ তুলনা জরুরি।
  3. Fold ৩. তিনটি কাছাকাছি (০.৭৪৬ থেকে ০.৭৫৬), C = ১ সামান্য এগিয়ে।
  4. Fold ৪. C = ০.১ এগিয়ে (০.৭৭৭)।
  5. Fold ৫. এবার C = ১০ এগিয়ে (০.৭৬৭)। তিনটি C-ই অন্তত একটি fold জিতেছে: একটি fold দেখে বাছলে উত্তর হতো এলোমেলো।
  6. গড় আর sd. ০.৭৬৩, ০.৭৬৪, ০.৭৫৯ (চার দশমিকে ০.৭৬২৯, ০.৭৬৩৭, ০.৭৫৮৮); ছড়ানো ০.০২৪ থেকে ০.০৩৩, গড়গুলোর তফাতের কয়েক গুণ।
  7. Rank আর refit. সর্বোচ্চ গড়, তাই C = ১ rank ১, আর refit=True (default) তাকে পুরো ৬৪০টিতে আবার fit করে best_estimator_ বানায়। পুরো grid-এ মোট fit: ৮ × ৫ + ১ = ৪১.

refit, best_estimator_, best_score_ — আর কেন best_score_ আশাবাদী

refit=True থাকলে gs.predict আর gs.predict_proba আসলে best_estimator_-কে ডাকে, যা পুরো train ডেটায় সেরা সেটিং দিয়ে fit করা। একাধিক metric দিলে কোনটি দিয়ে বাছা হবে বলতে হয়: refit="roc_auc".

best_score_ হলো সেরা সমাহারের mean_test_score, এখানে ০.৭৬৩৭. এটিকে model-এর প্রত্যাশিত পারফরম্যান্স বলে রিপোর্ট করা প্রচলিত ভুল। ৮টি সমাহারের প্রতিটির গড়ে কিছুটা এলোমেলোতা আছে; আমরা সবচেয়ে বড়টি বেছেছি, তাই যে কিছুটা ভাগ্যবান, সে-ই বাছাই হওয়ার সম্ভাবনা বেশি। নির্বাচনের এই পক্ষপাত (selection bias, কখনো “winner's curse” নামেও বলা হয়) বাড়ে সমাহারের সংখ্যা আর ছড়ানোর সঙ্গে। অংশ ৭-এ এর সৎ প্রতিকার।

৬ · RandomizedSearchCV — এলোমেলো খোঁজ কেন প্রায়ই ভালো

Grid-এর খরচ সমাহারের গুণফল: চারটি hyperparameter, প্রতিটির ৫টি মান, মানে ৬২৫টি সমাহার, ৫ fold-এ ৩,১২৫টি fit. RandomizedSearchCV একটি নির্দিষ্ট বাজেট (n_iter, default ১০) নেয়, আর প্রতিবার প্রতিটি hyperparameter-এর মান একটি বণ্টন থেকে এলোমেলো টানে। তালিকা দিলে তালিকা থেকে সমান সম্ভাবনায়; scipy.stats-এর একটি বণ্টন দিলে তার rvs থেকে।

C-এর মতো parameter-এর জন্য ঠিক বণ্টন loguniform(1e-3, 1e2): প্রতিটি দশ-গুণ পরিসরে (০.০০১–০.০১, ০.০১–০.১, …) সমান সম্ভাবনা। কেন? C = ০.০০১ আর ০.০০২-এর তফাত বড়, C = ৫০ আর ৫০.০০১-এর তফাত শূন্য; গুরুত্ব অনুপাতে, যোগফলে নয়। সাধারণ uniform দিয়ে ০.০০১ থেকে ১০০ টানলে C < ১ হওয়ার সম্ভাবনা মাত্র ০.৯৯৯ ÷ ৯৯.৯৯৯ ≈ ১%; loguniform-এ log(১ ÷ ০.০০১) ÷ log(১০০ ÷ ০.০০১) = ৩ ÷ ৫ = ৬০%.

আর কেন random, grid নয়? James Bergstra আর Yoshua Bengio ২০১২ সালে (Journal of Machine Learning Research) যুক্তি আর পরীক্ষা দিয়ে দেখান: অনেক সমস্যায় কয়েকটি মাত্র hyperparameter আসলে গুরুত্বপূর্ণ (তাঁদের ভাষায় কম “effective dimensionality”), কিন্তু কোনগুলো তা আগে জানা থাকে না। ৩ × ৩ grid ৯টি চেষ্টায় প্রতিটি parameter-এর মাত্র ৩টি আলাদা মান দেখে; ৯টি এলোমেলো চেষ্টা প্রতিটির প্রায় ৯টি আলাদা মান দেখে। যে parameter গুরুত্বহীন, তাতে grid-এর ৯টির ৬টি চেষ্টাই অপচয়। নিচের ব্লকে (ক) logistic pipeline-এ loguniform C, আর (খ) একটি decision tree-তে একই ৯টির বাজেটে grid বনাম random, তিনটি আলাদা seed-এ।

import numpy as np
from scipy.stats import loguniform, randint
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.tree import DecisionTreeClassifier

# (ক) C-এর জন্য log-scale-এ সমান ছড়ানো বণ্টন, pipeline একই
space = {"poly__degree": [1, 2], "model__C": loguniform(1e-3, 1e2)}
rs = RandomizedSearchCV(pipe, space, n_iter=8, cv=cv, scoring="roc_auc", random_state=0)
rs.fit(X_tr, y_tr)
tried = np.array(rs.cv_results_["param_model__C"], dtype=float)
print("random search যে ৮টি C চেষ্টা করেছে:", [f"{c:.3g}" for c in np.sort(tried)])
print("সেরা:", rs.best_params_["poly__degree"], round(rs.best_params_["model__C"], 4),
      "→", round(rs.best_score_, 4), "| grid-এর সেরা", round(gs.best_score_, 4))

# (খ) একই বাজেট, ৯টি সমাহার: grid বনাম random, একটি decision tree-তে
tree = DecisionTreeClassifier(random_state=42)
g = GridSearchCV(tree, {"max_depth": [3, 6, 9], "min_samples_leaf": [5, 20, 50]},
                 cv=cv, scoring="roc_auc").fit(X_tr, y_tr)
print(f"grid    : leaf-এর আলাদা মান 3 | সেরা {g.best_params_} → {g.best_score_:.4f}")
dist = {"max_depth": randint(2, 13), "min_samples_leaf": randint(2, 81)}
for s in range(3):
    r = RandomizedSearchCV(tree, dist, n_iter=9, cv=cv, scoring="roc_auc",
                           random_state=s).fit(X_tr, y_tr)
    k = len(set(r.cv_results_["param_min_samples_leaf"]))
    print(f"random {s}: leaf-এর আলাদা মান {k} | সেরা {r.best_params_} → {r.best_score_:.4f}")

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

random search যে ৮টি C চেষ্টা করেছে: ['0.00192', '0.0826', '0.154', '0.53', '0.555', '1.31', '11.5', '16.6']
সেরা: 2 0.5302 → 0.7639 | grid-এর সেরা 0.7637
grid    : leaf-এর আলাদা মান 3 | সেরা {'max_depth': 3, 'min_samples_leaf': 20} → 0.7462
random 0: leaf-এর আলাদা মান 9 | সেরা {'max_depth': 4, 'min_samples_leaf': 38} → 0.7506
random 1: leaf-এর আলাদা মান 9 | সেরা {'max_depth': 12, 'min_samples_leaf': 30} → 0.7586
random 2: leaf-এর আলাদা মান 9 | সেরা {'max_depth': 6, 'min_samples_leaf': 39} → 0.7527

(ক) ৮টি C ০.০০১৯২ থেকে ১৬.৬ পর্যন্ত ছড়ানো, প্রায় প্রতিটি দশ-গুণ পরিসরে একটি-দুটি। সেরা সমাহার degree 2, C ≈ ০.৫৩, score ০.৭৬৩৯: grid-এর ০.৭৬৩৭-এর প্রায় হুবহু সমান। এই সমস্যায় ১ আর ০.৫৩-এর মধ্যে কোনো আসল পার্থক্য নেই; দুটি খোঁজই একই চওড়া, চ্যাপ্টা চূড়ায় পৌঁছেছে।

(খ) এখানে তফাত স্পষ্ট: grid-এর সেরা ০.৭৪৬২, আর তিনটি random খোঁজই তার ওপরে (০.৭৫০৬, ০.৭৫৮৬, ০.৭৫২৭)। Grid min_samples_leaf-এর মাত্র তিনটি মান দেখেছে, প্রতিটি random খোঁজ ৯টি; তিন জয়ীরই leaf ৩০ থেকে ৩৯, যে অঞ্চল grid দেখেইনি, অথচ তাদের গভীরতা ৪, ৬, ১২: একেবারে আলাদা।

একই ডেটায় একটি ঘন grid, প্রতিটি ঘরে ৫-fold validation AUC (সত্যিকারের run, ডেস্কটপ, scikit-learn 1.9.1):

min_samples_leaf  depth=3  depth=6  depth=9  depth=12
       5           0.720    0.716    0.698    0.690
      10           0.733    0.718    0.714    0.714
      20           0.746    0.742    0.742    0.742
      30           0.751    0.759    0.759    0.759
      40           0.755    0.749    0.749    0.749
      50           0.731    0.734    0.734    0.734
      60           0.735    0.734    0.734    0.734
      80           0.706    0.706    0.706    0.706

leaf ২০ বা তার বেশি হলে গভীরতা ৬, ৯ আর ১২-এর কলাম হুবহু এক: পাতার ন্যূনতম আকারের নিয়মই গাছকে আগে থামিয়ে দেয়। এই অঞ্চলে একটি নব কার্যত অকেজো, ঠিক Bergstra আর Bengio-র বর্ণনার মতো, আর grid তার বাজেটের দুই-তৃতীয়াংশ সেখানে খরচ করেছে। এটি একটি ডেটায় তিনটি seed-এর ফল; দাবিটিও “প্রায়ই”, সবসময় নয়। এক-দুটি parameter আর অল্প কয়েকটি বিচ্ছিন্ন মান হলে grid সরল আর যথেষ্ট।

৭ · Nested cross-validation — পুরো বাছাই-পদ্ধতির সৎ নম্বর

সমস্যাটি আবার বলি: grid search-এর fold-গুলো দুটি কাজ করেছে, সেটিং বাছাই আর সেই সেটিংয়ের নম্বর দেওয়া। সমাধান হলো কাজ দুটিকে দুটি আলাদা loop-এ দেওয়া। Nested cross-validation-এ বাইরের loop (outer) ডেটাকে k ভাগে ভাগ করে; প্রতিটি ঘূর্ণনে একটি ভাগ মাপার জন্য সরিয়ে রেখে বাকি অংশে পুরো grid search চালায়, তার নিজের ভেতরের loop (inner) দিয়ে। Inner-এর বেছে নেওয়া সেটিং refit হয়, তারপর একবার মাপা হয় সেই outer ভাগে, যা বাছাইয়ে কোনো ভূমিকা রাখেনি। এভাবে k-টি outer score পাওয়া যায়।

Nested cross-validation: ভেতরের loop বাছে, বাইরের loop মাপে

Outer 5-fold on 640 training rows; inside each, a 3-fold grid search over 12 tree settings; the test set stays locked

Nested cross-validation: outer loop measures, inner loop selects test set · ১৬০টি অর্ডার · শেষ পর্যন্ত তালাবন্ধ বাইরের loop: ৬৪০টি train অর্ডার, ৫ ভাগ মাপাtraintraintraintrain ভেতরের loop: ওই ৫১২টি, ৩ ভাগ যাচাইfitfit ৩ বার ঘুরে ১২টি সেটিংয়ের গড় AUC সেরা সেটিং বেছে ৫১২টিতে আবার fit তীর: কমলা ভাগে একবার মাপা = একটি outer score ৫টি ভাগ ঘুরে: nested AUC 0.718 ± 0.053 non-nested best_score_ 0.720; inner-এর সেরার গড় 0.721

Nested CV কোনো একটি fit করা model নয়, একটি পদ্ধতি মাপে: “এই ১২টি সেটিংয়ের grid, ৩-fold দিয়ে বাছাই, তারপর refit”. প্রতিটি outer ভাগে inner আলাদা সেটিং বাছতে পারে; সেই অস্থিরতাসহ পারফরম্যান্সই আমরা জানতে চাই। চূড়ান্ত model বানাবেন একই পদ্ধতি পুরো train ডেটায় একবার চালিয়ে। scikit-learn-এ একটি GridSearchCV নিজেই একটি estimator, তাই তাকে cross_validate-এ model হিসেবে দিন, আর outer cv আলাদা।

import numpy as np
from sklearn.model_selection import StratifiedKFold, GridSearchCV, cross_validate
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_auc_score

grid = {"max_depth": [2, 3, 4, 6], "min_samples_leaf": [5, 20, 40]}   # ১২টি সমাহার
inner = StratifiedKFold(3, shuffle=True, random_state=0)               # বাছাইয়ের loop
outer = StratifiedKFold(5, shuffle=True, random_state=1)               # মাপার loop
search = GridSearchCV(DecisionTreeClassifier(random_state=42), grid, cv=inner, scoring="roc_auc")

flat = search.fit(X_tr, y_tr).best_score_       # non-nested: একই fold-এ বাছাই আর মাপ
r = cross_validate(search, X_tr, y_tr, cv=outer, scoring="roc_auc", return_estimator=True)
for k, est in enumerate(r["estimator"]):
    p = est.best_params_
    print(f"outer {k + 1}: depth={p['max_depth']}, leaf={p['min_samples_leaf']:2d} | "
          f"inner {est.best_score_:.3f} → outer {r['test_score'][k]:.3f}")
o = r["test_score"]
print(f"non-nested best_score_ {flat:.3f} | nested {o.mean():.3f} ± {o.std():.3f}")

print("ফাঁদ: label পুরো এলোমেলো, শেখার কিছু নেই")
for s in range(5):
    rng = np.random.default_rng(s)
    Xn, yn = rng.normal(size=(200, 20)), rng.integers(0, 2, 200)
    Xf, yf = rng.normal(size=(2000, 20)), rng.integers(0, 2, 2000)    # নতুন, না-দেখা noise
    g = GridSearchCV(DecisionTreeClassifier(random_state=42), grid, cv=inner,
                     scoring="roc_auc").fit(Xn, yn)
    fresh = roc_auc_score(yf, g.predict_proba(Xf)[:, 1])
    print(f"noise {s}: best_score_ {g.best_score_:.3f} | নতুন ডেটায় AUC {fresh:.3f}")

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

outer 1: depth=3, leaf=40 | inner 0.702 → outer 0.777
outer 2: depth=6, leaf=20 | inner 0.709 → outer 0.736
outer 3: depth=3, leaf=20 | inner 0.760 → outer 0.637
outer 4: depth=4, leaf= 5 | inner 0.712 → outer 0.679
outer 5: depth=4, leaf=20 | inner 0.724 → outer 0.763
non-nested best_score_ 0.720 | nested 0.718 ± 0.053
ফাঁদ: label পুরো এলোমেলো, শেখার কিছু নেই
noise 0: best_score_ 0.544 | নতুন ডেটায় AUC 0.484
noise 1: best_score_ 0.513 | নতুন ডেটায় AUC 0.497
noise 2: best_score_ 0.578 | নতুন ডেটায় AUC 0.500
noise 3: best_score_ 0.591 | নতুন ডেটায় AUC 0.507
noise 4: best_score_ 0.489 | নতুন ডেটায় AUC 0.500

বাজারঘরের ডেটায় পাঁচটি outer ভাগে inner পাঁচটি আলাদা সেটিং বেছেছে। Outer ৩-এ inner-এর সেরা ০.৭৬০, অথচ outer score ০.৬৩৭: inner-এর ভাগ্যবান জয় নতুন ভাগে টেকেনি। মোট nested ০.৭১৮ ± ০.০৫৩, non-nested best_score_ ০.৭২০: এই run-এ আশাবাদ মাত্র ০.০০২. কারণ grid ছোট, সেটিংগুলো কাছাকাছি, আর inner প্রতিবার কম সারিতে শেখে বলে তার নম্বর একটু নিচে নামে, যা নির্বাচনের ফোলানোকে কিছুটা কাটাকাটি করে।

ফাঁদের অংশে label মুদ্রা টসে বানানো, শেখার কিছুই নেই; তবু grid search পাঁচটির চারটিতে ০.৫-এর ওপরে একটি “সেরা” দেখিয়েছে (সর্বোচ্চ ০.৫৯১), যা নতুন ২,০০০টি সারিতে ০.৪৮৪ থেকে ০.৫০৭: মুদ্রা টস। ১২টি এলোমেলো নম্বরের সবচেয়ে বড়টি বাছলে সেটি গড়ের ওপরে থাকবেই।

outer: StratifiedKFold(5); inner: 3-fold grid, ১২টি সেটিং
outer ভাগ
inner-এর বাছাই
inner-এর সেরা score
outer score
এ পর্যন্ত outer গড়
fit-এর সংখ্যা
  1. প্রস্তুতি। প্রতিটি ঘূর্ণনে ১২৮টি সরিয়ে রেখে বাকি ৫১২টিতে পুরো grid search: ১২ × ৩ = ৩৬টি fit আর একটি refit.
  2. Outer ১. Inner বেছেছে গভীরতা ৩, leaf ৪০ (০.৭০২)। সরিয়ে রাখা ভাগে ০.৭৭৭: এই ভাগটি সহজ।
  3. Outer ২. নতুন ৫১২টিতে inner অন্য সেটিং বাছে: গভীরতা ৬, leaf ২০. Outer ০.৭৩৬; গড় ০.৭৫৭.
  4. Outer ৩. Inner-এর সেরা সবচেয়ে উঁচু (০.৭৬০), অথচ outer সবচেয়ে নিচু (০.৬৩৭): inner-এর জয় আংশিক ভাগ্য ছিল। গড় ০.৭১৭.
  5. Outer ৪. গভীরতা ৪, leaf ৫, outer ০.৬৭৯; গড় ০.৭০৭.
  6. Outer ৫. গভীরতা ৪, leaf ২০, outer ০.৭৬৩; পাঁচটির গড় ০.৭১৮.
  7. সারাংশ। “grid + refit” পদ্ধতির nested অনুমান ০.৭১৮ ± ০.০৫৩. পাঁচবারে পাঁচটিই আলাদা সেটিং: “সেরা সেটিং” নিজেই অস্থির। খরচ ৫ × ৩৭ = ১৮৫টি fit.

একটি run-এ আশাবাদ ছোট দেখা গেল; অনেকবার চালালে ছবিটা কেমন? বাজারঘরের ডেটায় ১০টি আলাদা seed-এ nested CV, আর বিশুদ্ধ noise-এর ২০টি আলাদা ডেটাসেটে (২০০টি সারি, ২০টি feature), সত্যিকারের run (ডেস্কটপ, scikit-learn 1.9.1):

~/bazarghar-ai$ python nested_repeat.py
bazarghar, 10 nested runs : mean inner best 0.728 | mean outer 0.727 | inner > outer in 4/10
pure noise, 20 datasets   : mean inner best 0.539 | mean outer 0.493 | inner > outer in 17/20

বাজারঘরে সংকেত জোরালো আর grid ছোট, তাই দুটি প্রায় সমান (০.৭২৮ বনাম ০.৭২৭); noise-এ ২০টির ১৭টিতে inner-এর সেরা বেশি, গড়ে ০.৫৩৯ বনাম ০.৪৯৩. নিয়ম: সংকেত যত দুর্বল, ডেটা যত কম, আর চেষ্টা যত বেশি, best_score_ তত বেশি ফোলানো; আপনি কোন অবস্থায় তা আগে জানা যায় না, nested CV সেটিই মাপে।

আরও গভীরে: Sudhir Varma আর Richard Simon ২০০৬ সালে (BMC Bioinformatics) দেখান, cross-validation দিয়ে hyperparameter বাছাই করে সেই একই CV নম্বর রিপোর্ট করলে, এমনকি label-এ কোনো সংকেত না থাকলেও, ভুলের হার উল্লেখযোগ্যভাবে কম দেখায়; তাঁরা nested CV-কে প্রায় নিরপেক্ষ বিকল্প হিসেবে সুপারিশ করেন। Gavin Cawley আর Nicola Talbot ২০১০ সালে (JMLR) এর আরেকটি দিক দেখান: model selection-এর মাপকাঠিতেই overfit হওয়া যায়, আর সেই পক্ষপাত কখনো প্রার্থী model-দের মধ্যের আসল পার্থক্যের সমান বা বড় হয়। Hastie, Tibshirani আর Friedman-এর বইয়ের cross-validation অধ্যায়ে “ভুল পথে আর ঠিক পথে cross-validation” অংশটি একই কথা feature বাছাইয়ের উদাহরণে বলে, যা পর্ব ৫৬-এ চালিয়ে দেখেছেন।

৮ · Test একবার, খরচের হিসাব, successive halving আর Bayesian optimisation

শেষ test একবার। সব তুলনা আর খোঁজের পর পদ্ধতিটি পুরো ৬৪০টিতে চালিয়ে একটি model, আর সেটি তালাবন্ধ ১৬০টিতে একবার। ফল খারাপ দেখে model বদলে আবার test-এ মাপলে test আরেকটি validation হয়ে যায় (পর্ব ৫৬); বদলাতেই হলে তা রিপোর্টে লিখুন, আর সম্ভব হলে নতুন ডেটায় নতুন test. ছোট test-এর নম্বরের পাশে bootstrap ব্যবধান দিন (পর্ব ৬০)।

খরচের হিসাব আগে। Grid search-এর fit = সমাহার × fold + ১ (refit); nested-এ তার ওপর outer-এর গুণ। প্রতি fit-এর সময় জেনে মোট সময় আন্দাজ করুন। বাস্তব কিছু অভ্যাস:

  • আগে একটি সরল baseline (যেমন DummyClassifier, AUC ০.৫) আর default সেটিংয়ের কয়েকটি model; প্রায়ই সেখানেই বড় লাভের বেশিটা পাওয়া যায়।
  • অল্প কয়েকটি গুরুত্বপূর্ণ নব বাছুন: logistic-এ C, গাছে min_samples_leaf আর max_depth, boosting-এ learning_rate আর গাছের সংখ্যা। মাপ বড় পরিসরে হলে log-scale.
  • প্রথমে মোটা খোঁজ (চওড়া পরিসর, অল্প fold বা random search-এর ২০–৬০টি চেষ্টা), তারপর ভালো অঞ্চলে সূক্ষ্ম। সেরা মান পরিসরের কিনারায় পড়লে পরিসর বাড়ান।
  • সমাহারগুলোর তফাত fold-এর ছড়ানোর চেয়ে অনেক ছোট হলে থামুন; আর কতগুলো চেষ্টা করলেন, তার লগ রাখুন।

Successive halving (নোট): সব প্রার্থীকে প্রথমে অল্প সম্পদে (কম সারি) মাপা, ভালো অংশ রেখে বাকিদের বাদ, তারপর বাকিদের বেশি সম্পদ। Kevin Jamieson আর Ameet Talwalkar (২০১৬) একে hyperparameter খোঁজে জনপ্রিয় করেন; Hyperband (Lisha Li ও সহকর্মী) এর ওপর দাঁড়ায়। scikit-learn-এ HalvingGridSearchCV, লেখার সময়ে এখনো experimental (from sklearn.experimental import enable_halving_search_cv লাগে)। একটি ডেস্কটপ run-এ বাজারঘরের ৬৪০টি সারিতে এটি লাভ দেয়নি: পুরো grid-এর চেয়ে বেশি fit (৪৩০ বনাম ৩২০), আর ৩০০ সারিতে বাছা সেটিং সেরাটি ছিল না। এর জোর বড় ডেটা আর দামি fit-এ।

Bayesian optimisation (নোট): আগের চেষ্টাগুলো থেকে শেখা। ফলের ওপর একটি সস্তা আন্দাজের model (যেমন Gaussian process) বসে, যা প্রতিটি সেটিংয়ের সম্ভাব্য score আর অনিশ্চয়তা দেয়, আর পর্ব ৫০-এর মতো প্রতিটি নতুন ফলে বিশ্বাস হালনাগাদ হয়; একটি নিয়ম (acquisition function) “ভালো হওয়ার সম্ভাবনা” আর “অজানা”-র ভারসাম্য রেখে পরের সেটিং বাছে। Jasper Snoek, Hugo Larochelle আর Ryan Adams-এর ২০১২ সালের NeurIPS প্রবন্ধ একে পরিচিত করে। scikit-learn-এ এটি নেই; Optuna-র মতো আলাদা লাইব্রেরি আছে। প্রতি fit দামি হলে এর মূল্য সবচেয়ে বেশি।

৯ · একটি model-selection report

বাছাইয়ের কাজ শেষ হয় একটি ছোট লিখিত report-এ, যাতে অন্য কেউ (বা ছয় মাস পরের আপনি) বুঝতে পারেন নম্বরটি কতটা ভরসার। ন্যূনতম যা থাকবে:

অংশকী লিখবেন
ডেটা আর ভাগসারির সংখ্যা, লক্ষ্যের হার, test কত সারি, কবে আর কীভাবে আলাদা (stratify, group, সময়)
Metricকোনটি আর কেন (এখানে ROC AUC, কারণ ফোনের তালিকার ক্রম), কাজ শুরুর আগে ঠিক করা
Foldকোন splitter, k, কতবার, seed; সব model একই fold-এ কি না
যা চেষ্টা করা হয়েছেপ্রতিটি প্রার্থী আর তার grid বা বণ্টন, মোট কতটি সেটিং; baseline
ফল আর ছড়ানোপ্রতিটি পদ্ধতির nested (বা অন্তত CV) গড় ± sd, সর্বনিম্ন fold; জোড়া পার্থক্য
সিদ্ধান্তকোনটি বাছা হলো আর কেন; কাছাকাছি সরল বিকল্পটি কেন নয়
শেষ testএকবারের নম্বর, অনিশ্চয়তার ব্যবধানসহ; test-এর পরে কিছু বদলালে তা স্পষ্টভাবে

বাজারঘরের জন্য তিনটি প্রার্থী-পদ্ধতি (interaction-সহ logistic-এর grid, গাছের grid, forest-এর একটি ছোট grid), প্রতিটি nested CV-তে (inner ৫-fold, outer ৫-fold × ৩ বার), তারপর সেরাটি পুরো train-এ আর test-এ একবার। সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.9.1, NumPy 2.5.4; ৪৫টি outer fit-এর প্রতিটিতে একটি পুরো grid, তাই প্রায় এক মিনিট লাগে):

~/bazarghar-ai$ python report.py
MODEL SELECTION REPORT - bazarghar returns (synthetic, seed 42)
data: 800 orders, return rate 0.273; test 160 rows held out before any work
metric: ROC AUC | inner: StratifiedKFold(5) | outer: 5-fold x 3 repeats = 15 splits
  logistic+poly   8 settings | nested AUC 0.755 +/- 0.054 (min 0.636)
  tree           12 settings | nested AUC 0.718 +/- 0.047 (min 0.641)
  forest          6 settings | nested AUC 0.771 +/- 0.051 (min 0.674)
chosen: forest {'max_features': 'sqrt', 'min_samples_leaf': 10}; its inner best_score_ 0.781
final test, used once: AUC 0.745 (bootstrap 95% CI 0.653-0.827); constant baseline 0.500

তিনটি নম্বর পাশাপাশি পড়ুন: বেছে নেওয়া forest-এর best_score_ ০.৭৮১, পুরো পদ্ধতির nested অনুমান ০.৭৭১, আর একবারের test ০.৭৪৫. বাছাইয়ের নম্বর সবচেয়ে উঁচু, যেমন আশা করা হয়েছিল। Test-এর bootstrap ব্যবধান ০.৬৫৩ থেকে ০.৮২৭, যার ভেতরে ০.৭৭১ আরামে বসে; ১৬০টি সারিতে মাত্র ৪৪টির মতো ফেরত, তাই এই নম্বর নিজেই নড়বড়ে। Forest আর interaction-সহ logistic-এর তফাত (০.৭৭১ বনাম ০.৭৫৫) ছড়ানোর চেয়ে ছোট; logistic পড়া যায় আর চালানো সস্তা, তাই সেটি বাছাও যুক্তিসঙ্গত। Report-এ এই বিকল্পটি লিখে রাখাই আসল কাজ।

১০ · AI-এর যুগে — AI assistant-এর tuning কোড আর তার “সেরা” নম্বর যাচাই

“আমার ডেটায় hyperparameter tune করে সেরা model দাও” বললে AI assistant মিনিটে একটি GridSearchCV আর একটি উজ্জ্বল নম্বর দেয়। কোড চলে বলেই ভুলগুলো চোখে পড়ে না:

AI যা প্রায়ই করেকীভাবে ধরবেন
best_score_-কে “model-এর accuracy” বলে রিপোর্ট করেসেটি বাছাইয়ের নম্বর; nested CV বা আলাদা test-এর নম্বর চান
Test set দিয়ে সেটিং বাছে, বা grid search-এ পুরো ডেটা (test-সহ) দেয়কোড পড়ে দেখুন fit-এ কোন ডেটা যাচ্ছে; test আলাদা হয়েছে সবার আগে
Scaling বা feature selection pipeline-এর বাইরে, পুরো ডেটায় fit করে তারপর grid searchসব preprocessing Pipeline-এর ধাপ, আর grid-এর নাম step__param
প্রতিটি model-কে আলাদা cv=5 বা seed ছাড়া shuffle দেয়, তারপর গড় তুলনা করেএকটি cv বস্তু, পূর্ণসংখ্যা random_state, সব model-এ একই
পুরোনো API: sklearn.grid_search, sklearn.cross_validation মডিউল, বা GridSearchCV(iid=...)এগুলো বহু আগে সরানো হয়েছে; নিজের version-এর docs-এ sklearn.model_selection দেখুন
বিশাল grid আর শত শত চেষ্টার পর ০.০০২-এর তফাতকে “উল্লেখযোগ্য উন্নতি” বলেfold-এর sd আর জোড়া পার্থক্য দেখুন; কতগুলো চেষ্টা হয়েছিল জিজ্ঞেস করুন

নির্দিষ্ট করে চান, আর এমন আউটপুট চান যা যাচাই করা যায়। আসল গ্রাহকের ডেটা পেস্ট করবেন না; কলামের নাম আর কয়েকটি বানানো সারিই যথেষ্ট।

আমার একটি pandas DataFrame আছে: কয়েকটি সংখ্যার feature আর লক্ষ্য কলাম returned (0/1, প্রায় ২৭% 1). Python 3.14 আর scikit-learn 1.8 দিয়ে কোড লেখো যা: ১. সবার আগে train_test_split(test_size=0.2, stratify, random_state=42) দিয়ে test আলাদা করবে, আর test শুধু একদম শেষে একবার ব্যবহার করবে। ২. একটি StratifiedKFold(5, shuffle=True, random_state=42) বানিয়ে DummyClassifier, Pipeline(StandardScaler, LogisticRegression) আর RandomForestClassifier-কে একই fold-এ cross_validate দিয়ে মাপবে: roc_auc-এর গড় ± sd, train score-সহ। ৩. Pipeline-এর ধাপের নাম দিয়ে (যেমন model__C) GridSearchCV আর RandomizedSearchCV (scipy.stats.loguniform) চালাবে, cv_results_ থেকে একটি ছোট টেবিল ছাপাবে। ৪. Nested cross-validation দিয়ে পুরো খোঁজ-পদ্ধতির নম্বর দেবে, আর best_score_-এর পাশে রেখে ব্যাখ্যা করবে কেন best_score_ আশাবাদী। ৫. কোনো warning চাপা দেবে না, পুরোনো বা সরানো API ব্যবহার করবে না, আর মোট কতটি fit হলো তা হিসাব করে দেখাবে।

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

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

  • একটি node-এ ৮টি নমুনা, ২টি positive: Gini কত? ১ − (০.২৫² + ০.৭৫²) = ১ − ০.৬২৫ = ০.৩৭৫.
  • Random forest একটি গাছের চেয়ে ভালো কেন? Bootstrap আর feature-উপসেট দিয়ে কম মিলওয়ালা অনেক গাছের গড় variance কমায়; গড়ের variance ρσ² + (1 − ρ)σ²/B.
  • ৩টি সেটিং, ৫-fold, refit-সহ grid search-এ কতটি fit? আর outer ৫-fold-এর nested-এ? ৩ × ৫ + ১ = ১৬; nested-এ ৫ × ১৬ = ৮০.
  • best_score_ কেন আশাবাদী? একই CV নম্বরের সর্বোচ্চটি বাছা হয়েছে, তাই তার ভেতরে ভাগ্যের অংশ আছে; সৎ নম্বর আসে nested CV বা না-দেখা test থেকে।
  • Grid-এর বদলে random search কখন? অনেক hyperparameter, যার অল্প কয়েকটি গুরুত্বপূর্ণ; একই বাজেটে প্রতিটি parameter-এর বেশি আলাদা মান দেখা যায়।

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

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

প্র ০১Gene-ডেটার মতো ক্ষেত্রে, যেখানে feature হাজারো আর নমুনা কয়েক ডজন, cross-validation-এর ভুল ব্যবহার কীভাবে প্রকাশিত গবেষণায় বিভ্রান্তিকর নম্বর এনেছিল? Nested CV এখানে কী শেখায়?

২০০০-এর দশকের শুরুতে microarray প্রযুক্তি একটি টিউমারের হাজার হাজার জিনের সক্রিয়তা একসঙ্গে মাপতে দেয়, অথচ রোগীর সংখ্যা প্রায়ই কয়েক ডজন। অনেক গবেষণা দাবি করে যে অল্প কয়েকটি জিন দিয়ে ক্যানসারের ধরন প্রায় নিখুঁতভাবে আলাদা করা যায়। Christophe Ambroise আর Geoffrey McLachlan ২০০২ সালে PNAS-এ দেখান, এসব দাবির একটি অংশের পেছনে একটি সূক্ষ্ম ভুল: জিন বাছাই করা হয়েছিল পুরো ডেটা দেখে, তারপর শুধু classifier-টিকে cross-validation করা হয়েছিল। জিন বাছাইয়ের ধাপটি fold-এর বাইরে থাকায় প্রতিটি validation fold-এর তথ্য আগেই বাছাইয়ে ঢুকে গিয়েছিল। তাঁরা দেখান, ঠিকভাবে বাছাইকেও প্রতিটি fold-এর ভেতরে রাখলে ভুলের হার অনেক বেড়ে যায়, আর label এলোমেলো করেও ভুল পদ্ধতিতে প্রায় শূন্য ভুল দেখানো যায়।

২০০৬ সালে Sudhir Varma আর Richard Simon একই রকম সমস্যা দেখান hyperparameter বাছাইয়ে: CV দিয়ে সেটিং বাছাই করে সেই CV নম্বরই রিপোর্ট করলে, সংকেতহীন ডেটাতেও ভুলের হার উল্লেখযোগ্যভাবে কম দেখায়। তাঁদের সুপারিশ nested CV. এই পর্বের noise-পরীক্ষাও একই কথা বলে: ২০টি এলোমেলো ডেটাসেটে inner-এর সেরা গড়ে ০.৫৩৯, অথচ outer ০.৪৯৩.

মূল শিক্ষা হলো, cross-validation একটি পদ্ধতি-কে মাপে, আর সেই পদ্ধতির প্রতিটি ডেটা-নির্ভর সিদ্ধান্ত (feature বাছাই, scaling, hyperparameter, এমনকি “কোন model চেষ্টা করব”) মাপার loop-এর ভেতরে থাকতে হবে। Nested CV সেই নীতির সবচেয়ে সরাসরি রূপ। শেষের ধাপটি মানুষের সিদ্ধান্ত বলে সবচেয়ে কঠিন: আপনি যদি দশটি নোটবুকে দশ রকম কিছু চেষ্টা করে সেরাটি রাখেন, সেই বাছাইও এক রকম grid search, যা কোনো কোডে লেখা নেই।

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

প্র ০২Grid search এত স্বাভাবিক মনে হয়, তবু Bergstra আর Bengio কেন বললেন একই বাজেটে random search প্রায়ই ভালো? Grid কোথায় এখনো যুক্তিসঙ্গত?

Grid search-এর আকর্ষণ সহজবোধ্যতা: প্রতিটি নবের কয়েকটি মান, সব সমাহার, একটি পরিষ্কার টেবিল। বহু বছর neural network আর SVM-এর গবেষণায় এটিই ছিল প্রচলিত, প্রায়ই হাতে করা খোঁজের সঙ্গে মিলিয়ে। James Bergstra আর Yoshua Bengio ২০১২ সালে Journal of Machine Learning Research-এ একটি সরল জ্যামিতিক যুক্তি দেন। ধরুন দুটি hyperparameter, কিন্তু score আসলে প্রায় পুরোপুরি একটির ওপর নির্ভর করে। ৩ × ৩ grid-এর ৯টি বিন্দু গুরুত্বপূর্ণ অক্ষে মাত্র ৩টি জায়গায় পড়ে; ৯টি এলোমেলো বিন্দু প্রায় ৯টি আলাদা জায়গায়। তাঁরা neural network আর deep belief network-এর পরীক্ষায় দেখান, অনেক ডেটাসেটে কয়েকটি মাত্র hyperparameter গুরুত্বপূর্ণ, আর কোনগুলো তা ডেটাসেট ভেদে বদলায়; তাই আগে থেকে grid সাজানো কঠিন, আর এলোমেলো খোঁজ একই বা কম চেষ্টায় সমান বা ভালো ফল দেয়।

এই পর্বের গাছের উদাহরণ সেই ছবির ছোট সংস্করণ: min_samples_leaf ২০-এর বেশি হলে max_depth প্রায় অপ্রাসঙ্গিক, আর grid তার ৯টি চেষ্টার মধ্যে leaf-এর মাত্র তিনটি মান দেখেছে। তিনটি random খোঁজের তিনটিই ৩০–৩৯-এর ভালো অঞ্চলে পৌঁছেছে। এলোমেলো খোঁজের আরেকটি ব্যবহারিক সুবিধা: যেকোনো সময় থামানো যায়, আর বাজেট বাড়াতে শুধু n_iter বাড়ালেই চলে; grid-এ একটি নতুন মান যোগ করলে পুরো সারি বা কলাম যোগ হয়।

তবে grid মৃত নয়। একটি বা দুটি parameter, অল্প কয়েকটি বিচ্ছিন্ন মান (যেমন degree 1 বা 2, kernel-এর ধরন), বা যখন পুরো তল দেখে বুঝতে চান কোন অঞ্চল সমতল, তখন grid পরিষ্কার আর পুনরুৎপাদনযোগ্য। আরও সমানভাবে ছড়ানো বিন্দু চাইলে Latin hypercube বা Sobol-এর মতো quasi-random নকশাও আছে, আর দামি fit-এ Bayesian optimisation.

মূল উপলব্ধি: কোন নব গুরুত্বপূর্ণ তা আগে জানা না থাকলে random search বাজেটকে গুরুত্বপূর্ণ অক্ষে বেশি ছড়িয়ে দেয়; grid ভালো যখন জায়গাটি ছোট আর বিচ্ছিন্ন।

প্র ০৩Deep learning-এর যুগেও টেবিল-আকারের ব্যবসায়িক ডেটায় random forest আর gradient boosting এত জনপ্রিয় কেন? এই জনপ্রিয়তাকে কতটা সাধারণ নিয়ম বলা যায়?

Leo Breiman ২০০১ সালে random forest প্রস্তাব করেন, আর একই বছর “Statistical Modeling: The Two Cultures” প্রবন্ধে যুক্তি দেন যে পরিসংখ্যানবিদদের শুধু ব্যাখ্যাযোগ্য সরল model-এ আটকে না থেকে অনুমানের নির্ভুলতাকেও গুরুত্ব দেওয়া উচিত। Forest প্রায় কোনো tuning ছাড়াই ভালো চলে, feature scaling লাগে না, সংখ্যা আর category মেশানো ডেটা সামলায়, আর interaction নিজে থেকেই ধরে: এই পর্বে default-এর কাছাকাছি একটি forest সব প্রার্থীর শীর্ষে ছিল, অথচ logistic-কে interaction ধরতে PolynomialFeatures লেগেছিল। Jerome Friedman-এর ২০০১ সালের gradient boosting পরে XGBoost (Tianqi Chen ও Carlos Guestrin, ২০১৬) আর LightGBM (২০১৭)-এর মতো দ্রুত রূপে Kaggle-এর মতো টেবিল-ডেটার প্রতিযোগিতায় খুব প্রচলিত হয়ে ওঠে বলে ব্যাপকভাবে বলা হয়।

২০২২ সালে Léo Grinsztajn, Edouard Oyallon আর Gaël Varoquaux (NeurIPS-এর datasets ও benchmarks অংশে) কয়েক ডজন মাঝারি আকারের টেবিল-ডেটায় দেখান, গাছ-ভিত্তিক model প্রায়ই সেই সময়ের deep learning পদ্ধতির চেয়ে ভালো বা সমান করে, কম tuning-এ। তাঁদের ব্যাখ্যায় টেবিল-ডেটায় অকেজো feature আর অনিয়মিত, খাঁজকাটা সম্পর্ক বেশি, যা গাছ সহজে ধরে।

তবু সাবধান হওয়া দরকার। ফল নির্ভর করে ডেটার আকার, benchmark বাছাই আর tuning-এর বাজেটের ওপর; বিশাল ডেটা, লেখা বা ছবির মতো কাঠামো, বা অনেক টেবিল একসঙ্গে শেখার ক্ষেত্রে neural network এগিয়ে থাকে, আর টেবিল-ডেটার জন্য নতুন pretrained model নিয়েও গবেষণা চলছে। আর এই পর্বের জোড়া তুলনায় দেখেছেন, forest বনাম interaction-সহ logistic-এর তফাত মাত্র ০.০০২: “forest সবসময় জেতে” একটি অভ্যাস, প্রমাণ নয়। প্রতিটি নতুন সমস্যায় একই fold-এ তুলনা চালানোই নিয়ম।

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

প্র ০৪দুই model-এর cross-validation গড় ০.৭৬২ আর ০.৭৬০. এই তফাত কি “আসল”? Fold-এর score-গুলোর ওপর সাধারণ t-test চালালে কী সমস্যা?

প্রশ্নটি পুরোনো, আর উত্তরটি অস্বস্তিকর। Thomas Dietterich ১৯৯৮ সালে Neural Computation-এ দুটি classification অ্যালগরিদম তুলনার কয়েকটি প্রচলিত পরিসংখ্যানিক পরীক্ষা যাচাই করেন। তিনি দেখান, বারবার এলোমেলো ভাগ বা k-fold-এর score-গুলোকে স্বাধীন ধরে সাধারণ paired t-test চালালে, আসলে কোনো তফাত না থাকলেও “উল্লেখযোগ্য” ফল প্রত্যাশার চেয়ে অনেক বেশি আসে। কারণ fold-গুলোর train অংশ অনেক সারি ভাগ করে নেয়, তাই score-গুলো একে অপরের সঙ্গে সম্পর্কিত, আর পরীক্ষাটি variance কম ধরে। তিনি 5×2 cross-validation-এর একটি পরীক্ষা প্রস্তাব করেন। Claude Nadeau আর Yoshua Bengio ২০০৩ সালে (Machine Learning) এই সম্পর্কের জন্য variance-এ একটি সংশোধন প্রস্তাব করেন, যা “corrected resampled t-test” নামে পরিচিত।

এই পর্বের ২৫টি জোড়া ভাগে forest বনাম interaction-সহ logistic: গড় পার্থক্য +০.০০২, জোড়া পার্থক্যের sd ০.০৩২, জিত ১২/২৫. এখানে কোনো পরীক্ষা ছাড়াই বলা যায়, তফাতটি noise-এর মধ্যে। কিন্তু সাধারণ নিয়ম হলো: গড়ের তফাতকে জোড়া পার্থক্যের ছড়ানোর সঙ্গে তুলনা করুন, একই fold ব্যবহার করুন, আর p-value চাইলে সংশোধিত পরীক্ষা নিন। আর মনে রাখুন multiple comparisons: দশটি model-এর সব জোড়া তুলনা করলে কোনো একটি জোড়া কাকতালীয়ভাবে “উল্লেখযোগ্য” হবেই।

শেষ বিচার পরিসংখ্যানেরও বাইরে। ০.০০২ AUC সত্যি হলেও বাজারঘরের ফোনের তালিকায় তার মূল্য কত টাকা? সরল model চালানো সস্তা, ব্যাখ্যা করা সহজ, আর পরে ভুল খুঁজে পাওয়াও সহজ। ব্যবসার খরচ দিয়ে তফাতকে মাপলে প্রায়ই সিদ্ধান্ত পরিষ্কার হয় (পর্ব ৬০-এর threshold-এর খরচের হিসাবের মতো)।

মূল উপলব্ধি: CV-এর fold-score স্বাধীন নয়, তাই সাধারণ t-test অতিরিক্ত আত্মবিশ্বাসী; জোড়া পার্থক্য, তার ছড়ানো আর ব্যবহারিক মূল্য দেখে তবেই “ভালো” বলুন।

প্র ০৫এলোমেলো label-এও grid search একটি “সেরা” model খুঁজে পেল। বিজ্ঞানের পুনরুৎপাদন-সংকটের (replication crisis) সঙ্গে এর কী মিল, আর tuning-এর বাজেট কীভাবে সৎ রাখবেন?

২০১১ সালে Joseph Simmons, Leif Nelson আর Uri Simonsohn মনোবিজ্ঞানে দেখান, ডেটা সংগ্রহ কখন থামাবেন, কোন পর্যবেক্ষণ বাদ দেবেন, কোন চলক রাখবেন, এমন ছোট ছোট স্বাধীনতা একসঙ্গে ব্যবহার করলে প্রায় যেকোনো মিথ্যা প্রভাবকেও পরিসংখ্যানিকভাবে “উল্লেখযোগ্য” দেখানো যায়। একে প্রায়ই p-hacking বলা হয়। Andrew Gelman আর Eric Loken এর আরও সূক্ষ্ম রূপকে বলেন “garden of forking paths”: গবেষক সচেতনভাবে অনেক পরীক্ষা না চালালেও, ডেটা দেখে নেওয়া প্রতিটি সিদ্ধান্ত সম্ভাব্য বিশ্লেষণের একটি ডাল বাছে, আর ফলটি সেই বাছাইয়ের ওপর দাঁড়ায়।

Hyperparameter খোঁজ ঠিক এই প্রক্রিয়ার যান্ত্রিক রূপ। প্রতিটি সেটিং একটি ডাল; সবচেয়ে ভালো ডালটির নম্বর রিপোর্ট করা মানে সবচেয়ে অনুকূল এলোমেলোতাকেও রিপোর্ট করা। এই পর্বের noise-পরীক্ষায় মাত্র ১২টি ডালেই একটি ডেটাসেটে ০.৫৯১ এসেছিল, যা নতুন ডেটায় ০.৫০৭. বড় প্রতিযোগিতা আর benchmark-এ হাজারো দল একই test-এ বছরের পর বছর মাপলে একই প্রভাব সম্মিলিতভাবে জমতে পারে, যদিও কতটা জমে তা নিয়ে গবেষণার ফল মিশ্র (পর্ব ৬১-এর ভাবনার প্রশ্ন)।

সৎ রাখার উপায় পুনরুৎপাদন-আন্দোলনের পরামর্শের মতোই। কাজ শুরুর আগে metric, fold আর প্রার্থীদের তালিকা লিখে রাখা (এক রকম pre-registration); test সবার আগে তালাবন্ধ; প্রতিটি চেষ্টা লগে, যাতে চূড়ান্ত নম্বরের পাশে লেখা যায় “৪৬টি সেটিংয়ের মধ্যে সেরা”; nested CV দিয়ে পুরো পদ্ধতির নম্বর; আর সম্ভব হলে সময়ের পরের নতুন ডেটায় আবার যাচাই। Bayesian optimisation বা successive halving খোঁজকে দ্রুত করে, কিন্তু এই সমস্যা কমায় না: দ্রুত খোঁজ মানে অল্প সময়ে আরও বেশি ডাল।

মূল উপলব্ধি: খোঁজ যত বেশি, “সেরা” তত বেশি ফোলানো; বাজেট আর চেষ্টার সংখ্যা লিখে রাখা, পদ্ধতিকে nested CV-তে মাপা আর না-দেখা ডেটায় একবার যাচাই করাই সৎ বিজ্ঞানের অভ্যাস।

অনুশীলন

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

  1. Gini, fit-এর সংখ্যা আর fold-এর ছড়ানো (হিসাব): (ক) একটি node-এ ৮টি অর্ডার, ৩টি ফেরত। একটি প্রশ্ন একে ভাগ করে ৫টি (১টি ফেরত) আর ৩টিতে (২টি ফেরত)। মায়ের Gini, সন্তানদের Gini, ওজনসহ গড় আর কমার পরিমাণ বের করুন। (খ) ৪ × ৫ = ২০টি সেটিংয়ের grid, ৫-fold, refit-সহ: কতটি fit? একই grid inner ৩-fold আর outer ৫-fold-এর nested CV-তে? (গ) পাঁচটি fold-এর AUC ০.৭১, ০.৭৫, ০.৬৯, ০.৭৭, ০.৭৩: গড় আর sd (n দিয়ে ভাগ, NumPy-র default)?
    • (ক) মা: p = ৩/৮, G = ১ − (৯/৬৪ + ২৫/৬৪) = ৩০/৬৪ = ০.৪৬৮৭৫. বাঁ: ১ − (১/২৫ + ১৬/২৫) = ৮/২৫ = ০.৩২. ডান: ১ − (৪/৯ + ১/৯) = ৪/৯ ≈ ০.৪৪৪৪. ওজনসহ: (৫/৮)(০.৩২) + (৩/৮)(০.৪৪৪৪) = ০.২ + ০.১৬৬৭ = ০.৩৬৬৭. কমল ০.৪৬৮৭৫ − ০.৩৬৬৭ ≈ ০.১০২১.
    • (খ) সাধারণ: ২০ × ৫ + ১ = ১০১. Nested: প্রতিটি outer ভাগে ২০ × ৩ + ১ = ৬১, পাঁচ ভাগে ৩০৫; পরে পুরো train-এ চূড়ান্ত model বানাতে আরেকটি grid search লাগলে আরও ২০ × ৩ + ১ = ৬১.
    • (গ) যোগ ৩.৬৫, গড় ০.৭৩. গড় থেকে দূরত্ব −০.০২, ০.০২, −০.০৪, ০.০৪, ০; বর্গ ০.০০০৪, ০.০০০৪, ০.০০১৬, ০.০০১৬, ০; যোগ ০.০০৪, ÷ ৫ = ০.০০০৮, বর্গমূল ≈ ০.০২৮৩. রিপোর্টে “০.৭৩০ ± ০.০২৮ (৫ fold)”.
  2. Forest বনাম HistGB, জোড়া তুলনা (কোড): অংশ ৪-এর X_tr, y_tr নিয়ে RandomForestClassifier(100, min_samples_leaf=5, random_state=42) আর HistGradientBoostingClassifier(learning_rate=0.05, max_depth=3, random_state=42)-কে একই RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=0)-এ মাপুন। দুটির গড় ± sd, জোড়া পার্থক্যের গড় ± sd, আর কতটি ভাগে forest এগিয়ে, ছাপান। কোনটি বাছবেন?
    from sklearn.model_selection import RepeatedStratifiedKFold, cross_validate
    rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=0)
    a = cross_validate(RandomForestClassifier(100, min_samples_leaf=5, random_state=42),
                       X_tr, y_tr, cv=rcv, scoring="roc_auc")["test_score"]
    b = cross_validate(HistGradientBoostingClassifier(learning_rate=0.05, max_depth=3, random_state=42),
                       X_tr, y_tr, cv=rcv, scoring="roc_auc")["test_score"]
    d = a - b                      # একই ভাগ, তাই জোড়া পার্থক্য অর্থবহ
    print(f"forest {a.mean():.3f} ± {a.std():.3f} | hist gb {b.mean():.3f} ± {b.std():.3f}")
    print(f"paired diff {d.mean():+.4f} ± {d.std():.4f} | forest ahead {np.sum(d > 0)}/{len(d)}")

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.9.1):

    forest 0.771 ± 0.033 | hist gb 0.760 ± 0.039
    paired diff +0.0105 ± 0.0180 | forest ahead 10/15

    Forest গড়ে ০.০১০৫ এগিয়ে, ১৫টির ১০টিতে। জোড়া পার্থক্যের sd (০.০১৮) প্রতিটি model-এর নিজের sd-এর প্রায় অর্ধেক, কারণ একই ভাগের কঠিনতা দুজনকেই একসঙ্গে নামায়। তবু ফাঁক প্রায় এক sd: “forest সম্ভবত সামান্য ভালো, নিশ্চিত নয়”. ন্যায্য তুলনায় দুটিকে সমান tuning-বাজেটও দিতে হয়।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মীর নোটবুক: “৩০০টি অর্ডার। প্রথমে পুরো ডেটায় StandardScaler আর SelectKBest(k=10). তারপর পাঁচটি model, প্রতিটির জন্য আলাদা cv=KFold(5, shuffle=True), seed ছাড়া। Random forest-এ ৫০০টি সমাহারের GridSearchCV; best_score_ ০.৯৪, এটিই আমাদের প্রত্যাশিত accuracy. Test set-এ তিনটি শীর্ষ model মেপে সবচেয়ে ভালোটি নিয়েছি।” কী কী ভুল, আর আপনি কীভাবে সাজাবেন?
    • পুরো ডেটায় scaling আর SelectKBest: leakage; label দেখে feature বাছাই fold-এর বাইরে, প্র ০১-এর microarray ভুলের হুবহু রূপ। দুটিই Pipeline-এর ধাপ।
    • আলাদা, seed ছাড়া fold: পাঁচটি model পাঁচটি আলাদা প্রশ্নপত্রে; একটি StratifiedKFold(5, shuffle=True, random_state=42) সবার জন্য।
    • ৩০০ সারিতে ৫০০টি সমাহার: খুব বেশি ডাল, খুব কম ডেটা; ছোট random search, কয়েকটি গুরুত্বপূর্ণ নব।
    • best_score_-কে প্রত্যাশিত পারফরম্যান্স বলা: সেটি বাছাইয়ের নম্বর; nested CV আর একটি baseline দরকার।
    • Test-এ তিনটি মেপে বাছাই: test তখন আরেকটি validation; test একবার, একটি চূড়ান্ত model-এ।

    নতুন নকশা: test সবার আগে; একটি cv বস্তু; প্রতিটি প্রার্থী একটি pipeline; DummyClassifier-সহ cross_validate; সম্ভাবনাময় দুই-তিনটিতে ছোট RandomizedSearchCV, nested CV-তে মাপা; সেরা পদ্ধতি পুরো train-এ; test একবার, bootstrap ব্যবধানসহ; চেষ্টার সংখ্যাসহ report.

  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে? দ্বিতীয় লাইনের dict-এ key-গুলোর ক্রম কেন এমন?
    import numpy as np
    from sklearn.model_selection import ParameterGrid, StratifiedKFold
    g = {"poly__degree": [1, 2], "model__C": [0.1, 1, 10]}
    print(len(ParameterGrid(g)))
    print(list(ParameterGrid(g))[1])
    cv = StratifiedKFold(4)
    print(cv.get_n_splits(), len(ParameterGrid(g)) * cv.get_n_splits() + 1)
    s = np.array([0.80, 0.70, 0.75, 0.75])
    print(s.mean(), round(s.std(), 4))

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

    6
    {'model__C': 0.1, 'poly__degree': 2}
    4 25
    0.75 0.0354

    ২ × ৩ = ৬টি সমাহার। ParameterGrid প্রতিটি dict-এর key আগে বর্ণানুক্রমে সাজায় (“model__C” আগে, “poly__degree” পরে), তারপর শেষ key সবচেয়ে দ্রুত বদলায়: প্রথমটি C = 0.1, degree 1; দ্বিতীয়টি C = 0.1, degree 2. তাই cv_results_-এর সারির ক্রমও লেখার ক্রম নয়। ৪টি fold, তাই grid search-এ ৬ × ৪ + ১ = ২৫টি fit (refit-সহ)। শেষ লাইনে গড় ০.৭৫; দূরত্ব ০.০৫, −০.০৫, ০, ০; বর্গের গড় ০.০০৫ ÷ ৪ = ০.০০১২৫, বর্গমূল ০.০৩৫৪.

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

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

ক্রমকয়েকটি প্রার্থী model থেকে একটি বেছে তার পারফরম্যান্স রিপোর্ট করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Seed দিয়ে stratify করে test set আলাদা করা আর তালাবন্ধ রাখা
  2. Metric আর একটি cv বস্তু ঠিক করা, যা সব model একই fold-এ ব্যবহার করবে
  3. Baseline আর প্রার্থী pipeline-গুলো cross_validate-এ তুলনা করা
  4. সম্ভাবনাময় পদ্ধতিগুলোর hyperparameter খোঁজ, nested CV দিয়ে মাপা
  5. বেছে নেওয়া পদ্ধতিটি পুরো train ডেটায় চালিয়ে চূড়ান্ত model বানানো
  6. Test set-এ একবার মেপে অনিশ্চয়তাসহ report লেখা

একটি decision tree-র node-এ ৬টি অর্ডার, তার ৩টি ফেরত, ৩টি ফেরত নয়। এই node-এর Gini impurity কত?

  • ০.২৫
  • ০.৫
  • ১.০
  • ০.৭৫

একটি pipeline: Pipeline([("scale", StandardScaler()), ("clf", LogisticRegression())]). GridSearchCV-এর param_grid-এ logistic regression-এর C কোন key দিয়ে বদলাতে হবে?

  • "C"
  • "clf_C"
  • "clf__C"
  • "LogisticRegression__C"

৫০টি সেটিংয়ের GridSearchCV শেষে best_score_ = ০.৮২. নতুন ডেটায় সেই model-এর প্রত্যাশিত score সম্পর্কে কোন কথাটি সবচেয়ে ঠিক?

  • সম্ভবত ০.৮২-এর একটু নিচে, কারণ সর্বোচ্চটি বাছাই করায় নম্বরটি আশাবাদী
  • ঠিক ০.৮২, কারণ cross-validation সবসময় নিরপেক্ষ
  • ০.৮২-এর বেশি, কারণ refit পুরো ডেটায় হয়
  • Train score দেখলেই ঠিক মান জানা যাবে

দুটি hyperparameter, যার একটি score-এ প্রায় কোনো প্রভাব ফেলে না। ৯টি চেষ্টার বাজেটে ৩ × ৩ grid আর ৯টি এলোমেলো চেষ্টা গুরুত্বপূর্ণ parameter-টির যথাক্রমে কতটি আলাদা মান দেখবে (এলোমেলো মানগুলো একটি অবিচ্ছিন্ন বণ্টন থেকে)?

  • ৯ আর ৯
  • ৯ আর ৩
  • ৩ আর ৩
  • ৩ আর প্রায় ৯

Nested cross-validation: outer ৫-fold, inner ৩-fold, ১০টি সেটিংয়ের grid, inner-এ refit=True. শুধু outer loop শেষ করতে মোট কতটি model fit হয়?

  • ১৫০
  • ১৫৫
  • ৫০
  • ৩১

একই ২৫টি repeated-CV ভাগে model A-র গড় AUC ০.৭৬২, model B-র ০.৭৬০; জোড়া পার্থক্যের sd ০.০৩২, আর A এগিয়ে ২৫-এর ১২টিতে। B সরল আর দ্রুত। সবচেয়ে যুক্তিসঙ্গত সিদ্ধান্ত কোনটি?

  • A, কারণ গড় বেশি
  • ২৫টি score-এ সাধারণ t-test চালিয়ে p < 0.05 হলে A
  • দুটি কার্যত সমান; সরল আর দ্রুত B বাছা যুক্তিসঙ্গত
  • দুটিকে test set-এ মেপে যেটি ভালো সেটি নেওয়া

শব্দকোষ

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

মনে রাখুন

  1. দুই কাজ, দুই নম্বর: বাছাইয়ের (selection) নম্বর আপেক্ষিক তুলনার জন্য; রিপোর্টের (assessment) নম্বর আসবে বাছাইয়ে না-লাগা ডেটা থেকে। Test সবার আগে আলাদা, শেষে একবার।
  2. প্রার্থী model: decision tree Gini সবচেয়ে বেশি কমানো প্রশ্ন লোভীভাবে বাছে, max_depth আর min_samples_leaf তার complexity; random forest bootstrap আর feature-উপসেটে কম মিলওয়ালা গাছের গড় নিয়ে variance কমায়; gradient boosting ছোট গাছ ধাপে ধাপে যোগ করে bias কমায়।
  3. ন্যায্য তুলনা: একটি StratifiedKFold(shuffle=True, random_state=…) সবার জন্য, cross_validate-এ একাধিক metric আর train score, গড়ের পাশে sd, আর repeated CV-তে জোড়া পার্থক্য। ছড়ানোর চেয়ে ছোট তফাত মানে কার্যত সমান।
  4. খোঁজ: pipeline-এর ভেতরে GridSearchCV বা RandomizedSearchCV, নাম step__param, C-এর জন্য loguniform; cv_results_ টেবিল হিসেবে পড়ুন; refit সেরাটিকে পুরো train-এ fit করে best_estimator_ বানায়। একই বাজেটে random প্রায়ই grid-কে হারায়।
  5. Nested CV আর সততা: best_score_ আশাবাদী, বিশেষ করে দুর্বল সংকেত, কম ডেটা আর অনেক চেষ্টায়; noise-এও খোঁজ “সেরা” পায়। Nested CV পুরো পদ্ধতিকে মাপে; বাজেট, চেষ্টার সংখ্যা আর অনিশ্চয়তা report-এ লিখুন।

পরের পর্বে: পর্ব ৬৭ (বাংলা লেখার ডেটা — tokenization, TF-IDF আর একটি classifier): বাংলা আর Banglish পণ্য-রিভিউকে সংখ্যায় রূপ দেওয়া, আর এই পর্বের ন্যায্য তুলনার নিয়ম মেনে কয়েকটি classifier-কে পাশাপাশি মাপা।

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

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