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

scikit-learn pipeline — fit, transform আর leakage-মুক্ত কাজ

scikit-learn Pipelines — The Estimator API, ColumnTransformer for Mixed Data, Custom Transformers, Cross-Validating the Whole Pipeline and Saving Models Safely

Taught in Bangla, this lesson explains scikit-learn's estimator API and what Pipeline calls during fit and predict, then builds a ColumnTransformer for mixed order data and a custom transformer, cross-validated as one object. Real runs reproduce three common bugs, predict on new orders from a raw CSV, and show joblib saving, the pickle code-execution risk and version-mismatch warnings.

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

  • scikit-learn-এর estimator API: fit, transform, predict, hyperparameter আর শেখা attribute-এর নিয়ম
  • Pipeline আর make_pipeline-এর ভেতরে কোন ডাক কখন হয়, ধাপের নাম আর ধাপ__সেটিং নামকরণ
  • সংখ্যা আর category মেশানো ডেটায় ColumnTransformer, remainder, set_output আর get_feature_names_out
  • FunctionTransformer আর API মেনে নিজের ছোট transformer, আর পুরো pipeline-এর ওপর cross_val_score
  • তিনটি সাধারণ বাগ, কাঁচা CSV থেকে নতুন অর্ডারের অনুমান, joblib দিয়ে সংরক্ষণ, pickle-এর ঝুঁকি আর version pinning

পর্ব ৫৬ (Train, validation আর test — data leakage-এর ফাঁদ)-এ দেখেছেন, preprocessing ভাগের আগে পুরো ডেটায় করলে test-এর তথ্য চুইয়ে ঢোকে, আর এর সহজ প্রতিকার Pipeline. সেখানে Pipeline ছিল এক লাইনের পরিচয়। এই পর্বে সেটিকে পুরো কাজের হাতিয়ার বানাব: বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) অর্ডারের ডেটায়, যেখানে সংখ্যা, category আর ফাঁকা ঘর মেশানো, কাঁচা CSV থেকে নতুন অর্ডারের ফেরতের সম্ভাবনা পর্যন্ত একটি বস্তুতে। Logistic regression আর ROC-AUC-এর বিস্তার পর্ব ৫৯ আর পর্ব ৬০-এ; কোন scaling বা encoding কখন ভালো, তা পর্ব ৬৩ (Feature engineering — scaling, encoding আর নতুন feature)-এর বিষয়। এখানে প্রশ্ন একটাই: এই টুকরোগুলো কীভাবে নির্ভুলভাবে জোড়া লাগে। চালানো যায় এমন ব্লকগুলো পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); scikit-learn প্রথমবার নামতে প্রায় ১০ সেকেন্ড লাগে, পরের run দ্রুত।

১ · একটি চুক্তি, অনেক টুকরো — estimator API

পাড়ার দর্জির কথা ভাবুন। তিনি একবার আপনার মাপ নেন আর খাতায় লিখে রাখেন: বুক ৩৮, হাতা ২৪. তারপর প্রতিটি নতুন কাপড় সেই খাতার মাপে কাটেন। মাপ নেওয়া হলো fit, খাতার সংখ্যা হলো শেখা attribute, আর খাতা দেখে কাপড় কাটা হলো transform. কোনো দিন নতুন কাপড় এলে দর্জি যদি কাপড়টারই মাপ নিয়ে সেই মাপে কাটেন, জামা আপনার গায়ে লাগবে না। Machine learning-এর অনেক বাগ ঠিক এই ভুল।

scikit-learn-এর শত শত class একই আচরণের নিয়ম মানে। এই নিয়মকে বলে estimator API. ২০১৩ সালে Lars Buitinck, Gilles Louppe, Mathieu Blondel, Fabian Pedregosa, Andreas Müller-সহ প্রকল্পের ১৫ জন ডেভেলপার “API design for machine learning software: experiences from the scikit-learn project” নামের লেখায় (ECML PKDD ২০১৩-এর সঙ্গে প্রকাশিত, arXiv-এ উন্মুক্ত) এই নকশার কারণ ব্যাখ্যা করেন। তাঁদের মূল নীতিগুলো: সব বস্তুর একই ধরনের interface (consistency), শেখা মান খোলাখুলি দেখা যাবে (inspection), নতুন class যত কম সম্ভব আর ডেটা থাকে NumPy array-র মতো সাধারণ ধরনে (non-proliferation of classes), টুকরো জুড়ে বড় জিনিস বানানো (composition), আর যুক্তিসঙ্গত default.

অংশকাজউদাহরণ
Estimatorfit(X, y) দিয়ে ডেটা থেকে কিছু শেখে, আর self ফেরত দেয়সব class: SimpleImputer, LogisticRegression
Transformerশেখা মান দিয়ে ডেটা বদলায়: transform(X); সঙ্গে fit_transformStandardScaler, OneHotEncoder
Predictorনতুন সারির উত্তর দেয়: predict, অনেক সময় predict_proba, আর scoreLogisticRegression
Hyperparameterশুধু __init__-এ, নিজের নামে জমা; get_params, set_params দিয়ে পড়া ও বদলানোSimpleImputer(strategy="median")
শেখা attributeশুধু fit-এর পরে থাকে, নামের শেষে underscoremean_, scale_, statistics_, coef_

ছোট একটি হিসাব দিয়ে নিয়মটা হাতে ধরুন। Train-এ ডেলিভারির দিন ২, ৪, ৬. StandardScaler().fit শেখে mean_ = ৪, আর scale_ = population standard deviation = √((৪ + ০ + ৪)/৩) ≈ ১.৬৩৩. এরপর যেকোনো নতুন মান x-কে সে বদলায় z = (x − ৪) ÷ ১.৬৩৩ দিয়ে; নতুন অর্ডারের ১০ দিন হয় ৩.৬৭. নতুন ডেটা দেখে mean_ বদলায় না, কারণ transform কিছু শেখে না। এই ভাগাভাগিই পুরো পর্বের ভিত্তি।

নিয়মটা কাগজে মামুলি, কিন্তু এর ফল বিশাল। যেহেতু প্রতিটি estimator-এর fit আর predict আছে, তাই এমন কোড লেখা যায় যা কোন estimator তা না জেনেই কাজ করে: cross_val_score, Pipeline, পরে GridSearchCV. Python-এ একে বলে duck typing: “হাঁসের মতো হাঁটলে আর ডাকলে, তাকে হাঁস ধরে নাও”. Python-এর class আর inheritance-এর মূল কথা Python কোর্সের class পর্বে আছে।

২ · ভুল পথ বনাম pipeline — একই ডেটায় পাশাপাশি

এক লাইনে মনে করিয়ে দিই: scaler বা imputer পুরো ডেটায় fit করলে test-এর গড় আর median train-এর মাপকাঠিতে ঢুকে যায় (পর্ব ৫৬-এর preprocessing leakage). এবার পুরো পর্বের ডেটা। বাজারঘরের ৩৬০টি কাল্পনিক অর্ডার, প্রতিটিতে জেলা, পণ্যের ধরন, পরিশোধের উপায়, দাম, পরিমাণ, ছাড়ের হার, ডেলিভারির দিন, প্রথম অর্ডার কি না, আর লক্ষ্য returned (পণ্য ফেরত এসেছিল কি না). ডেটা seed দিয়ে বানানো, ফেরতের হার ৩৯%, আর কিছু ঘর ইচ্ছে করে ফাঁকা: দাম ১০টিতে, ডেলিভারির দিন ১৮টিতে, পরিশোধ ১৪টিতে। ফাইলটি পাতার ভেতরেই রাখা, কোড একে returns.csv নামে পড়ে; প্রথম দুই সারি:

order_id,district,category,payment,price,qty,discount_pct,delivery_days,first_order,returned
5001,বরিশাল,fashion,COD,2470,3,0,5,0,1
5002,চট্টগ্রাম,books,card,580,4,20,2,1,0

প্রথম ব্লক শুধু সংখ্যার পাঁচটি কলাম নেয় আর দুটি পথ পাশাপাশি চালায়। ভুল পথে imputer আর scaler পুরো ৩৬০টি সারি দেখে; ঠিক পথে Pipeline শুধু ২৭০টি train সারি দেখে। সঙ্গে estimator API-এর দুটি জিনিস দেখি: get_params আর fit-এর আগে শেখা attribute-এর অনুপস্থিতি।

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

df = pd.read_csv("returns.csv")
num = ["price", "qty", "discount_pct", "delivery_days", "first_order"]
X, y = df[num], df["returned"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          stratify=y, random_state=42)

sc = StandardScaler()                     # hyperparameter যায় __init__-এ
print("সেটিং:", sc.get_params())
print("fit-এর আগে mean_ আছে?", hasattr(sc, "mean_"))

# ভুল পথ: পুরো ডেটায় imputer আর scaler fit, তারপর আলাদা model
imp_all = SimpleImputer(strategy="median").fit(X)
sc_all = StandardScaler().fit(imp_all.transform(X))
prep = lambda d: sc_all.transform(imp_all.transform(d))
leaky = LogisticRegression(max_iter=1000).fit(prep(X_tr), y_tr)

# ঠিক পথ: তিনটি ধাপ এক বস্তুতে, fit শুধু train-এ
pipe = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(),
                     LogisticRegression(max_iter=1000))
pipe.fit(X_tr, y_tr)

print("price-এর median: পুরো ডেটা", imp_all.statistics_[0],
      "| শুধু train", pipe[0].statistics_[0])
print("price-এর গড়   : পুরো ডেটা", round(sc_all.mean_[0], 1),
      "| শুধু train", round(pipe[1].mean_[0], 1))
print("test accuracy: ভুল পথ", round(leaky.score(prep(X_te), y_te), 3),
      "| pipeline", round(pipe.score(X_te, y_te), 3))
print("baseline (সবসময় 'ফেরত নয়'):", round(1 - y_te.mean(), 3))

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

সেটিং: {'copy': True, 'with_mean': True, 'with_std': True}
fit-এর আগে mean_ আছে? False
price-এর median: পুরো ডেটা 1330.0 | শুধু train 1310.0
price-এর গড়   : পুরো ডেটা 1945.9 | শুধু train 1932.5
test accuracy: ভুল পথ 0.622 | pipeline 0.622
baseline (সবসময় 'ফেরত নয়'): 0.611

প্রথম দুই লাইন API-এর চুক্তি: সেটিংগুলো (with_mean, with_std) বানানোর মুহূর্তেই আছে, কিন্তু mean_ নেই, কারণ এখনো কিছু শেখা হয়নি। এরপর ফাঁসটা চোখে দেখা যায়: পুরো ডেটার median ১৩৩০, train-এর ১৩১০; গড় ১৯৪৫.৯ বনাম ১৯৩২.৫. ভুল পথের মাপকাঠিতে test-এর ৯০টি অর্ডারের দামও মিশে আছে।

Accuracy দুই পথে হুবহু এক, ০.৬২২. এটি সৎ খবর: পর্ব ৫৬-এও দেখেছেন scaler-এর leakage প্রায়ই ছোট। তাহলে pipeline কেন? এক, label দেখে এমন ধাপে (feature বাছাই, target encoding) একই ভুল ভয়ংকর হয়। দুই, ভুল পথে আপনার হাতে তিনটি আলাদা বস্তু, যার প্রতিটি নতুন অর্ডারে ঠিক ক্রমে ডাকতে হবে; একটি ভুললেই বাগ। Pipeline-এ বস্তু একটি। আর খেয়াল করুন শেষ লাইন: শুধু সংখ্যার কলাম দিয়ে model baseline-এর (০.৬১১) প্রায় সমান। ফেরতের আসল সংকেত সম্ভবত পণ্যের ধরন আর পরিশোধের উপায়ে, যা সংখ্যা নয়। সেগুলো আনতে অংশ ৪-এর ColumnTransformer লাগবে।

৩ · Pipeline আর make_pipeline — ভেতরে কোন ডাক কখন

Pipeline([("imputer", SimpleImputer()), ("scaler", StandardScaler()), ("model", LogisticRegression())]) একটি তালিকা নেয়: প্রতিটি উপাদান (নাম, estimator). শেষটি ছাড়া সবাইকে transformer হতে হয়। make_pipeline(...) একই জিনিস বানায়, শুধু নাম নিজে দেয়: class-এর নাম ছোট হাতে, যেমন simpleimputer, standardscaler, logisticregression. ছোট কাজে make_pipeline সুবিধাজনক; পরে সেটিং খুঁজতে হবে এমন কাজে নিজের ছোট নাম ("pre", "model") দেওয়া ভালো।

Pipeline নিজেও একটি estimator: তারও fit, predict, score আছে। ভেতরে কী ঘটে, তা দেখতে আমরা প্রতিটি ধাপকে একটি ছোট “গুপ্তচর” wrapper-এ মুড়ে দিয়েছিলাম, যা প্রতিটি ডাক লিখে রাখে। ডেটা ইচ্ছে করে ছোট: পাঁচটি অর্ডারের ডেলিভারির দিন ২, ফাঁকা, ৮, ৪, ৬; ফেরত ০, ০, ১, ০, ১. নিচের সব সংখ্যা ও ডাকের ক্রম ডেস্কটপে সেই সত্যিকারের run থেকে (Python 3.14.4, scikit-learn 1.8.0).

1pipe = Pipeline([2  ("imputer", SimpleImputer(strategy="median")),3  ("scaler", StandardScaler()),4  ("model", LogisticRegression())])5pipe.fit(X, y)          # X: 2, NaN, 8, 4, 66pipe.predict_proba(X_new)   # X_new: NaN, 9
এই মুহূর্তে
ভেতরের ডাক
ডেটা এখন
শেখা মান
  1. বানানো। Pipeline তিনটি বস্তু শুধু সাজিয়ে রাখে। কোনো ডেটা দেখা হয়নি, কোনো শেখা মান নেই; hasattr(pipe[1], "mean_") এখন False.
  2. fit, প্রথম ধাপ। Pipeline প্রথম ধাপে fit_transform ডাকে। Imputer শেখে median = ৫ (২, ৪, ৬, ৮-এর মাঝখান) আর ফাঁকা ঘরে ৫ বসিয়ে ফল পরের ধাপে দেয়।
  3. fit, দ্বিতীয় ধাপ। Scaler পায় ২, ৫, ৮, ৪, ৬, আসল কাঁচা ডেটা নয়। শেখে গড় ৫, population sd √((৯ + ০ + ৯ + ১ + ১)/৫) = ২, আর বদলে দেয় −১.৫, ০, ১.৫, −০.৫, ০.৫.
  4. fit, শেষ ধাপ। শেষ ধাপে শুধু fit, কারণ model-এর পরে আর কেউ নেই যাকে রূপান্তরিত ডেটা দিতে হবে। Logistic regression শেখে সহগ ০.৯৮১, intercept −০.৪৯৩.
  5. predict, প্রথম ধাপ। দুটি নতুন অর্ডার: একটির দিন অজানা, অন্যটির ৯. এবার imputer শুধু transform: ফাঁকা ঘরে train-এর median ৫ বসায়, নতুন ডেটার median নয়।
  6. predict, দ্বিতীয় ধাপ। Scaler train-এর গড় ৫ আর sd ২ দিয়েই বদলায়: (৫ − ৫)/২ = ০, (৯ − ৫)/২ = ২.
  7. predict, শেষ ধাপ। Model দেয় sigmoid(০.৯৮১ × ০ − ০.৪৯৩) ≈ ০.৩৭৯ আর sigmoid(০.৯৮১ × ২ − ০.৪৯৩) ≈ ০.৮১৩. predict ডাকলে ০.৫ সীমায় [০, ১]. পুরো predict-এ কেউ কিছু শেখেনি।
মূল ধারণা

fit-এর সময়: প্রতিটি মাঝের ধাপে fit_transform, শেষ ধাপে fit. predict-এর সময়: প্রতিটি মাঝের ধাপে শুধু transform, শেষ ধাপে predict. এই নিয়মের কারণেই cross_val_score-কে পুরো pipeline দিলে প্রতিটি fold-এর test অংশ কোনো ধাপের শেখার কাছে অদেখা থাকে।

ধাপে হাত দেওয়া আর ধাপ__সেটিং নাম

Pipeline-এর ধাপ তিনভাবে পাওয়া যায়: নাম দিয়ে pipe.named_steps["model"] বা pipe["model"], অবস্থান দিয়ে pipe[0], আর শেষটি pipe[-1]. Slice দিলে pipe[:-1] নিজেই একটি Pipeline, যা শুধু প্রস্তুতির অংশ; model-এর আগে ডেটা কেমন দেখায়, তা দেখতে কাজে লাগে। ভেতরের সেটিংয়ের নাম তৈরি হয় দুটি underscore দিয়ে জুড়ে: ধাপের নাম__সেটিং, আর গভীরে গেলে আরও স্তর। পর্ব ৬৬ (Model বাছাই — cross-validation আর hyperparameter search)-এ GridSearchCV ঠিক এই নাম দিয়ে সেটিং বদলাবে। সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0), একটি pipeline যার ধাপের নাম "pre" আর "model", আর pre-এর ভেতরে "num" ও "cat" শাখা:

~/bazarghar-ai$ python steps_params.py
True | Pipeline
47 টি nested সেটিং, যেমন:
   model__C = 1.0
   pre__num__simpleimputer__strategy = median
   pre__cat__handle_unknown = ignore
বদলের পরে: 0.1 mean
ValueError: Invalid parameter 'c' for estimator LogisticRegression(C=0.1, max_iter=1000). Valid parame …

প্রথম লাইন বলে named_steps["model"] আর pipe[-1] একই বস্তু, আর pipe[:-1] একটি Pipeline. set_params(model__C=0.1, pre__num__simpleimputer__strategy="mean") গভীরের দুটি সেটিং একসঙ্গে বদলেছে। আর বানান ভুল (model__c, ছোট হাতের c) চুপচাপ উপেক্ষা হয় না, স্পষ্ট ValueError দেয়। AI assistant-এর বানানো parameter নাম ধরতে এটি কাজের।

৪ · ColumnTransformer — সংখ্যা আর category আলাদা পথে

বাস্তবের টেবিলে কলাম মেশানো: দাম সংখ্যা, জেলা লেখা। দামে median বসিয়ে scale করা ঠিক, জেলায় গড় হয় না। জেলার জন্য দরকার one-hot encoding: প্রতিটি জেলার জন্য একটি ০/১ কলাম (পর্ব ৫৮-এ dummy trap-সহ দেখেছেন)। ColumnTransformer কাজটা ভাগ করে: প্রতিটি শাখা (নাম, transformer, কলামের তালিকা); প্রতিটি শাখা নিজের কলাম নিয়ে আলাদা fit করে, আর শেষে সব ফল পাশাপাশি জোড়া হয়।

বড় বিয়েবাড়ির রান্নাঘর: মাছ যায় এক বাবুর্চির কাছে, সবজি আরেকজনের কাছে, মিষ্টি তৃতীয়জনের কাছে, যার যার নিজের রেসিপিতে। শেষে সব পদ এক থালায় সাজিয়ে অতিথির সামনে। ColumnTransformer সেই প্রধান বাবুর্চি, যিনি কোন উপকরণ কার কাছে যাবে তা ঠিক করেন, আর যা কারও তালিকায় নেই, তার ব্যাপারে remainder ঠিক করে: ফেলে দেওয়া (default "drop") নাকি যেমন আছে তেমন থালায় তোলা ("passthrough").

Category শাখায় দুটি সেটিং জরুরি। SimpleImputer(strategy="most_frequent") ফাঁকা পরিশোধে সবচেয়ে বেশি দেখা মান বসায়। আর OneHotEncoder(handle_unknown="ignore") বলে, train-এ দেখা হয়নি এমন জেলা এলে error না দিয়ে সেই জেলার সব কলাম ০ রাখতে; এই unseen category-এর সমস্যা অংশ ৭-এ সত্যিই ঘটিয়ে দেখব। নিচের stepper চারটি অর্ডারে পুরো ঘটনাটা দেখায় (ডেস্কটপে সত্যিকারের run, scikit-learn 1.8.0).

৪টি train অর্ডার: district, payment, price, first_orderপ্রতি ধাপে একটি কাজ

            
  1. কাঁচা ডেটা। চারটি অর্ডার, দুটি ফাঁকা ঘর: B-এর পরিশোধ আর C-এর দাম। কলামের ধরন মেশানো: লেখা, সংখ্যা, ০/১.
  2. ভাগ। কলামের নাম ধরে তিন ভাগ: num পায় দাম, cat পায় জেলা আর পরিশোধ। first_order কোনো তালিকায় নেই, তাই সেটি remainder-এর ভাগে।
  3. সংখ্যার শাখা। শুধু দামের কলাম দেখে median ১২০০ শেখে, C-তে বসায়; তারপর scaler গড় ১৩০০ আর sd ৪৩৫.৮৯ শেখে। এই শাখা জেলা বা পরিশোধ কখনো দেখে না।
  4. Category শাখা। B-এর ফাঁকা পরিশোধে COD (৪টির ২টি)। তারপর encoder দেখা জেলাগুলো (খুলনা, ঢাকা, সিলেট) আর পরিশোধ (COD, bKash) মনে রাখে: ৩ + ২ = ৫টি ০/১ কলাম।
  5. Remainder. "passthrough" দেওয়ায় first_order কোনো বদল ছাড়াই আসে। Default "drop" হলে কলামটি নিঃশব্দে হারিয়ে যেত।
  6. জোড়া। সব শাখার ফল শাখার ক্রমে পাশাপাশি: ১ + ৫ + ১ = ৭টি কলাম। প্রতিটি নামের আগে শাখার নাম আর দুটি underscore, যাতে কোন কলাম কোথা থেকে, তা বোঝা যায়।
  7. নতুন অর্ডার। কুমিল্লার একটি অর্ডার, দাম ও পরিশোধ অজানা। দাম পায় train-এর median ১২০০ (z = −০.২২৯), পরিশোধ পায় COD, আর অচেনা জেলার তিনটি কলামই ০. কোনো error নেই, কোনো নতুন শেখা নেই।

এবার পুরো ডেটায়। set_output(transform="pandas") বললে transformer-এর ফল NumPy array না হয়ে কলামের নামসহ DataFrame হয় (scikit-learn 1.2 থেকে). আর get_feature_names_out() বলে শেষ পর্যন্ত model কোন কলামগুলো পায়। একটি ফাঁদ: OneHotEncoder default-এ sparse matrix দেয় (বেশিরভাগ ঘর ০ হলে শুধু অশূন্য ঘর রাখার ধরন), আর pandas ফল sparse নিতে পারে না; তাই এখানে sparse_output=False.

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import make_pipeline

df = pd.read_csv("returns.csv")
X = df.drop(columns=["order_id", "returned"])
print("কলামের ধরন:", X.dtypes.astype(str).to_dict())

num = ["price", "qty", "discount_pct", "delivery_days"]
cat = ["district", "category", "payment"]
num_pipe = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())
cat_pipe = make_pipeline(SimpleImputer(strategy="most_frequent"),
                         OneHotEncoder(handle_unknown="ignore", sparse_output=False))
ct = ColumnTransformer([("num", num_pipe, num), ("cat", cat_pipe, cat)],
                       remainder="passthrough")   # first_order যেমন আছে তেমন
ct.set_output(transform="pandas")

Xt = ct.fit_transform(X)
print("আকার:", X.shape, "→", Xt.shape)
names = ct.get_feature_names_out()
for i in range(0, len(names), 3):
    print("  ", ", ".join(names[i:i + 3]))
print("remainder পেল:", ct.transformers_[-1][2])
row = Xt.iloc[0]                                  # প্রথম অর্ডার, রূপান্তরের পরে
print("০ নয় এমন ঘর:", row[row != 0].round(2).to_dict())

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

কলামের ধরন: {'district': 'str', 'category': 'str', 'payment': 'str', 'price': 'float64', 'qty': 'int64', 'discount_pct': 'int64', 'delivery_days': 'float64', 'first_order': 'int64'}
আকার: (360, 8) → (360, 20)
   num__price, num__qty, num__discount_pct
   num__delivery_days, cat__district_খুলনা, cat__district_চট্টগ্রাম
   cat__district_ঢাকা, cat__district_বরিশাল, cat__district_রাজশাহী
   cat__district_সিলেট, cat__category_books, cat__category_electronics
   cat__category_fashion, cat__category_grocery, cat__category_shoes
   cat__payment_COD, cat__payment_Nagad, cat__payment_bKash
   cat__payment_card, remainder__first_order
remainder পেল: ['first_order']
০ নয় এমন ঘর: {'num__price': 0.28, 'num__qty': 0.43, 'num__discount_pct': -1.04, 'num__delivery_days': 1.24, 'cat__district_বরিশাল': 1.0, 'cat__category_fashion': 1.0, 'cat__payment_COD': 1.0}

প্রথম লাইনে pandas 3-এর নতুন str dtype দেখুন: লেখার কলাম এখন আর object নয় (পর্ব ২৩). ৮টি কাঁচা কলাম হয়েছে ২০টি: ৪টি scale করা সংখ্যা, ৬টি জেলা, ৫টি পণ্যের ধরন, ৪টি পরিশোধ, আর remainder__first_order. প্রতিটি category শাখার কলাম বর্ণানুক্রমে সাজানো (বাংলা নামেও Unicode-এর ক্রমে)। এখানে কোনো কলাম বাদ দেওয়া হয়নি, তাই linear model-এ dummy trap-এর প্রশ্ন ওঠে; logistic regression-এর default regularization এটি সামলায় (পর্ব ৫৮), আর চাইলে OneHotEncoder(drop="first") দেওয়া যায়।

শেষ লাইন প্রথম অর্ডারটি (বরিশাল, fashion, COD, ২৪৭০ টাকা) model-এর চোখে কেমন, তা দেখায়: চারটি z-মান আর তিনটি ১. first_order এই অর্ডারে ০, তাই তালিকায় নেই। Model যা পায় তা নিজের চোখে দেখা, ভুল ধরার সবচেয়ে সস্তা উপায়।

বাজারঘরের ফেরতের pipeline: কাঁচা ৮ কলাম থেকে ফেরতের সম্ভাবনা

Pipeline = ColumnTransformer (three branches) + LogisticRegression

The full returns pipeline as a diagram Pipeline কাঁচা অর্ডার · ৮ কলাম ফাঁকা ঘরসহ DataFrame ColumnTransformer num · ৪ কলাম SimpleImputermedian StandardScaler cat · ৩ কলাম SimpleImputermost_frequent OneHotEncoderunknown → ignore remainder first_orderpassthrough → ৪ কলাম→ ১৫ কলাম→ ১ কলাম পাশাপাশি জোড়া · ২০ কলাম LogisticRegression predict_proba → ফেরতের সম্ভাবনা

Jupyter-এ pipeline-এর নাম লিখে চালালে scikit-learn নিজেই একটি ভাঁজ-খোলা HTML ছবি দেখায় (estimator-এর _repr_html_). এই পাতার ব্রাউজার-Python শুধু লেখা আর matplotlib-এর ছবি দেখায়, ওই HTML নয়; তাই ওপরের ছবিটি হাতে আঁকা। পাতায় বা terminal-এ print(pipe) লেখার আকারে একই কাঠামো দেখায়।

৫ · নিজের transformer — FunctionTransformer আর একটি ছোট class

কখনো আপনার দরকার এমন ধাপ, যা scikit-learn-এ নেই। দুটি পথ। এক, যে রূপান্তর কিছু শেখে না, যেমন দামের log, তার জন্য FunctionTransformer: FunctionTransformer(np.log1p) যেকোনো function-কে transformer বানায়। log1p মানে log(১ + x), যা ০ টাকাতেও চলে; দামের মতো ডান দিকে লম্বা লেজের কলামে log transform বড় মানের দাপট কমায় (কখন দরকার, তা পর্ব ৬৩-এ)। feature_names_out="one-to-one" না দিলে পরে get_feature_names_out ব্যর্থ হয়; ডেস্কটপে আমরা ঠিক এই AttributeError পেয়েছিলাম।

দুই, যে ধাপ train থেকে কিছু শেখে, তার জন্য নিজের class. নিয়মগুলো অংশ ১-এর চুক্তিই:

  • BaseEstimator থেকে উত্তরাধিকার নিলে get_params/set_params বিনা খরচে আসে; TransformerMixin দেয় fit_transform. এমন ছোট সাহায্যকারী class-কে বলে mixin.
  • __init__ শুধু argument-গুলো একই নামে জমা রাখে; কোনো হিসাব, যাচাই বা ডেটা পড়া নয়। কারণ clone (cross-validation আর grid search যা প্রতিবার করে) get_params পড়ে নতুন বস্তু বানায়; __init__-এ অন্য কিছু ঘটলে copy আর আসল আলাদা হয়ে যায়।
  • fit শেখা মান underscore-যুক্ত নামে রাখে আর self ফেরত দেয়। transform শুরুতে check_is_fitted(self) ডাকে, যা underscore-এ শেষ হওয়া attribute খোঁজে, না পেলে NotFittedError.

নিচের UnitPrice একটি নতুন feature বানায়: প্রতি পিসের দাম = দাম ÷ পরিমাণ, আর train-এর ৯৫তম শতাংশের ওপরের মান কেটে সেখানে নামায়। সেই সীমা (cap_) train থেকে শেখা, তাই এটি FunctionTransformer দিয়ে করা যেত না: সীমাটা পুরো ডেটা থেকে নিলে ছোট একটি leakage. তারপর পুরো pipeline-এর ওপর ৫-fold cross-validation, মাপ ROC-AUC (পর্ব ৬০).

import numpy as np, pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer as Imp
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
class UnitPrice(BaseEstimator, TransformerMixin):    # নতুন কলাম: price ÷ qty
    def __init__(self, cap_q=0.95):
        self.cap_q = cap_q                           # শুধু সেটিং রাখা, হিসাব নয়
    def fit(self, X, y=None):                        # শেখা: train-এর উচ্চ সীমা
        self.cap_ = (X["price"] / X["qty"]).quantile(self.cap_q)
        return self
    def transform(self, X):
        check_is_fitted(self)                        # cap_ না থাকলে NotFittedError
        return X.assign(unit_price=(X["price"] / X["qty"]).clip(upper=self.cap_))
df = pd.read_csv("returns.csv")
X, y = df.drop(columns=["order_id", "returned"]), df["returned"]
try: UnitPrice().transform(X)
except Exception as e: print(type(e).__name__, "→", str(e)[:48], "…")
log1p = FunctionTransformer(np.log1p, feature_names_out="one-to-one")
ct = ColumnTransformer([
    ("log", make_pipeline(Imp(strategy="median"), log1p, StandardScaler()), ["price", "unit_price"]),
    ("num", make_pipeline(Imp(strategy="median"), StandardScaler()),
     ["qty", "discount_pct", "delivery_days"]),
    ("cat", make_pipeline(Imp(strategy="most_frequent"), OneHotEncoder(handle_unknown="ignore")),
     ["district", "category", "payment"])], remainder="passthrough")
pipe = make_pipeline(UnitPrice(), ct, LogisticRegression(max_iter=1000))
cv = StratifiedKFold(5, shuffle=True, random_state=42)
s = cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc")
print("৫টি fold-এর ROC-AUC:", np.round(s, 3), f"গড় {s.mean():.3f} ± {s.std(ddof=1):.3f}")
names = pipe.fit(X, y)[1].get_feature_names_out()
print("ধাপ:", list(pipe.named_steps), "| cap_:", round(pipe[0].cap_, 1), "| কলাম:", len(names))

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

NotFittedError → This UnitPrice instance is not fitted yet. Call  …
৫টি fold-এর ROC-AUC: [0.769 0.733 0.813 0.748 0.721] গড় 0.757 ± 0.036
ধাপ: ['unitprice', 'columntransformer', 'logisticregression'] | cap_: 3042.5 | কলাম: 21

প্রথম লাইন: fit না করে transform ডাকায় check_is_fitted স্পষ্ট বার্তা দিয়েছে। এটি না থাকলে ভুলটা AttributeError: 'UnitPrice' object has no attribute 'cap_'-এর মতো অস্পষ্ট রূপে আসত, বা আরও খারাপ, পুরোনো কোনো মান নিয়ে চুপচাপ চলত।

দ্বিতীয় লাইন: পাঁচটি fold-এ ROC-AUC ০.৭২১ থেকে ০.৮১৩, গড় ০.৭৫৭. ডেস্কটপে একই folds-এ শুধু চারটি সংখ্যার কলাম দিয়ে গড় ছিল ০.৬০৬ (fold-ভেদে ০.৪৭ থেকে ০.৭১): category আর পরিশোধের উপায় আনায় পরিষ্কার উন্নতি। কিন্তু ৩৬০টি কাল্পনিক অর্ডারে ± ০.০৩৬ ছড়ানো মনে রাখুন; এটি একটি run, সাধারণ সত্য নয়। শেষ লাইন: make_pipeline নিজের class-এর নামও ছোট হাতে বানিয়েছে (unitprice), আর পুরো ডেটায় fit করলে cap_ ৩০৪২.৫ টাকা; model পায় ২১টি কলাম (অংশ ৪-এর ২০টি, সঙ্গে নতুন unit_price কলাম)।

৬ · Pipeline-এর ওপর cross_val_score — প্রতিটি fold-এ কী নতুন করে শেখে

cross_val_score(pipe, X, y, cv=cv) প্রতিটি fold-এ তিনটি কাজ করে: clone(pipe) দিয়ে একটি শূন্য copy (শুধু সেটিং, কোনো শেখা মান নয়), সেই copy-কে fold-এর train অংশে fit, আর test অংশে score. Pipeline-এর প্রতিটি ধাপ তাই প্রতিটি fold-এ নতুন করে শেখে। নিচের সংখ্যাগুলো ওপরের ব্লকের একই pipeline আর একই folds থেকে, cross_validate(..., return_estimator=True) দিয়ে প্রতিটি fold-এর fit করা copy খুলে পড়া (ডেস্কটপে সত্যিকারের run, scikit-learn 1.8.0).

StratifiedKFold(5, shuffle=True, random_state=42) · ৩৬০টি অর্ডার, ১৪১টি ফেরত
Fold
train / test সারি
test-এ ফেরত
UnitPrice-এর cap_
imputer-এর price median
OneHot category (জেলা, ধরন, পরিশোধ)
test ROC-AUC
  1. Fold ১. শূন্য copy ২৮৮টি সারিতে fit: UnitPrice শেখে cap_ ৩১১৪.০, imputer শেখে দামের median ১৩৪০. Test-এর ৭২টি সারি (২৯টি ফেরত) এর কোনোটিতে অংশ নেয়নি। ROC-AUC ০.৭৬৯.
  2. Fold ২. আবার clone, আবার শূন্য থেকে। Train অংশ বদলেছে, তাই শেখা মানও: cap_ ৩১৭৮.০, median ১৩৬০. ০.৭৩৩.
  3. Fold ৩. এই train অংশে প্রতি পিসে বেশি দামের অর্ডার কম পড়েছে: cap_ নেমে ২৭৮০.৯, median ১২৫৫. AUC এই run-এর সবচেয়ে বেশি, ০.৮১৩; শেখা মানের পার্থক্যের সঙ্গে এর সরাসরি সম্পর্ক ধরে নেওয়া ঠিক হবে না, এটি মূলত কোন অর্ডার test-এ পড়ল তার দোলা।
  4. Fold ৪. cap_ ৩১০২.৫, median ১৪০৫. ০.৭৪৮. প্রতিটি fold-এ encoder-ও নতুন করে জেলা আর পরিশোধ শেখে; এখানে সব fold-এর train-এ সব category ছিল, তাই সংখ্যা ৬, ৫, ৪ এক।
  5. Fold ৫. cap_ ৩১৪০.৫, median ১৩৩০. ০.৭২১, এই run-এর সবচেয়ে কম।
  6. শেষে। পাঁচটি score-এর গড় ০.৭৫৭, sd ০.০৩৬. Cross-validation একটি পদ্ধতির মাপ দেয়; পাঁচটি copy ফেলে দেওয়া হয়। ব্যবহারের model পেতে পুরো train ডেটায় আরেকবার fit করতে হয় (তখন cap_ ৩০৪২.৫)।

খেয়াল করুন, পাঁচটি fold-এ শেখা মানগুলো ভিন্ন। Pipeline-এর বাইরে আগে একবার পুরো ডেটায় এগুলো শিখে ফেললে পাঁচটি fold একই মান ব্যবহার করত, আর প্রতিটি test fold নিজের তথ্য আগেই দিয়ে ফেলত। cross_validate, StratifiedKFold বা GridSearchCV দিয়ে model বাছাই আর একই folds-এ ন্যায্য তুলনা পর্ব ৬৬-এর বিষয়; এখানে শুধু জানুন, সেখানে যা কিছু ঘটে, তা এই “প্রতি fold-এ clone আর fit” নিয়মের ওপর দাঁড়িয়ে।

৭ · Pipeline-এর তিনটি সাধারণ বাগ — সত্যিই ঘটিয়ে দেখা

তিনটি বাগই ডেস্কটপে সত্যিই চালানো হয়েছে (Python 3.14.4, scikit-learn 1.8.0, pandas 3.1.1), অংশ ২-এর একই train অংশে fit করা pipeline দিয়ে। আউটপুট হুবহু, শুধু project-এর পথ বদলে ~/bazarghar-ai.

~/bazarghar-ai$ python bugs.py
scikit-learn 1.8.0 | pandas 3.1.1

[1] নতুন ব্যাচে আবার fit (fit_transform)
train-এর গড় [3.35] → z: [-0.97  0.46  1.18]
ব্যাচে নতুন fit  → z: [-1.34  0.27  1.07]
একটি অর্ডার, ৯ দিন → train-এর মাপে [4.06] | নতুন fit-এ [0.]

[2] handle_unknown ভুলে গেলে
ValueError: Found unknown categories ['কুমিল্লা'] in column 0 during transform

[3] dtype মেলে না: ওয়েব ফর্ম থেকে সব মান লেখা (string)
{'district': 'str', 'category': 'str', 'payment': 'str', 'price': 'str', 'qty': 'str', 'discount_pct': 'str', 'delivery_days': 'str', 'first_order': 'str'}
সব string হলেও: [0.797]
price='1,800' দিলে ValueError: Cannot use median strategy with non-numeric data:
could not convert string to float: '1,800'
price='' দিলে ValueError: Cannot use median strategy with non-numeric data:
could not convert string to float: ''
first_order='yes' দিলে ValueError: could not convert string to float: 'yes'

বাগ ১ — test বা নতুন ডেটায় আবার fit

দর্জির গল্পের ভুল। নতুন অর্ডারের ব্যাচে কেউ scaler.fit_transform(batch) লিখলেন, transform-এর বদলে। তিনটি অর্ডারের ২, ৪, ৫ দিন train-এর মাপে −০.৯৭, ০.৪৬, ১.১৮; ব্যাচে নতুন fit-এ −১.৩৪, ০.২৭, ১.০৭. Model অন্য মাপকাঠির সংখ্যা পাচ্ছে, কিন্তু কোনো error নেই। আরও খারাপ শেষ লাইন: একটি অর্ডারের ব্যাচ, ৯ দিন, train-এর মাপে ৪.০৬ (স্পষ্ট অস্বাভাবিক দেরি), নতুন fit-এ ঠিক ০, অর্থাৎ “একদম গড়”. একটি সারির sd শূন্য, তাই scaler সব মান ০ বানায়। একই বাগ ঘটে যখন কেউ pipe.fit(X_test, y_test) দিয়ে “test-এ মানিয়ে” নেন; তখন test আর test থাকে না। প্রতিকার: test আর নতুন ডেটায় শুধু pipe.predict বা pipe.predict_proba; pipeline-এর বাইরে কোনো আলাদা transformer রাখবেন না।

বাগ ২ — handle_unknown ভুলে যাওয়া

OneHotEncoder-এর default handle_unknown="error". Train-এ কুমিল্লার কোনো অর্ডার ছিল না; নতুন অর্ডার আসতেই পুরো অনুমান থেমে গেল। নতুন জেলা, নতুন পণ্যের ধরন বা নতুন পরিশোধের উপায় বাস্তবে আসবেই। "ignore" দিলে অচেনা মানের সব কলাম ০, অর্থাৎ model সেটিকে “কোনো পরিচিত জেলা নয়” ধরে। এটি চুপচাপ মেনে নেওয়াও একটি সিদ্ধান্ত: কত অর্ডারে অচেনা category আসছে, তা আলাদা গুনে নজরে রাখা ভালো। বিরল category একসঙ্গে করার জন্য min_frequency আর handle_unknown="infrequent_if_exist"-ও আছে; সেটি পর্ব ৬৩-এর আলোচনা।

বাগ ৩ — prediction-এর সময় dtype মেলে না

Train-এ দাম ছিল float64; ওয়েব ফর্ম বা JSON থেকে আসা নতুন অর্ডারে সব মান লেখা, অর্থাৎ string আকারে এসেছে। প্রথম ফল অবাক করে: সব string হলেও উত্তর এসেছে (০.৭৯৭), কারণ scikit-learn ভেতরে "1800"-কে সংখ্যায় বদলাতে পেরেছে। এই নিঃশব্দ সাফল্যই বিপদ: পরের দিন কেউ "1,800" (হাজারের কমা) পাঠালে ValueError. সবচেয়ে কপট দ্বিতীয়টি: ফর্মে দাম ফাঁকা থাকলে আসে "", NaN নয়; imputer ফাঁকা string-কে ফাঁকা মান মনে করে না, তাই সে নিজের কাজটাই করতে পারে না। আর first_order="yes" (train-এ ছিল ০/১) সরাসরি ব্যর্থ। প্রতিকার: model-এর সামনে একটি স্পষ্ট ধরনের চুক্তি (schema) রাখুন। কলামের নাম আর dtype train থেকে লিখে রাখুন, নতুন ডেটায় pd.to_numeric(..., errors="coerce") দিয়ে বদলান, ফাঁকা string-কে NaN করুন, আর অমিল হলে স্পষ্টভাবে আটকান (পর্ব ২৩-এ type ঠিক করা দেখেছেন)।

কলামের নাম আর ক্রম: DataFrame দিয়ে fit করলে estimator feature_names_in_-এ কলামের নাম মনে রাখে, আর ColumnTransformer নাম ধরে কলাম বাছে, তাই নতুন ডেটায় কলামের ক্রম বদলালেও সমস্যা হয় না। কিন্তু কোনো কলাম না থাকলে error, আর DataFrame-এ fit করে NumPy array দিয়ে predict করলে নাম মেলানোর সুযোগ চলে যায়। Train আর predict দুই জায়গায় একই ধরনের ইনপুট দিন।

৮ · কাঁচা CSV থেকে নতুন অর্ডারের অনুমান — পুরো pipeline

সব টুকরো এবার এক জায়গায়, আর সঠিক ক্রমে: কাঁচা CSV পড়া, ID আর লক্ষ্য আলাদা করা, সবার আগে ভাগ, preprocessing আর model এক pipeline-এ, শুধু train-এ fit, test-এ একবার মাপা, তারপর তিনটি নতুন অর্ডারে অনুমান। নতুন অর্ডারের দ্বিতীয়টি কুমিল্লার (train-এ নেই), তৃতীয়টির দাম আর পরিশোধ অজানা। শেষে fit করা pipeline joblib দিয়ে একটি ফাইলে লিখে আবার পড়া। ব্রাউজারের Python-এর নিজস্ব অস্থায়ী, মেমোরির ভেতরের file system আছে; ফাইলটি সেখানেই থাকে, পাতা বন্ধ করলে মুছে যায়, আপনার কম্পিউটারে কিছু লেখা হয় না।

import os, joblib, numpy as np, pandas as pd
from sklearn.compose import make_column_transformer
from sklearn.impute import SimpleImputer as Imp
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

df = pd.read_csv("returns.csv")                      # কাঁচা CSV, ফাঁকা ঘরসহ
X, y = df.drop(columns=["order_id", "returned"]), df["returned"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          stratify=y, random_state=42)
num = ["price", "qty", "discount_pct", "delivery_days"]
cat = ["district", "category", "payment"]
pre = make_column_transformer(
    (make_pipeline(Imp(strategy="median"), StandardScaler()), num),
    (make_pipeline(Imp(strategy="most_frequent"), OneHotEncoder(handle_unknown="ignore")), cat),
    remainder="passthrough")
model = make_pipeline(pre, LogisticRegression(max_iter=1000)).fit(X_tr, y_tr)
print("test ROC-AUC:", round(roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]), 3))

new = pd.DataFrame({"district": ["ঢাকা", "কুমিল্লা", "সিলেট"],      # কুমিল্লা: train-এ নেই
                    "category": ["grocery", "fashion", "shoes"],
                    "payment": ["bKash", "COD", np.nan],              # ফাঁকা payment
                    "price": [650.0, 1800.0, np.nan], "qty": [2, 1, 1],
                    "discount_pct": [0, 20, 10], "delivery_days": [2.0, 4.0, 5.0],
                    "first_order": [0, 1, 0]})
p = model.predict_proba(new)[:, 1]
print("নতুন অর্ডারে ফেরতের সম্ভাবনা:", np.round(p, 3))

joblib.dump(model, "returns_model.joblib")               # ব্রাউজারের অস্থায়ী ফাইলে
again = joblib.load("returns_model.joblib")
print("ফাইল:", os.path.getsize("returns_model.joblib"), "বাইট | একই উত্তর?",
      np.array_equal(again.predict_proba(new)[:, 1], p))

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

test ROC-AUC: 0.684
নতুন অর্ডারে ফেরতের সম্ভাবনা: [0.02  0.822 0.559]
ফাইল: 5562 বাইট | একই উত্তর? True

Test-এর ৯০টি অর্ডারে ROC-AUC ০.৬৮৪, cross-validation-এর ০.৭৫৭-এর চেয়ে কম। ছোট test-এ এমন পার্থক্য স্বাভাবিক দোলা (cross-validation-এর fold-গুলোই ০.৭২ থেকে ০.৮১ ছড়িয়েছিল); test দেখে এখন model বদলালে সেটি আর নিরপেক্ষ থাকবে না (পর্ব ৫৬)।

তিনটি নতুন অর্ডার কোনো error ছাড়াই উত্তর পেয়েছে। ঢাকার বিকাশে পরিশোধ করা মুদির অর্ডার: ০.০২, প্রায় নিশ্চিতভাবে ফেরত নয়। কুমিল্লার প্রথম অর্ডার, fashion, COD, ২০% ছাড়: ০.৮২২; অচেনা জেলার কলামগুলো ০ হয়ে গেছে, কিন্তু বাকি সংকেত (ধরন, COD, প্রথম অর্ডার) জোরালো। তৃতীয়টির ফাঁকা দাম আর পরিশোধ train-এর median আর সবচেয়ে বেশি দেখা মান পেয়েছে। শেষ লাইন: ফাইলটি মাত্র কয়েক কিলোবাইট, আর ফিরে পড়া pipeline হুবহু একই সম্ভাবনা দিয়েছে, কারণ ফাইলে পুরো pipeline, প্রতিটি শেখা মানসহ, জমা আছে। এগুলো সম্ভাবনা, সিদ্ধান্ত নয়: কোন সীমায় কী ব্যবস্থা (যেমন COD-এর আগে ফোনে নিশ্চিত করা), তা খরচ ধরে ঠিক করার বিষয় (পর্ব ৫৯)।

৯ · সংরক্ষণ — joblib, pickle-এর ঝুঁকি আর version pinning

Fit করা pipeline ফাইলে রাখাকে বলে model persistence. joblib.dump(model, "returns_model.joblib") আর joblib.load(...) সবচেয়ে প্রচলিত পথ; joblib ভেতরে Python-এর pickle ব্যবহার করে, বড় NumPy array-র জন্য কিছু দক্ষতা যোগ করে। এর দুটি সীমা জানা জরুরি: নিরাপত্তা আর version.

Pickle খোলা মানে কোড চালানো

Python-এর নথি স্পষ্ট সতর্ক করে যে pickle মডিউল নিরাপদ নয়, শুধু বিশ্বস্ত ডেটা খুলতে হবে; scikit-learn-এর model persistence নথিও বলে, অবিশ্বস্ত উৎসের pickle ফাইল কখনো load করবেন না, ঠিক যেমন অচেনা উৎসের কোড চালান না। কারণটা নিচের নিরীহ পরীক্ষায় দেখা যায়: একটি বস্তু pickle-কে বলে রাখে “খোলার সময় এই function চালাও”, আর আমরা শুধু pickle.loads করি। সত্যিকারের run (ডেস্কটপ, Python 3.14.4; function-টি ইচ্ছে করে শুধু print):

import pickle

class NotAModel:
    def __reduce__(self):                 # pickle-কে বলে: খোলার সময় এই function চালাও
        return (print, ("!!! এই লাইন ফাইল খোলার সময় চলেছে; print-এর জায়গায় যেকোনো কোড হতে পারত",))

data = pickle.dumps(NotAModel())
print("ফাইলের আকার:", len(data), "বাইট; এবার শুধু load করছি …")
obj = pickle.loads(data)                 # কোনো method ডাকিনি, তবু কোড চলল
print("load-এর ফল:", obj)
~/bazarghar-ai$ python pickle_danger.py
ফাইলের আকার: 212 বাইট; এবার শুধু load করছি …
!!! এই লাইন ফাইল খোলার সময় চলেছে; print-এর জায়গায় যেকোনো কোড হতে পারত
load-এর ফল: None

২১২ বাইটের একটি ফাইল খুলতেই কোড চলেছে, কোনো method ডাকার আগেই। আক্রমণকারী print-এর জায়গায় ফাইল মোছা বা গোপন তথ্য পাঠানোর কোড বসাতে পারে, আর .joblib বা .pkl নাম দেখে তা বোঝা যায় না। তাই নিয়ম: শুধু নিজের বা যাচাই করা উৎসের model ফাইল খুলুন, ফাইলের hash মিলিয়ে নিন, আর ইন্টারনেট থেকে নামানো “ready model” সরাসরি joblib.load করবেন না। এই deserialization-এর ঝুঁকি নিয়ে বিস্তারিত পড়ুন Cybersecurity কোর্সের secure coding পর্বে আর model ফাইলের supply chain নিয়ে AI নিরাপত্তা পর্বে।

যে version-এ বানানো, সেই version-এ খোলা

scikit-learn-এর নথি অনুযায়ী এক version-এ সংরক্ষিত model অন্য version-এ খোলার কোনো সমর্থিত উপায় নেই; অনেক সময় খোলে, কিন্তু ফল ভিন্ন হতে পারে বা process ভেঙে পড়তে পারে। ১.৩ version (২০২৩) থেকে এমন ক্ষেত্রে InconsistentVersionWarning আসে। লেখার সময় PyPI-তে সর্বশেষ scikit-learn 1.9.1, আর এই কোর্সের পাতাগুলো চলে 1.8.0-এ; তাই সত্যিকারের পরীক্ষা সহজ ছিল। 1.8.0-এর একটি venv-এ model আর একটি ছোট metadata JSON লেখা, তারপর আলাদা একটি venv-এ 1.9.1 দিয়ে খোলা, warning-কে error বানিয়ে:

(sk18) ~/bazarghar-ai$ python save_model.py
saved: 5562 bytes | 1.8.0 3.14.4
(sk18) ~/bazarghar-ai$ python load_model.py
এখন scikit-learn 1.8.0 | model বানানো 1.8.0 দিয়ে
load ঠিক আছে, ধাপ: ['columntransformer', 'logisticregression']

(sk19) ~/bazarghar-ai$ python load_model.py
এখন scikit-learn 1.9.1 | model বানানো 1.8.0 দিয়ে
load থামানো হলো: InconsistentVersionWarning
  estimator: SimpleImputer | সংরক্ষণের version: 1.8.0 | এখনকার: 1.9.1

Warning-কে error না বানালে একই load চলত, শুধু প্রতিটি estimator-এর জন্য একটি করে লম্বা warning ছাপত (এই pipeline-এ সাতটি: SimpleImputer, StandardScaler, Pipeline, OneHotEncoder, FunctionTransformer, ColumnTransformer, LogisticRegression; remainder="passthrough" ভেতরে একটি FunctionTransformer হিসেবে জমা থাকে)। Log-এ এমন warning চোখ এড়িয়ে যাওয়া সহজ, তাই production-এ থামিয়ে দেওয়াই নিরাপদ।

প্রতিকার তিন স্তরে। এক, version pinning: প্রকল্পের lockfile বা requirements-এ scikit-learn==1.8.0 হুবহু লিখে রাখা (পর্ব ১২-এর lockfile), যাতে model যেখানে খোলা হবে সেখানে একই version থাকে। দুই, model-এর পাশে metadata: scikit-learn আর Python-এর version, কলামের নাম, train-এর সারির সংখ্যা, cross-validation-এর score; scikit-learn-এর নথিও প্রশিক্ষণের ডেটার snapshot, কোড আর dependency-র version রাখার পরামর্শ দেয়। তিন, version বদলানোর সময় পুরোনো ফাইল খোলার চেষ্টা না করে কোড আর ডেটা থেকে model আবার train করা; সেজন্যই পুরো কাজ একটি pipeline-এ থাকা দরকার।

বিকল্প ফরম্যাট (সংক্ষেপে, সাবধানে): skops প্রকল্পের skops.io নিজস্ব ফরম্যাটে সংরক্ষণ করে, যা খোলার সময় অচেনা type পেলে থামে আর তালিকা দেখায়; লেখার সময়ের 0.16 version-এ আমাদের pipeline-এর জন্য তালিকায় এসেছিল শুধু numpy.dtype, যা হাতে অনুমোদন দিলে খোলে। তবে এর নিরাপত্তা নির্ভর করে আপনি কোন type বিশ্বাস করছেন তার ওপর, আর এটিও একই version-এর পরিবেশ চায়। ONNX (Open Neural Network Exchange) একটি খোলা model ফরম্যাট; skl2onnx দিয়ে অনেক scikit-learn model রূপান্তর করে Python ছাড়াই অন্য runtime-এ চালানো যায়। কিন্তু সব transformer সমর্থিত নয়, নিজের লেখা UnitPrice-এর মতো ধাপের জন্য আলাদা converter লাগে, আর ফল মূল model-এর সঙ্গে মিলিয়ে দেখা দরকার। ONNX রূপান্তর এই পর্বে চালানো হয়নি, তাই ওপরের কথাগুলো শুধু বর্ণনা; skops-এর ফলটি একটি ডেস্কটপ run (scikit-learn 1.8.0).

১০ · AI-এর যুগে — AI assistant-এর লেখা pipeline যাচাই

“এই CSV থেকে ফেরতের model বানাও” বললে AI assistant প্রায়ই চলনসই কোড দেয়, কিন্তু কয়েকটি ভুল ঘুরেফিরে আসে, কারণ ইন্টারনেটের পুরোনো টিউটোরিয়ালে এগুলোই বেশি।

AI যা প্রায়ই করেকীভাবে ধরবেন
pd.get_dummies আর fit_transform পুরো ডেটায়, তারপর splitপ্রথম fit-এর লাইন খুঁজুন; সব preprocessing একটি Pipeline-এ, split-এর পরে
পুরোনো API: OneHotEncoder(sparse=False), get_feature_names()নিজের version-এর docs দেখুন: এখন sparse_output আর get_feature_names_out
বানানো parameter, যেমন Pipeline(..., handle_missing=True)চালিয়ে দেখুন; set_params আর constructor অচেনা নামে error দেয়
handle_unknown বাদ, তাই নতুন category-তে অনুমান ভাঙেtrain-এ নেই এমন category দিয়ে একটি নতুন সারি বানিয়ে predict চালান
নিজের transformer-এর __init__-এ হিসাব, underscore ছাড়া শেখা মানclone করে get_params মিলিয়ে দেখুন; fit-এর আগে transform ডেকে NotFittedError আসে কি না দেখুন
training set-এ score, বা warning চাপা দিতে warnings.filterwarnings("ignore")score কোন ডেটায়, জিজ্ঞেস করুন; warning চাপা দেওয়ার লাইন মুছে আবার চালান
অজানা উৎস থেকে নামানো model joblib.load করতে বলেনিজে train করুন বা উৎস ও hash যাচাই করুন; pickle খোলা মানে কোড চালানো

কাঠামো আর শর্ত নিজে বলে দিন। আসল গ্রাহকের ডেটা পেস্ট করবেন না; কলামের নাম আর কয়েকটি কাল্পনিক সারিই যথেষ্ট।

আমার returns.csv-তে কলাম: order_id, district, category, payment (লেখা, কিছু ফাঁকা), price, qty, discount_pct, delivery_days (সংখ্যা, কিছু ফাঁকা), first_order (0/1), returned (লক্ষ্য, 0/1). Python 3.14, pandas 3 আর scikit-learn 1.8.0 দিয়ে কোড লেখো যা: ১. order_id বাদ দেবে আর সবার আগে stratify দিয়ে 75/25 ভাগ করবে। ২. একটি Pipeline বানাবে: ColumnTransformer (সংখ্যায় median imputer + StandardScaler, লেখায় most_frequent imputer + OneHotEncoder(handle_unknown="ignore")), remainder="passthrough", তারপর LogisticRegression(max_iter=1000). Pipeline-এর বাইরে কোনো fit থাকবে না। ৩. train অংশে StratifiedKFold(5)-এ পুরো pipeline-এর ROC-AUC-এর গড় আর sd, তারপর test-এ একবার। ৪. get_feature_names_out দিয়ে model-এর কলামের নাম ছাপাবে, আর train-এ নেই এমন একটি district দিয়ে একটি নতুন সারিতে predict_proba দেখাবে। ৫. joblib দিয়ে সংরক্ষণ করবে, পাশে একটি JSON-এ scikit-learn-এর version আর কলামের তালিকা রাখবে। কোনো warning চাপা দেবে না, কোনো deprecated API ব্যবহার করবে না।

AI-এর লেখা pipeline হাতে পেলে — যাচাইয়ের চেকলিস্ট

আরও গভীরে: Buitinck ও সহকর্মীদের ২০১৩ সালের লেখাটি শুধু ইতিহাস নয়, নিজের library-র API নকশার একটি ভালো পাঠ্য: কেন hyperparameter আর শেখা মান আলাদা, কেন fit self ফেরত দেয়, কেন নতুন ধরনের বস্তুর বদলে সাধারণ array. D. Sculley ও সহকর্মীরা ২০১৫ সালে NeurIPS-এ “Hidden Technical Debt in Machine Learning Systems” লেখায় দেখান, বাস্তব ML ব্যবস্থার বেশিরভাগ কোড model নয়, তার চারপাশের ডেটা-প্রস্তুতির “glue code” আর জট পাকানো “pipeline jungle”; একটি pipeline বস্তু সেই জট কমানোর ছোট কিন্তু কার্যকর হাতিয়ার। scikit-learn-এর নিজের check_estimator function একটি নতুন estimator API মানে কি না, তার অনেকগুলো স্বয়ংক্রিয় পরীক্ষা চালায়।

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

  • Pipeline কেন leakage ঠেকায়? fit-এ প্রতিটি ধাপ শুধু দেওয়া train ডেটায় শেখে; cross-validation পুরো pipeline-কে প্রতি fold-এ clone করে fit করে, তাই test fold কোনো ধাপের শেখায় ঢোকে না।
  • fit, transform আর fit_transform-এর পার্থক্য? fit শেখে, transform শেখা মানে বদলায়, fit_transform দুটো একসাথে; নতুন ডেটায় শুধু transform.
  • ৪টি সংখ্যার কলাম, ৬টি জেলা, ৫টি ধরন, ৪টি পরিশোধ আর একটি passthrough কলাম: model কয়টি কলাম পায়? ৪ + ৬ + ৫ + ৪ + ১ = ২০.
  • handle_unknown="ignore" কী করে? train-এ না দেখা category এলে error না দিয়ে সেই feature-এর সব one-hot কলাম ০ রাখে।
  • অজানা উৎসের .joblib ফাইল খোলা কেন বিপজ্জনক? joblib pickle-ভিত্তিক; load-এর সময় ফাইলের ভেতরের নির্দেশ অনুযায়ী যেকোনো Python কোড চলতে পারে।

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

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

প্র ০১scikit-learn-এর “সব কিছুর fit আর predict আছে” নিয়মটি এত সফল হলো কেন, আর এই কঠোরতার দাম কী?

২০০০-এর দশকের শেষে Python-এ machine learning-এর কোড ছড়িয়ে ছিল নানা গবেষণাগারের নিজস্ব script-এ, প্রতিটির আলাদা ভঙ্গি। scikit-learn শুরু হয়েছিল একটি Google Summer of Code প্রকল্প হিসেবে, পরে ফরাসি গবেষণা প্রতিষ্ঠান INRIA-র একদল ডেভেলপারের হাতে বড় হয়। ২০১৩ সালে Lars Buitinck ও সহকর্মীরা যখন API নকশার অভিজ্ঞতা লেখেন, তাঁদের যুক্তি ছিল সরল: যদি প্রতিটি algorithm একই তিনটি ক্রিয়া মানে, তাহলে algorithm বদলানো মানে এক লাইন বদলানো, আর মূল্যায়ন, cross-validation, grid search একবার লিখলেই সবার জন্য চলে।

এই নিয়মের সবচেয়ে বড় ফল এসেছে প্রকল্পের বাইরে। অন্য অনেক library নিজেদের model-এর জন্য scikit-learn-ধাঁচের wrapper দেয়, যাতে সেগুলো একই Pipeline আর cross_val_score-এ বসানো যায়; gradient boosting-এর জনপ্রিয় কিছু library-তে XGBClassifier বা LGBMClassifier-এর মতো class এর পরিচিত উদাহরণ। একটি নিয়ম এভাবে একটি পুরো ecosystem-এর সাধারণ ভাষা হয়ে গেছে, যেমন বিভিন্ন কোম্পানির charger একই USB প্লাগে চলে।

দাম আছে। transform শুধু X নেয়, y নয়, আর সারির সংখ্যা বদলানোর কথা নয়; তাই যে ধাপ সারি যোগ বা বাদ দেয় (যেমন class imbalance সামলাতে oversampling), তা এই কাঠামোয় আঁটে না। imbalanced-learn-এর মতো library সেজন্য নিজস্ব Pipeline রাখে। Deep learning-এর বিশাল model, যেখানে training কয়েক দিন চলে আর মাঝপথে থামিয়ে আবার শুরু করতে হয়, সেখানেও এক লাইনের fit প্রায়ই যথেষ্ট নয়। আর নিয়ম ভাঙলে (যেমন __init__-এ হিসাব) সমস্যা চুপচাপ থাকে, যতক্ষণ না clone ভুল copy বানায়।

মূল উপলব্ধি: একটি ছোট, কঠোর চুক্তি টুকরোগুলোকে বদলযোগ্য করে আর হাজারো টুল একসঙ্গে চলতে দেয়; যেখানে কাজটি চুক্তিতে আঁটে না, সেখানে চুক্তি বাঁকানোর বদলে আলাদা নকশা দরকার।

প্র ০২Model ফাইল ভাগাভাগি এখন সাধারণ ঘটনা। Pickle-ভিত্তিক ফাইলের ঝুঁকি বাস্তবে কতটা, আর সম্প্রদায় কীভাবে সাড়া দিয়েছে?

Python-এর pickle-এর নকশাই এমন যে খোলার সময় ফাইল নিজে বলে দিতে পারে কোন function কোন argument দিয়ে ডাকতে হবে; এতে জটিল বস্তু ফেরানো সহজ হয়, কিন্তু একই কারণে ফাইলটি কার্যত একটি প্রোগ্রাম। Python-এর অফিসিয়াল নথি বহু বছর ধরে লাল বাক্সে সতর্ক করছে: শুধু বিশ্বস্ত ডেটা unpickle করুন। এই পর্বের ২১২ বাইটের পরীক্ষায় দেখেছেন, কোনো method ডাকার আগেই কোড চলে।

যতদিন model ফাইল একটি দলের ভেতরে থাকত, ঝুঁকি ছিল সীমিত। কিন্তু এখন model hub থেকে অপরিচিত মানুষের ফাইল নামানো রোজকার কাজ। কয়েকটি নিরাপত্তা প্রতিষ্ঠান ২০২৩–২০২৪ সালে প্রকাশ্যে জানিয়েছিল যে জনপ্রিয় model hub-এ এমন ফাইল পাওয়া গেছে, যা খুললে ক্ষতিকর কোড চলার চেষ্টা করে; এগুলোর বেশিরভাগ pickle-ভিত্তিক ফরম্যাট। সাড়া এসেছে কয়েক দিক থেকে। Deep learning-এর ওজন রাখতে safetensors-এর মতো ফরম্যাট জনপ্রিয় হয়েছে, যা শুধু সংখ্যার array রাখে, কোড নয়। Model hub-গুলো আপলোড করা ফাইল স্ক্যান করে সতর্কবার্তা দেখায়। scikit-learn-এর নথি এখন persistence-এর বিকল্পগুলোর একটি তুলনা দেয়, যেখানে skops আর ONNX-কে pickle-এর ঝুঁকি কমানোর পথ হিসেবে দেখানো হয়।

তবু কোনো ফরম্যাট বিশ্বাসের প্রশ্ন পুরো মুছে দেয় না। skops-এ আপনিই ঠিক করেন কোন type বিশ্বাস করবেন; ONNX-এর জন্যও scikit-learn-এর নথি sandbox-এ চালানোর পরামর্শ দেয়। Cybersecurity-র ভাষায় এটি অনিরাপদ deserialization-এর পুরোনো সমস্যা, Java বা PHP-তেও যার দীর্ঘ ইতিহাস আছে। বাজারঘরের মতো দলের জন্য ব্যবহারিক নিয়ম: নিজের train করা model নিজের নিয়ন্ত্রিত জায়গায় রাখা, ফাইলের hash লিখে রাখা, আর বাইরের model শুধু পরিচিত উৎস থেকে, সম্ভব হলে কোডহীন ফরম্যাটে।

মূল উপলব্ধি: Pickle-ভিত্তিক model ফাইল খোলা মানে অন্যের লেখা কোড চালানো; বিশ্বাস ফাইলের নাম বা এক্সটেনশন থেকে নয়, উৎস আর যাচাই থেকে আসে।

প্র ০৩Training-এর সময় আর ব্যবহারের সময় preprocessing ভিন্ন হলে কী হয়? “training-serving skew” ঠেকাতে pipeline কতটা যথেষ্ট?

অনেক প্রকল্পে training হয় notebook-এ, আর ব্যবহার হয় অন্য দলের লেখা একটি ওয়েব সার্ভিসে। Notebook-এ কেউ দামের ফাঁকা ঘরে median বসিয়েছিলেন; সার্ভিসের ডেভেলপার জানতেন না, তিনি ০ বসালেন। কিংবা notebook-এ log নেওয়া হয়েছিল, সার্ভিসে নয়। Model কোনো error দেয় না, শুধু অন্য রকম সংখ্যা পায় আর ভুল অনুমান করে। একে বলে training-serving skew. Google-এর Martin Zinkevich তাঁর বহুল পঠিত “Rules of Machine Learning” নির্দেশিকায় এই সমস্যাকে আলাদা করে গুরুত্ব দিয়েছেন, আর পরামর্শ দিয়েছেন training আর serving-এ যতটা সম্ভব একই কোড ব্যবহার করতে।

scikit-learn pipeline ঠিক এই কাজটিই করে: preprocessing আর model এক বস্তুতে, এক ফাইলে। অংশ ৮-এ দেখেছেন, ফিরে পড়া pipeline ফাঁকা দামে train-এর median নিজেই বসায়; সার্ভিসের ডেভেলপারকে median জানতে হয় না। অংশ ৭-এর প্রথম বাগ, নতুন ব্যাচে scaler আবার fit, এই skew-এরই একটি রূপ, আর pipeline-এ কোনো আলাদা scaler না থাকায় সেটি ঘটার সুযোগ কমে।

কিন্তু pipeline সব ঢাকে না। ডেটা pipeline-এ ঢোকার আগের ধাপ, যেমন ডেটাবেস থেকে কলাম আনা, “গত ৩০ দিনের অর্ডার” গোনা, বা ওয়েব ফর্মের string-কে সংখ্যায় বদলানো, প্রায়ই pipeline-এর বাইরে থাকে। অংশ ৭-এর dtype-এর বাগ সেই সীমানায় ঘটেছিল। বড় প্রতিষ্ঠানগুলো এজন্য feature store, ইনপুটের schema যাচাই আর ব্যবহারের ডেটার নিয়মিত পর্যবেক্ষণ ব্যবহার করে; D. Sculley ও সহকর্মীদের ২০১৫ সালের “Hidden Technical Debt” লেখাও এই সীমানার জটকে ML ব্যবস্থার বড় ঋণ বলেছে। এই পর্যবেক্ষণ আর maintenance-এর বড় ছবি পর্ব ৭৪ (AI প্রকল্পের জীবনচক্র)-এ আসবে।

মূল উপলব্ধি: Pipeline training আর ব্যবহারের মাঝের preprocessing-কে এক কোডে বাঁধে, যা skew-এর সবচেয়ে সাধারণ উৎস বন্ধ করে; তবে pipeline-এর দরজার বাইরের ডেটা আলাদা চুক্তি আর পাহারা চায়।

প্র ০৪Pandas-এর DataFrame আর scikit-learn-এর NumPy array-র মাঝের দূরত্ব কীভাবে কমেছে? ColumnTransformer, get_feature_names_out আর set_output কোন সমস্যার উত্তর?

শুরুর দিকে scikit-learn শুধু NumPy array বুঝত: কলামের নাম নেই, সব কলাম একই ধরনের সংখ্যা। অথচ বাস্তবের ডেটা থাকে pandas DataFrame-এ, নাম আর মেশানো ধরনসহ। এই দূরত্ব মেটাতে কয়েক বছর ধরে লোকে নিজস্ব সমাধান বানাতেন; sklearn-pandas নামের একটি বাইরের library-র “DataFrameMapper” ছিল এর পরিচিত উদাহরণ, যা কলাম ধরে আলাদা transformer বসাত। আরেকটি সাধারণ অভ্যাস ছিল আগে পুরো ডেটায় pd.get_dummies, যা নিজেই leakage আর নতুন category-র সমস্যা আনত।

২০১৮ সালের 0.20 version-এ scikit-learn নিজেই ColumnTransformer আনে, প্রায় একই সময়ে বর্তমান রূপের OneHotEncoder লেখার category সরাসরি নিতে শেখে। এতে mixed-type preprocessing প্রথমবার পুরোপুরি pipeline-এর ভেতরে এল। কিন্তু একটি অস্বস্তি থেকে গেল: রূপান্তরের পরে কলামগুলো আবার নামহীন array. Model-এর সহগ পড়তে গেলে জানতে হতো কোন সংখ্যা কোন কলামের। Version 1.0 (২০২১)-এ এল সব transformer-এ একরকম get_feature_names_out, আর 1.2 (২০২২)-এ set_output(transform="pandas"), যাতে ফল সরাসরি নামসহ DataFrame হয়। পরের version-গুলোতে polars-এর জন্যও একই ধরনের output যুক্ত হয়েছে।

এই ইতিহাস থেকে একটি ব্যবহারিক শিক্ষা: ইন্টারনেটের পুরোনো কোড, আর তা থেকে শেখা AI assistant, প্রায়ই পুরোনো পথ দেখায়, যেমন get_feature_names() (এখন সরানো) বা sparse=False (এখন sparse_output). Trade-off-ও আছে: pandas output সুবিধাজনক, কিন্তু খুব বড়, বেশিরভাগ শূন্যের one-hot ডেটায় sparse matrix অনেক কম মেমোরি নেয়; তাই set_output সবসময় সঠিক পছন্দ নয়।

মূল উপলব্ধি: নামসহ কলাম শুধু সৌন্দর্য নয়, যাচাইয়ের হাতিয়ার; model আসলে কী দেখছে, তা get_feature_names_out দিয়ে পড়তে পারা leakage আর ভুল কলাম ধরার সবচেয়ে সস্তা উপায়।

প্র ০৫একটি model-এর কোন অংশ “ফাইলে রাখার মতো”, আর কোন অংশ কোডে থাকা উচিত? Model ফাইল আর পুনরুৎপাদনযোগ্য প্রকল্পের সম্পর্ক কী?

একটি fit করা pipeline-এ তিন ধরনের জিনিস থাকে: সেটিং (hyperparameter), শেখা সংখ্যা (median, গড়, সহগ), আর সেগুলো কীভাবে ব্যবহার হবে তার কোড (scikit-learn-এর class, আর নিজের লেখা UnitPrice). Pickle বা joblib প্রথম দুটি সংরক্ষণ করে, কিন্তু কোড নয়; ফাইল খোলার সময় Python সেই class-গুলো import করে খোঁজে। তাই অন্য version-এর scikit-learn-এ খুললে শেখা সংখ্যাগুলো এমন কোডের হাতে পড়ে, যা হয়তো সেগুলো অন্যভাবে বোঝে; আর নিজের class-এর কোড বদলে গেলে বা module খুঁজে না পেলে ফাইল খোলেই না।

এ কারণেই scikit-learn-এর নথি model ফাইলের পাশে training ডেটার snapshot, কোড, dependency-র version আর cross-validation score রাখার পরামর্শ দেয়। ব্যাপারটা রান্নার মতো: রান্না করা খাবার ফ্রিজে রাখা যায় (model ফাইল), কিন্তু রেসিপি আর বাজারের তালিকা (কোড আর ডেটা) না থাকলে খাবার শেষ হলে আবার একই স্বাদ আনা যায় না। পর্ব ১২-এর lockfile, data versioning আর Git এখানে সরাসরি কাজে লাগে: একই commit, একই lockfile আর একই ডেটা থেকে model আবার বানানো যাওয়া উচিত, আর তখন ফাইলটি শুধু একটি সুবিধাজনক cache.

বিভিন্ন দল এখানে ভিন্ন পথ নেয়। কেউ model ফাইলকেই “আসল জিনিস” ধরে model registry-তে রাখে; কেউ ONNX-এর মতো ফরম্যাটে রূপান্তর করে Python-নির্ভরতা কমায়, যাতে ব্যবহারের সার্ভিস অন্য ভাষায় লেখা যায়; কেউ প্রতিটি release-এ model আবার train করে। প্রতিটির দাম আছে: ফাইল রাখা দ্রুত কিন্তু version-এ বাঁধা, রূপান্তর বহনযোগ্য কিন্তু সব transformer সমর্থিত নয়, আবার train পরিষ্কার কিন্তু সময় আর ডেটার নিরবচ্ছিন্ন প্রাপ্যতা চায়।

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

অনুশীলন

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

  1. Pipeline হাতে চালানো (হিসাব): make_pipeline(SimpleImputer(strategy="median"), StandardScaler())-কে train-এর ডেলিভারির দিন ৩, ফাঁকা, ৫, ১০, ২ দিয়ে fit করা হলো। (ক) imputer-এর statistics_, scaler-এর mean_ আর scale_ (population sd) কত? (খ) নতুন দুটি অর্ডার, দিন ফাঁকা আর ৯: transform-এর ফল কত? (গ) কোনো pipeline-এ ৪টি সংখ্যার কলাম, ৬টি জেলা, ৫টি পণ্যের ধরন আর ৪টি পরিশোধের উপায়, আর remainder="drop" সহ একটি বাড়তি কলাম। Model কয়টি কলাম পাবে?
    • (ক) median: ২, ৩, ৫, ১০-এর মাঝের দুটির গড় (৩ + ৫)/২ = ৪. ফাঁকা ঘরে ৪ বসিয়ে ৩, ৪, ৫, ১০, ২; গড় ২৪/৫ = ৪.৮. বিচ্যুতির বর্গ ৩.২৪, ০.৬৪, ০.০৪, ২৭.০৪, ৭.৮৪, যোগ ৩৮.৮, variance ৭.৭৬, sd ≈ ২.৭৮৬.
    • (খ) ফাঁকা → ৪ → (৪ − ৪.৮)/২.৭৮৬ ≈ −০.২৮৭; ৯ → (৯ − ৪.৮)/২.৭৮৬ ≈ ১.৫০৮. নতুন ডেটা দেখে কিছুই নতুন শেখা হয়নি।
    • (গ) ৪ + ৬ + ৫ + ৪ = ১৯; "drop"-এ বাড়তি কলামটি বাদ যায়। "passthrough" হলে ২০.

    scikit-learn-এ যাচাই (ডেস্কটপ, সত্যিকারের run): statistics_ [4.], mean_ [4.8], scale_ [2.7857]; নতুন দুটির ফল [-0.287 1.508].

  2. নিজের transformer (কোড): একটি ClipOutliers transformer লিখুন, যার সেটিং low আর high (quantile, default ০.০৫ ও ০.৯৫), যা fit-এ প্রতিটি কলামের সেই দুটি quantile শেখে আর transform-এ মানগুলো সেই সীমায় কাটে। শর্ত: __init__-এ কোনো হিসাব নয়, শেখা মান underscore-যুক্ত, fit-এর আগে transform ডাকলে NotFittedError, আর transform-এ কলামের সংখ্যা fit-এর সঙ্গে না মিললে error. ১, ২, ৩, ৪, ১০০ দিয়ে high=0.75-এ পরীক্ষা করুন।
    import numpy as np
    from sklearn.base import BaseEstimator, TransformerMixin, clone
    from sklearn.utils.validation import check_is_fitted, validate_data
    
    class ClipOutliers(BaseEstimator, TransformerMixin):
        def __init__(self, low=0.05, high=0.95):
            self.low, self.high = low, high          # শুধু সেটিং
        def fit(self, X, y=None):
            X = validate_data(self, X)               # array বানায়, n_features_in_ লেখে
            self.lo_, self.hi_ = np.quantile(X, [self.low, self.high], axis=0)
            return self
        def transform(self, X):
            check_is_fitted(self)
            X = validate_data(self, X, reset=False)  # কলামের সংখ্যা fit-এর সঙ্গে মেলায়
            return np.clip(X, self.lo_, self.hi_)
        def get_feature_names_out(self, input_features=None):
            return np.asarray(input_features if input_features is not None
                              else [f"x{i}" for i in range(self.n_features_in_)], dtype=object)
    
    X = np.array([[1.0], [2.0], [3.0], [4.0], [100.0]])
    c = ClipOutliers(high=0.75).fit(X)
    print(c.lo_, c.hi_, c.transform([[0.0], [50.0]]).ravel())
    print(clone(c).get_params(), hasattr(clone(c), "hi_"))
    try:
        c.transform([[1.0, 2.0]])
    except ValueError as e:
        print("ValueError:", e)

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

    [1.2] [4.] [1.2 4. ]
    {'high': 0.75, 'low': 0.05} False
    ValueError: X has 2 features, but ClipOutliers is expecting 1 features as input.

    ৫টি মানের ৫% quantile: NumPy-র linear interpolation-এ অবস্থান ০.০৫ × ৪ = ০.২, অর্থাৎ ১ + ০.২ × (২ − ১) = ১.২; ৭৫%: অবস্থান ৩, অর্থাৎ ৪. তাই ০ হয় ১.২, ৫০ হয় ৪; ১০০-র মতো বড় মান আর model-কে টানতে পারে না। clone শুধু সেটিং নিয়ে নতুন শূন্য বস্তু বানিয়েছে (hi_ নেই), যা cross-validation প্রতি fold-এ করে। validate_data (scikit-learn 1.6 থেকে) n_features_in_ লেখে আর পরে কলামের সংখ্যা মেলায়; get_feature_names_out থাকায় এটি ColumnTransformer-এর ভেতরেও নামসহ কাজ করবে।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মীর কোডের সারাংশ: “df = pd.get_dummies(df); df = df.fillna(df.mean()); X = StandardScaler().fit_transform(df.drop(columns="returned")); তারপর train_test_split, LogisticRegression().fit, আর pickle.dump. ব্যবহারের সার্ভিসে নতুন অর্ডারে আবার pd.get_dummies আর নতুন StandardScaler().fit_transform.” কী কী ভুল, আর আপনি কীভাবে সাজাবেন?
    • Leakage: গড় দিয়ে fillna আর scaler ভাগের আগে পুরো ডেটায়; order_id থাকলে তাও feature হয়ে গেছে।
    • নতুন অর্ডারে কলাম মেলে না: একটি অর্ডারে get_dummies শুধু সেই অর্ডারের জেলার কলাম বানায়; কলামের সংখ্যা আর ক্রম train-এর সঙ্গে মিলবে না, আর অচেনা জেলার কোনো ব্যবস্থা নেই।
    • নতুন ডেটায় আবার fit: অংশ ৭-এর বাগ ১; একটি অর্ডারের ব্যাচে সব z শূন্য হবে।
    • সংরক্ষণ: শুধু model pickle হয়েছে, preprocessing নয়; version আর metadata নেই।
    • মূল্যায়ন: baseline, cross-validation বা পরিসর নেই।

    নতুন নকশা: ID বাদ, সবার আগে stratified ভাগ; ColumnTransformer (সংখ্যায় median imputer + scaler, লেখায় most_frequent imputer + OneHotEncoder(handle_unknown="ignore")) আর model একটি Pipeline-এ; train-এ পুরো pipeline-এর cross-validation, baseline-এর পাশে; test একবার; পুরো pipeline joblib-এ, পাশে version আর কলামের JSON; সার্ভিসে শুধু pipe.predict_proba, তার আগে ইনপুটের dtype যাচাই।

  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে? (সংকেত: remainder-এর default কী, আর ফল sparse না dense?)
    import pandas as pd
    from sklearn.compose import ColumnTransformer
    from sklearn.preprocessing import OneHotEncoder, StandardScaler
    
    X = pd.DataFrame({"city": ["ঢাকা", "খুলনা", "ঢাকা"], "pay": ["COD", "COD", "card"],
                      "price": [100, 300, 200], "qty": [1, 2, 3]})
    ct = ColumnTransformer([("num", StandardScaler(), ["price"]),
                            ("cat", OneHotEncoder(handle_unknown="ignore"), ["city", "pay"])])
    Xt = ct.fit_transform(X)
    print(type(Xt).__name__, Xt.shape)
    print(ct.get_feature_names_out().tolist())
    new = pd.DataFrame({"city": ["সিলেট"], "pay": ["COD"], "price": [200], "qty": [9]})
    print(ct.transform(new).round(2))

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

    ndarray (3, 5)
    ['num__price', 'cat__city_খুলনা', 'cat__city_ঢাকা', 'cat__pay_COD', 'cat__pay_card']
    [[0. 0. 0. 1. 0.]]

    remainder-এর default "drop", তাই qty নিঃশব্দে বাদ: ১ + ২ + ২ = ৫টি কলাম। OneHotEncoder একা sparse দেয়, কিন্তু ColumnTransformer জোড়া ফলের অশূন্য ঘরের অনুপাত দেখে; এখানে তা sparse_threshold (default ০.৩)-এর বেশি, তাই সাধারণ NumPy array. নতুন সারিতে দাম ২০০ = train-এর গড়, তাই z = ০; সিলেট অচেনা, তাই দুটি শহরের কলামই ০; COD-এর কলাম ১.

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

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

ক্রমকাঁচা CSV থেকে নতুন অর্ডারের অনুমান পর্যন্ত leakage-মুক্ত কাজের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. CSV পড়া, ID আর লক্ষ্য কলাম আলাদা করা
  2. Stratify দিয়ে train আর test ভাগ করা
  3. ColumnTransformer আর model একটি Pipeline-এ সাজানো
  4. Train অংশে পুরো pipeline-এর cross-validation
  5. পুরো train অংশে pipeline fit করে test-এ একবার মাপা
  6. Pipeline আর version-এর metadata সংরক্ষণ করে নতুন অর্ডারে predict_proba

তিনটি ধাপের একটি Pipeline (imputer, scaler, LogisticRegression)-এ pipe.fit(X, y) ডাকলে ভেতরে কোন ক্রমে ডাক হয়?

  • imputer.fit_transform, scaler.fit_transform, model.fit_transform
  • imputer.fit_transform, scaler.fit_transform, model.fit
  • imputer.transform, scaler.transform, model.fit
  • তিনটি ধাপই একই কাঁচা X-এ আলাদা fit

scikit-learn-এর estimator API-এর নিয়মে কোনটি ঠিক?

  • শেখা মান __init__-এ হিসাব করা উচিত, যাতে বস্তু তৈরি হতেই প্রস্তুত থাকে
  • Hyperparameter-এর নামের শেষে underscore থাকে, যেমন strategy_
  • transform প্রতিবার নতুন ডেটা দেখে mean_ হালনাগাদ করে
  • fit শেখা মান underscore-যুক্ত নামে রাখে আর self ফেরত দেয়

Train-এ জেলা ছিল ঢাকা, চট্টগ্রাম, সিলেট। OneHotEncoder(handle_unknown="ignore")-কে নতুন অর্ডারে “কুমিল্লা” দিলে কী হবে?

  • তিনটি জেলার কলামই 0 হবে, কোনো error নয়
  • ValueError: Found unknown categories
  • “কুমিল্লা”-র জন্য একটি নতুন কলাম যোগ হবে
  • সবচেয়ে বেশি দেখা জেলা (ঢাকা)-র কলামে 1 বসবে

৫-fold cross_val_score-কে একটি Pipeline (imputer, নিজের transformer, model) দিলে imputer মোট কতবার fit হয়?

  • একবার, পুরো ডেটায়
  • একবারও না, শুধু model fit হয়
  • ৫ বার, প্রতিটি fold-এর train অংশে একবার করে
  • ১০ বার, প্রতিটি fold-এর train আর test দুই অংশে

একটি ওয়েব ফর্ম থেকে নতুন অর্ডারের দাম এসেছে ফাঁকা string "" হিসেবে। Pipeline-এর প্রথম ধাপ SimpleImputer(strategy="median"). কী ঘটে?

  • Imputer train-এর median বসিয়ে দেয়
  • ValueError: সংখ্যায় বদলানো যায়নি, কারণ "" NaN নয়
  • মানটি চুপচাপ 0 ধরে নেওয়া হয়
  • দামের কলাম বাদ দিয়ে বাকিগুলো দিয়ে অনুমান হয়

একটি বন্ধু ইন্টারনেট থেকে নামানো model.joblib ফাইল দিয়ে বললেন, “শুধু joblib.load করুন, কিছু চালাতে হবে না।” সবচেয়ে সঠিক মন্তব্য কোনটি?

  • Load নিরাপদ; শুধু predict ডাকলে ঝুঁকি
  • .joblib এক্সটেনশন হলে ভেতরে কোড থাকতে পারে না
  • একই scikit-learn version থাকলে ফাইলটি নিরাপদ
  • joblib pickle-ভিত্তিক, তাই load-এর সময়ই কোড চলতে পারে; অচেনা উৎসের ফাইল খোলা উচিত নয়

শব্দকোষ

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

মনে রাখুন

  1. চুক্তি: সেটিং __init__-এ, শেখা fit-এ, শেখা মান underscore-যুক্ত নামে; transform আর predict কিছু শেখে না।
  2. Pipeline: fit-এ মাঝের ধাপে fit_transform, শেষে fit; predict-এ শুধু transform. ধাপ পেতে named_steps বা pipe[-1], সেটিং বদলাতে ধাপ__সেটিং.
  3. ColumnTransformer: সংখ্যায় imputer + scaler, category-তে imputer + OneHotEncoder(handle_unknown="ignore"); remainder-এর default "drop"; get_feature_names_out দিয়ে model-এর কলাম দেখুন।
  4. নিজের ধাপ আর CV: শেখে না এমন রূপান্তরে FunctionTransformer, শেখে এমনটিতে BaseEstimator + TransformerMixin + check_is_fitted; পুরো pipeline-এর ওপর cross_val_score প্রতি fold-এ সব ধাপ নতুন করে শেখায়।
  5. বাগ আর সংরক্ষণ: নতুন ডেটায় আবার fit, ভুলে যাওয়া handle_unknown, ইনপুটের dtype; joblib দিয়ে পুরো pipeline রাখুন, কিন্তু অচেনা pickle নয়, আর version pin করে metadata রাখুন।

পরের পর্বে: এ পর্যন্ত সব model শিখেছে label থেকে। পর্ব ৬৫ (Clustering আর PCA — label ছাড়া গঠন খোঁজা)-এ label ছাড়াই ডেটার গঠন খুঁজব: k-means নিজের হাতে, silhouette, hierarchical clustering, বাজারঘরের গ্রাহকদের দল ভাগ, আর PCA দিয়ে অনেক কলামকে কয়েকটিতে নামানো। Scaling কেন সেখানেও জরুরি, আর তা কেন pipeline-এর ভেতরেই থাকা উচিত, তাও দেখবেন।

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

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