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

Feature engineering — scaling, encoding আর নতুন feature

Feature Engineering — Scalers and Outliers, Log Transforms, Ratio and Interaction Features, Encoding Categories, Target Encoding with Cross-Fitting, Cyclical Time and Permutation Importance

Taught in Bangla, this lesson shows how scalers behave with outliers, how log, ratio and interaction features lift a logistic regression on fictional order data, and how to encode categories, including unseen and high-cardinality ones. Real runs show naive target encoding leaking while scikit-learn's TargetEncoder cross-fits, plus cyclical time features, missing indicators and the limits of permutation importance.

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

  • StandardScaler, MinMaxScaler আর RobustScaler কী হিসাব করে, আর outlier এলে কোনটি কীভাবে ভাঙে
  • বাঁকা বণ্টনে log ও power transform, binning, আর ratio ও interaction feature বানানো
  • One-hot আর ordinal encoding, prediction-এর সময় অচেনা category, আর হাজারো category সামলানোর উপায়
  • Target encoding-এর smoothing, naive সংস্করণের leakage আর scikit-learn-এর TargetEncoder-এর cross-fitting
  • sin/cos দিয়ে ঘণ্টা ও মাস, missing indicator, permutation importance আর তার ফাঁদ, আর কোন রূপান্তর কী শেখে

পর্ব ৫৬-এ শিখেছেন ডেটা আগে ভাগ করতে হয় আর প্রস্তুতির ধাপও leakage ঘটাতে পারে। পর্ব ৫৭-এ দেখেছেন scaling ছাড়া টাকার কলাম দূরত্ব দখল করে, পর্ব ৫৮-এ one-hot আর dummy-variable trap, পর্ব ৫৯-এ logistic regression আর পর্ব ৬০-এ ROC AUC. পর্ব ৬২ (Exploratory data analysis — প্রশ্ন, পরিসংখ্যান আর chart)-এ ডেটা দেখে প্রশ্ন তোলা হয়; এই পর্বে সেই প্রশ্নের উত্তর feature-এ রূপ দেব, আর প্রতিটি সিদ্ধান্ত held-out ডেটায় মাপব। চালানো যায় এমন ব্লকগুলো পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0). scikit-learn প্রথমবার নামতে প্রায় ১০ সেকেন্ড লাগে; পরের run দ্রুত।

১ · Feature engineering কী — model শুধু আপনার দেওয়া সংখ্যা দেখে

ডাক্তার একজন রোগীর ওজন ৮০ কেজি শুনে কিছু বলতে পারেন না; ১.৬ মিটার লম্বা মানুষের জন্য ৮০ কেজি এক কথা, ১.৯ মিটারের জন্য আরেক কথা। তাই তিনি একটি নতুন সংখ্যা বানান: BMI = ওজন ÷ উচ্চতা². Model-এর ক্ষেত্রেও তাই। সে নিজে থেকে “প্রতি পণ্যের দাম” ভাববে না, “রাত ১১টা আর রাত ১২টা কাছাকাছি” জানবে না, “গ্রামে লেখা ওজন” সন্দেহ করবে না। আপনি যে সংখ্যা দেবেন, সে শুধু সেগুলোই দেখবে।

Feature engineering মানে কাঁচা কলাম থেকে এমন feature বানানো, যাতে model সম্পর্কটা সহজে ধরতে পারে। কাজগুলো তিন পরিবারে পড়ে। রূপ বদল: একই তথ্য অন্য মাপে, যেমন scaling বা log. Category থেকে সংখ্যা: “cod”, “mobile”, “card” বা হাজারো পণ্যের ID-কে সংখ্যায় আনা। নতুন feature: অনুপাত, দুই কলামের গুণফল, তারিখ থেকে ঘণ্টা, “ফাঁকা ছিল কি না”।

পুরো পর্বের একটি নিয়ম আগেই বলে রাখি: যে রূপান্তর ডেটা থেকে কিছু শেখে (গড়, median, category-র তালিকা, লক্ষ্যের গড়), তা শুধু train অংশে fit হবে, আর test বা নতুন ডেটায় শুধু সেই শেখা মাপকাঠি দিয়ে transform. কেন, তা পর্ব ৫৬-এ দেখেছেন; অংশ ৯-এ প্রতিটি রূপান্তর কী শেখে, তার একটি তালিকা।

বাজারঘরের ফেরতের ডেটা

প্রশ্ন: একটি অর্ডার ফেরত আসবে কি না (returned, 0/1). নিচের কোড ৩০০০টি কাল্পনিক অর্ডার বানায়, প্রায় ১৯% ফেরত। কলাম: মোট দাম (value, টাকা), পণ্যের সংখ্যা, ছাড় ছিল কি না, প্রথম অর্ডার কি না, অর্ডারের ঘণ্টা আর মাস, পরিশোধের ধরন, ১৫০০টি সম্ভাব্য পণ্য-ID, আর ডেলিভারির দিন (১২% ফাঁকা)। এটি পাতায় bazarghar.py নামে রাখা আছে, তাই পরের ব্লকগুলো শুধু from bazarghar import make_orders লেখে (সঙ্গে import pandas, কারণ পাতা ব্লকের নিজের import দেখে প্যাকেজ নামায়)। ডেস্কটপে চালাতে চাইলে এই কোডটি একই নামে পাশে রাখুন।

# বাজারঘরের কাল্পনিক অর্ডার: সব সংখ্যা seed দিয়ে বানানো, বাস্তব নয়
import numpy as np
import pandas as pd

def make_orders(n=3000, seed=42):
    r = np.random.default_rng(seed)
    items = r.integers(1, 7, n)
    per_item = r.lognormal(np.log(450), 0.8, n)          # এক পণ্যের দাম, ডানে লম্বা লেজ
    value = np.round(items * per_item, -1)                # অর্ডারের মোট দাম (টাকা)
    discount = (r.random(n) < 0.35).astype(int)
    first = (r.random(n) < 0.30).astype(int)
    hour = r.integers(0, 24, n)
    month = r.integers(1, 13, n)
    pay = r.choice(["cod", "mobile", "card"], n, p=[0.5, 0.35, 0.15])
    product = r.choice([f"P{i:04d}" for i in range(1500)], n)   # ID-এর মতো, কোনো সংকেত নেই
    days = r.integers(1, 8, n).astype(float)
    miss = r.random(n) < 0.12
    days[miss] = np.nan                                   # courier স্ক্যান করেনি
    logit = (-2.4 + 1.1 * (np.log(per_item) - np.log(450))
             + 1.6 * discount * first + 0.9 * np.cos(2 * np.pi * hour / 24)
             + 0.9 * miss + 0.5 * (pay == "cod"))
    returned = (r.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    return pd.DataFrame({"value": value, "items": items, "discount": discount,
                         "first_time": first, "hour": hour, "month": month,
                         "pay": pay, "product_id": product, "delivery_days": days,
                         "returned": returned})

এই ডেটার একটি সুবিধা আর একটি সতর্কতা: logit লাইনটি বলে ফেরত কীসে বাড়ে: প্রতি পণ্যের দামের log, ছাড় × প্রথম অর্ডার, মধ্যরাত, ফাঁকা ডেলিভারির দিন আর cash on delivery; মাস, দিনের মান আর পণ্য-ID-র কোনো প্রভাব নেই। তাই কোন feature কাজে লাগা উচিত, তা মিলিয়ে দেখা যায়। বাস্তবে এই নিয়ম অজানা; সেখানে ধারণা আসে EDA আর ব্যবসার জ্ঞান থেকে, আর যাচাই হয় held-out score দিয়ে। পাতার সব সংখ্যা ছোট কাল্পনিক ডেটার, তাই seed বদলালে কিছুটা নড়ে; যেখানে জরুরি, কয়েকটি ডেটাসেটের ফল পাশাপাশি দেখানো হয়েছে।

২ · Scaling — StandardScaler, MinMaxScaler, RobustScaler আর একটি outlier

Feature scaling-এর তিনটি প্রচলিত পথ, প্রতিটি train থেকে দুটি সংখ্যা শেখে:

Scalerসূত্রtrain থেকে যা শেখেoutlier এলে
StandardScalerz = (x − μ) ÷ σগড় μ, standard deviation σ (population, ddof = 0)μ আর σ দুটোই outlier-এর দিকে টানে
MinMaxScaler(x − min) ÷ (max − min)সর্বনিম্ন আর সর্বোচ্চসর্বোচ্চটিই outlier, বাকিরা ০-এর কাছে চাপা পড়ে
RobustScaler(x − median) ÷ IQRmedian, IQR = Q3 − Q1প্রায় নড়ে না; outlier নিজে বড় থাকে

প্রথমটি standardization; দ্বিতীয়টি min-max scaling; তৃতীয়টি robust scaling, যা median আর IQR ব্যবহার করে (পর্ব ৩৭-এর robust statistic মনে করুন)। নিচের stepper পাঁচটি অর্ডারের দাম নিয়ে তিনটি scaler চালায়: ১০০, ২০০, ৩০০, ৪০০ টাকা আর একটি ৫০০০ টাকার outlier. সংখ্যাগুলো scikit-learn 1.8.0-এ মেলানো (ডেস্কটপ, সত্যিকারের run).

অর্ডারের দাম (টাকা): 100, 200, 300, 400, 5000
ধাপ
যা শেখা হলো
100, 200, 300, 400
5000
  1. কাঁচা মান। চারটি সাধারণ অর্ডার ১০০ থেকে ৪০০ টাকা, আর একটি ৫০০০ টাকা, হয়তো একটি বড় অর্ডার, হয়তো টাইপের ভুল।
  2. StandardScaler শেখে। গড় ১২০০, যা পাঁচটির চারটির চেয়েই বড়। σ = √((১১০০² + ১০০০² + ৯০০² + ৮০০² + ৩৮০০²) ÷ ৫) ≈ ১৯০২.৬. একটি মানই দুটি মাপকাঠি টেনে নিয়েছে।
  3. StandardScaler-এর ফল। চারটি সাধারণ মান −০.৫৭৮ থেকে −০.৪২০-এর ভেতরে, সবাই ঋণাত্মক আর প্রায় এক জায়গায়। ১০০ আর ৪০০ টাকার পার্থক্য প্রায় মুছে গেছে।
  4. MinMaxScaler. (x − ১০০) ÷ ৪৯০০. চারটি সাধারণ মান ০ থেকে ০.০৬১-এ; ০–১ পরিসরের বাকিটা ফাঁকা।
  5. RobustScaler. median ৩০০, IQR = ৪০০ − ২০০ = ২০০. চারটি মান −১, −০.৫, ০, ০.৫, আগের মতোই সমান দূরত্বে। Outlier ২৩.৫: সে বড়ই থাকে, কিন্তু বাকিদের মাপকাঠি নষ্ট করে না।
  6. তুলনা। সাধারণ চারটির ছড়ানো Standard-এ ০.১৫৮, MinMax-এ ০.০৬১, Robust-এ ১.৫. Outlier না থাকলে তিনটিই কাছাকাছি ছবি দিত (Standard −১.৩৪ থেকে ১.৩৪, MinMax ০ থেকে ১, Robust −১ থেকে ১)।

এখন দেখি model-এর score-এ এর প্রভাব। বাজারঘরের ৮০০টি ডেলিভারি: দূরত্ব মিটারে (১০০০–২৫০০০), পার্সেলের ওজন কেজিতে; দূরত্ব আর ওজন যত বেশি, দেরির সম্ভাবনা তত বেশি। কিন্তু ৩% সারিতে কেউ ওজন গ্রামে লিখেছেন (২.৬ কেজি হয়ে গেছে ২৬০০)। Model একটি k-NN (পর্ব ৫৭), যা দূরত্বে চলে, তাই scaling-এর প্রভাব সরাসরি দেখা যায়। algorithm="kd_tree" দেওয়া হয়েছে, কারণ ব্রাউজারের Python-এ default বিকল্পটি একটি অপ্রাসঙ্গিক warning ছাপে; প্রতিবেশী একই থাকে।

import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split

rng = np.random.default_rng(42)
n = 800
meter = rng.uniform(1000, 25000, n)                 # ডেলিভারির দূরত্ব, মিটারে
kg = rng.gamma(2.0, 1.5, n)                         # পার্সেলের ওজন, কেজিতে
p = 1 / (1 + np.exp(-(0.25 * (meter / 1000 - 13) + 0.9 * (kg - 3))))
late = (rng.random(n) < p).astype(int)              # দেরিতে পৌঁছেছে কি না
bad = rng.random(n) < 0.03                          # ৩% সারিতে ওজন গ্রামে লেখা
X = np.column_stack([meter, np.where(bad, kg * 1000, kg)])
X_tr, X_te, y_tr, y_te, bad_tr, _ = train_test_split(
    X, late, bad, test_size=0.3, random_state=42, stratify=late)
print("ভুল লেখা সারি:", bad.sum(), "| ওজনের median:", round(np.median(X[:, 1]), 2),
      "| সর্বোচ্চ:", round(X[:, 1].max()))

fig, axes = plt.subplots(3, 1, figsize=(7, 4.6), sharex=True)
scalers = [None, StandardScaler(), MinMaxScaler(), RobustScaler()]
for i, sc in enumerate(scalers):
    name = "none" if sc is None else type(sc).__name__
    A, B = (X_tr, X_te) if sc is None else (sc.fit(X_tr).transform(X_tr), sc.transform(X_te))
    acc = KNeighborsClassifier(15, algorithm="kd_tree").fit(A, y_tr).score(B, y_te)
    lo, hi = np.percentile(A[:, 1], [5, 95])        # train-এর মাঝের ৯০% ওজন
    print(f"{name:14s} test accuracy {acc:.3f} | মাঝের ৯০% ওজন: {lo:.2f} থেকে {hi:.2f}")
    if sc is not None:
        ax, w = axes[i - 1], A[~bad_tr, 1]
        ax.scatter(w, rng.random(w.size), s=5, alpha=0.5)
        ax.set_title(f"{name}: normal rows; outliers (not drawn) at "
                     f"{A[bad_tr, 1].min():.1f} to {A[bad_tr, 1].max():.1f}", fontsize=9)
        ax.set_yticks([]); ax.set_xlim(-2, 5)
axes[-1].set_xlabel("weight after scaling (train rows)")
fig.tight_layout()

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

ভুল লেখা সারি: 22 | ওজনের median: 2.6 | সর্বোচ্চ: 6738
none           test accuracy 0.654 | মাঝের ৯০% ওজন: 0.50 থেকে 9.67
StandardScaler test accuracy 0.658 | মাঝের ৯০% ওজন: -0.16 থেকে -0.14
MinMaxScaler   test accuracy 0.650 | মাঝের ৯০% ওজন: 0.00 থেকে 0.00
RobustScaler   test accuracy 0.792 | মাঝের ৯০% ওজন: -0.79 থেকে 2.73

Scaling ছাড়া (none) দূরত্ব হাজারের ঘরে আর ওজন একক অঙ্কে, তাই k-NN প্রায় শুধু দূরত্ব দেখে। StandardScaler আর MinMaxScaler দূরত্বের সমস্যা মিটিয়েছে, কিন্তু ওজনের মাপকাঠি ২২টি ভুল সারি দখল করেছে: বাকি সারিগুলোর মাঝের ৯০% ওজন একটি প্রায় শূন্য চওড়া ফালিতে (−০.১৬ থেকে −০.১৪, বা ০.০০ থেকে ০.০০)। দূরত্বের হিসাবে ওজন তাই কার্যত অদৃশ্য, আর accuracy scaling না করার সমান। RobustScaler-এ সেই ৯০% −০.৭৯ থেকে ২.৭৩ জুড়ে ছড়ানো, ওজন আবার কাজে লাগে, accuracy ০.৭৯. ছবিতে তিনটি প্যানেলের x-অক্ষ একই, তাই ফালি আর ছড়ানো পাশাপাশি দেখা যায়।

তবে মনে রাখুন, RobustScaler ভুল সারিগুলো ঠিক করেনি; ওগুলো এখনো ২৫৫ থেকে ২৫৮১-এ বসে আছে। ভালো সমাধান ভুলের উৎস খুঁজে (EDA-তে, পর্ব ৬২) গ্রামকে কেজি করা; robust scaling তখনকার প্রতিরক্ষা, যখন outlier সত্যিকারের বা সব ভুল ধরা যায় না।

কোন model-এ scaling লাগে: দূরত্ব বা দৈর্ঘ্যভিত্তিক সবকিছুতে: k-NN, PCA (পর্ব ৬৫), regularization-সহ linear ও logistic regression (জরিমানা প্রতিটি সহগে সমান, তাই কলামের একক তুলনীয় হওয়া চাই), আর gradient descent-এ চলা model (পর্ব ৫৮)। Tree model-এ সাধারণত লাগে না (অংশ ৮)। আরেকটি খুঁটিনাটি: MinMaxScaler train-এর পরিসর শেখে, তাই নতুন ডেটায় বড় মান এলে ১-এর বেশি হয় (অনুশীলন ৪ দেখুন)।

৩ · বাঁকা দাম, log আর নতুন feature — ratio আর interaction

অনলাইন দোকানের অর্ডারের দাম প্রায় সবসময় ডানে লম্বা লেজওয়ালা: বেশির ভাগ ছোট অর্ডার, অল্প কিছু অনেক বড়। বাজারঘরের ডেটায় গড় ২১৮২ টাকা কিন্তু median ১৪৩০; skewness (বাঁকার মাপ) প্রায় ৩.৪. Log transform এই লেজ গুটিয়ে আনে: ১০০ → ৪.৬, ১০০০ → ৬.৯, ১০০০০ → ৯.২. প্রতি দশগুণ বাড়ায় একই ২.৩ ধাপ। Linear model-এর জন্য এতে দুটি লাভ: কয়েকটি বিশাল অর্ডার সহগ টেনে নিতে পারে না, আর আনুপাতিক প্রভাব সরল হয় (২০০ থেকে ৪০০ আর ২০০০ থেকে ৪০০০ দুটোই “দ্বিগুণ”, log-এ একই দূরত্ব)।

শূন্য থাকলে np.log −∞ দেয়, তখন np.log1p (অর্থাৎ log(1 + x)). ঋণাত্মক মানও থাকলে বা কোন ঘাত ভালো তা ডেটা থেকে বের করতে চাইলে power transform: scikit-learn-এর PowerTransformer (default Yeo-Johnson; Box-Cox শুধু ধনাত্মক মানে)। এটি একটি λ শেখে, তাই অংশ ৯-এর নিয়মে পড়ে; সাধারণ log কিছু শেখে না। আরেকটি পথ binning: দামকে কয়েকটি পরিসরে ভাগ করা (KBinsDiscretizer, যা train থেকে সীমা শেখে)। এতে বাঁকা সম্পর্ক ধরা যায়, কিন্তু একই bin-এর ভেতরের পার্থক্য হারায়।

Ratio আর interaction

Ratio feature দুটি কলামের ভাগফল। ছয়টি পণ্যের ৩০০০ টাকার অর্ডার আর একটি পণ্যের ৩০০০ টাকার অর্ডার আলাদা জিনিস: দ্বিতীয়টিতে একটি দামি জিনিস, যা ফেরতের ঝুঁকি বেশি বহন করে। তাই per_item = value ÷ items. Interaction feature বলে একটির প্রভাব অন্যটির ওপর নির্ভর করে। বাজারঘরে ছাড় একা বা প্রথম অর্ডার একা ফেরত বাড়ায় না, কিন্তু প্রথমবারের গ্রাহক ছাড়ে কিনলে বেশি ফেরত দেন, হয়তো “দেখি কেমন” ভেবে কেনেন। Linear model ছাড়ের একটি সহগ আর প্রথম অর্ডারের একটি সহগ শেখে; “দুটো একসাথে হলে বাড়তি” বোঝাতে গুণফল discount × first_time নিজে দিতে হয়। পর্ব ৫৮-এর polynomial feature এই ধারণারই যান্ত্রিক রূপ, যা সব জোড়ার গুণফল বানায়; feature সংখ্যা তাতে দ্রুত বাড়ে, তাই জ্ঞান থেকে বাছা কয়েকটি interaction প্রায়ই ভালো।

নিচের ব্লক প্রতি ধাপে আগের সব রেখে একটি বদল আনে, আর একটি logistic regression-এর test AUC মাপে (০.৫ মানে কাকতাল, ১ মানে নিখুঁত ক্রম)। একটি ভাগের ফল নড়বড়ে, তাই পাঁচটি আলাদা কাল্পনিক ডেটাসেটে একই পরীক্ষা।

import numpy as np, pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from bazarghar import make_orders

df = make_orders(seed=0)
print(df[["value", "items", "discount", "first_time", "returned"]].head(3))
print("value: mean", round(df.value.mean()), "median", df.value.median(),
      "| skew raw", round(df.value.skew(), 2), "log", round(np.log(df.value).skew(), 2))

def versions(d):                                    # প্রতিটি ধাপে আগের সব রেখে একটি বদল
    f1 = d[["value", "items", "discount", "first_time"]]
    f2 = f1.assign(per_item=d.value / d["items"])                      # ratio
    f3 = f2.assign(value=np.log(d.value), per_item=np.log(f2.per_item))  # log
    f4 = f3.assign(disc_x_first=d.discount * d.first_time)             # interaction
    return {"1 raw": f1, "2 + per_item": f2, "3 + log": f3, "4 + disc x first": f4}

scores = {}
for seed in range(5):                               # ৫টি আলাদা কাল্পনিক ডেটাসেট
    d = make_orders(seed=seed)
    tr, te = train_test_split(d, test_size=0.3, random_state=0, stratify=d.returned)
    A, B = versions(tr), versions(te)
    for name in A:
        m = make_pipeline(StandardScaler(), LogisticRegression()).fit(A[name], tr.returned)
        auc = roc_auc_score(te.returned, m.predict_proba(B[name])[:, 1])
        scores.setdefault(name, []).append(auc)
res = pd.DataFrame(scores).T                        # সারি = feature-এর ধাপ, কলাম = seed
res["গড়"] = res.mean(axis=1)
print(res.round(3))

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

    value  items  discount  first_time  returned
0  3420.0      6         1           0         0
1  2140.0      4         0           0         0
2  1510.0      4         0           0         0
value: mean 2182 median 1430.0 | skew raw 3.4 log -0.16
                      0      1      2      3      4    গড়
1 raw             0.680  0.674  0.699  0.705  0.700  0.691
2 + per_item      0.694  0.690  0.725  0.720  0.722  0.710
3 + log           0.721  0.690  0.742  0.728  0.737  0.723
4 + disc x first  0.746  0.702  0.753  0.741  0.756  0.740

Log-এ skewness ৩.৪ থেকে প্রায় শূন্যে (−০.১৬)। AUC-এর গড় প্রতিটি ধাপে বেড়েছে: কাঁচা কলামে ০.৬৯১, প্রতি পণ্যের দাম যোগে ০.৭১০, log-এ ০.৭২৩, আর interaction যোগে ০.৭৪০. লক্ষ করুন, ডেটাসেট থেকে ডেটাসেটে AUC ০.০৩-এর মতো নড়ে, যা একটি ধাপের লাভের চেয়েও বড়; তাই একটি ভাগ দেখে রায় দেওয়া ঝুঁকিপূর্ণ। একই ডেটাসেটে জোড়ায় তুলনা (একই কলাম ধরে) বেশি নির্ভরযোগ্য, আর শেষ ধাপটি পাঁচটির পাঁচটিতেই প্রথম ধাপের চেয়ে ভালো।

সততার কথা: ডেটা এই নিয়মেই বানানো, তাই উন্নতি প্রত্যাশিত। বাস্তবে আপনি সঠিক রূপটি আগে জানবেন না; ভালো প্রার্থী আসে ব্যবসার জ্ঞান আর EDA থেকে, আর প্রতিটি প্রার্থী validation বা cross-validation-এ (পর্ব ৬৬) মাপতে হয়, test-এ নয়। অনেক প্রার্থী চেষ্টা করে সেরাটি বাছাই নিজেই একধরনের overfitting.

৪ · Category থেকে সংখ্যা — one-hot, ordinal, অচেনা আর অসংখ্য category

One-hot encoding আর dummy-variable trap পর্ব ৫৮-এ দেখেছেন: প্রতিটি category-র জন্য একটি 0/1 কলাম, যাতে model কোনো মিথ্যা ক্রম না ধরে। Nominal কলামে (পরিশোধের ধরন, জেলা) এটিই স্বাভাবিক। Ordinal কলামে (জামার মাপ S < M < L < XL, রেটিং) ক্রম সত্যিই আছে, তখন ordinal encoding একটি কলামে কাজ সারে। OrdinalEncoder-কে ক্রমটি নিজে বলে দিন, যেমন categories=[["S", "M", "L", "XL"]]; না দিলে সে বর্ণানুক্রমে সাজায় (L, M, S, XL), যা অর্থহীন।

Prediction-এর সময় অচেনা category

ধরুন বাজারঘর নভেম্বরে “voucher” দিয়ে পরিশোধ চালু করল। Model train হয়েছিল cod, mobile আর card দেখে। OneHotEncoder-এর handle_unknown ঠিক করে তখন কী হবে। Default "error": transform থেমে যায় (ValueError), যা চালু সিস্টেমে গ্রাহকের অর্ডার আটকে দিতে পারে। "ignore": অচেনা মানের সব one-hot কলাম ০, অর্থাৎ model তাকে “কোনো পরিচিত ধরন নয়” হিসেবে দেখে। "infrequent_if_exist": train-এ বিরল category-গুলোকে একটি “বাকি সব” কলামে রাখা থাকলে অচেনা মানও সেখানে যায়। scikit-learn 1.6 থেকে "warn"-ও আছে, যা warning দিয়ে তারপর আগেরটির মতো চলে। ভুল ডেটা থামাতে চাইলে error; সিস্টেম চালু রাখতে চাইলে ignore, আর অচেনা মানের সংখ্যা লগে রাখা।

অসংখ্য category — high cardinality

পণ্য-ID-র মতো কলামে আলাদা মান হাজারো; একে বলে high cardinality. বাজারঘরের train অংশে ২১০০টি সারিতে ১১৪৩টি আলাদা ID, যার ৫৪৭টি মাত্র একবার এসেছে। One-hot দিলে ১১৪৩টি কলাম, যার বেশির ভাগ একটি সারির জন্য; model সেগুলো মুখস্থ করতে পারে, কিন্তু শেখার মতো কিছু পায় না। আর test-এর ২৩.৮% সারির ID train-এ নেই। উপায়গুলো:

  • বিরলদের এক কলামে: min_frequency=5 (৫ বারের কম দেখা সব ID একটি infrequent কলামে) বা max_categories=20.
  • গণনা বা হার: “এই পণ্য train-এ কতবার বিক্রি হয়েছে” (count encoding), বা লক্ষ্যের গড় (target encoding, অংশ ৫)। দুটোই train থেকে শেখা।
  • Feature hashing: প্রতিটি মানকে একটি hash function দিয়ে নির্দিষ্ট সংখ্যক কলামে পাঠানো (FeatureHasher); অভিধান লাগে না, নতুন ID-ও চলে, কিন্তু কিছু ID একই কলামে মেশে।
  • ID-র পেছনের তথ্য: ID-কে তার বৈশিষ্ট্যে বদলানো (পণ্যের শ্রেণি, দাম), আর অর্থহীন ID (অর্ডার নম্বর) বাদ দেওয়া; পর্ব ৫৬-এ দেখেছেন ID-তে ক্রম বা leakage লুকিয়ে থাকতে পারে।

সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0, pandas 3.1.1), অংশ ১-এর ডেটায়:

~/bazarghar-ai$ python highcard.py
{}                       কলাম: 1143
{'min_frequency': 5}     কলাম: 27
{'max_categories': 20}   কলাম: 20
test-এর কত ভাগ ID train-এ নেই: 0.238
[1. 3. 0. 2.]

প্রথম তিনটি লাইন OneHotEncoder(handle_unknown="infrequent_if_exist", ...) দিয়ে: min_frequency=5 রাখে ২৬টি ঘন ঘন ID আর একটি product_id_infrequent_sklearn কলাম, মোট ২৭টি; max_categories=20 রাখে ১৯টি সবচেয়ে ঘন ঘন ID আর একটি infrequent কলাম। শেষ লাইনে OrdinalEncoder(categories=[["S", "M", "L", "XL"]]) M, XL, S, L-কে দিয়েছে ১, ৩, ০, ২.

৫ · Target encoding — শক্তিশালী, কিন্তু নিজের উত্তর দেখে ফেলার ফাঁদ

Target encoding প্রতিটি category-কে একটি সংখ্যায় বদলায়: train-এ সেই category-র লক্ষ্যের গড়। জেলা “সিলেট”-এর ২০০টি অর্ডারের ২৫% ফেরত এলে সিলেটের মান ০.২৫. একটি কলামেই কাজ, হাজারো category হলেও। কিন্তু অল্প সারির category-র গড় অবিশ্বাস্য, তাই গড়কে সামগ্রিক গড়ের দিকে টানা হয় (shrinkage বা smoothing):

encoding = (n × category-র গড় + m × সামগ্রিক গড়) ÷ (n + m)

এখানে n সেই category-র train সারির সংখ্যা, আর m একটি smoothing শক্তি: n যত ছোট, মান তত সামগ্রিক গড়ের কাছে। scikit-learn-এর TargetEncoder-এ smooth একটি সংখ্যা (এই m) বা "auto" (default), যেখানে m ডেটা থেকে একটি empirical Bayes হিসাবে আসে। সমস্যা অন্য জায়গায়: একটি সারির encoding-এর হিসাবে তার নিজের label থাকলে feature-টি label-এর ছায়া হয়ে যায়। একবারই দেখা ID-র গড় তো তার নিজের label! এটি target leakage-এর একটি সূক্ষ্ম রূপ, কারণ কোনো ভবিষ্যতের কলাম নেই, শুধু হিসাবের ক্রম ভুল।

সমাধান cross-fitting: train-কে কয়েকটি fold-এ ভাগ করুন, আর প্রতিটি fold-এর সারির encoding বসান অন্য fold-গুলো থেকে হিসাব করে। কোনো সারি নিজের label দেখে নিজের feature পায় না। নিচের stepper আটটি অর্ডারে পুরোটা দেখায় (হাতে হিসাব; smoothing-এর সংখ্যা scikit-learn-এর TargetEncoder(smooth=2.0)-এ মেলানো)।

৮টি train অর্ডার · সামগ্রিক ফেরতের হার 4/8 = 0.5
ধাপ
#1 P07 · y=1
#2 P12 · y=0
#3 P31 · y=0
#4 P31 · y=1
#5 P44 · y=0
#6 P58 · y=1
#7 P63 · y=0
#8 P90 · y=1
সারাংশ
  1. ডেটা। আটটি অর্ডার, সাতটি আলাদা পণ্য-ID; শুধু P31 দুবার এসেছে। চারটি ফেরত, তাই সামগ্রিক হার ০.৫.
  2. Naive গড়। প্রতিটি ID-র গড় তার নিজের সারিসহ। একবার দেখা ছয়টি ID-র encoding হুবহু তাদের label: ১ মানে ফেরত, ০ মানে নয়। Model শিখবে “এই কলাম ১ হলেই ফেরত”, আর train-এ প্রায় নিখুঁত দেখাবে।
  3. Naive দিয়ে নতুন ID. Test-এর অর্ডারে P15 আর P77, যা train-এ নেই; দুটোই পায় সামগ্রিক গড় ০.৫. Train-এ যে কলামটি সবচেয়ে “শক্তিশালী” ছিল, নতুন ডেটায় তা কিছুই বলে না।
  4. Smoothing, m = ২. একবারের ফেরত (১ × ১ + ২ × ০.৫) ÷ ৩ = ০.৬৬৭, একবারের না-ফেরত ০.৩৩৩. মান মাঝের দিকে সরেছে, কিন্তু ক্রম একই: ০.৫-এর ওপরে মানেই ফেরত। Smoothing একা leakage সারায় না।
  5. Cross-fit, fold A. Train-কে দুই fold-এ ভাগ (প্রতিটিতে দুটি ফেরত): A = #১, #২, #৩, #৬; B = #৪, #৫, #৭, #৮. A-র সারির মান B থেকে: B-তে P07, P12, P58 নেই, তাই B-র গড় ০.৫; #৩-এর P31 B-তে আছে (#৪, ফেরত), তাই ১.
  6. Cross-fit, fold B. B-র সারির মান A থেকে: #৪-এর P31 A-তে ছিল (#৩, ফেরত নয়), তাই ০; বাকিরা ০.৫. এখন কোনো সারি নিজের label দেখেনি। একবারের ID-রা সবাই ০.৫, অর্থাৎ “কোনো তথ্য নেই”, যা সত্য। P31-এর মান এক উদাহরণের ওপর দাঁড়ানো, তাই এলোমেলো; বাস্তবে smoothing একে মাঝের দিকে টানে।

এখন বাজারঘরের ৩০০০টি অর্ডারে একসাথে দুটি জিনিস: (ক) one-hot-এ অচেনা “voucher”, (খ) কোনো সংকেত নেই এমন product_id-এর তিন রকম ব্যবহার: একেবারে বাদ, naive গড়, আর TargetEncoder. বাকি feature দুটি (প্রতি পণ্যের দামের log আর interaction) তিনটিতেই এক।

import numpy as np, pandas as pd
from sklearn.preprocessing import OneHotEncoder, TargetEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from bazarghar import make_orders

# ক) one-hot: train-এ যা দেখেনি, prediction-এর সময় তা এলে?
old = pd.DataFrame({"pay": ["cod", "mobile", "card"]})
new = pd.DataFrame({"pay": ["cod", "voucher"]})    # "voucher" নতুন পদ্ধতি
ohe = OneHotEncoder(handle_unknown="ignore", sparse_output=False).fit(old)
print(ohe.get_feature_names_out(), ohe.transform(new).tolist())
try:
    OneHotEncoder().fit(old).transform(new)        # default: handle_unknown="error"
except ValueError as e:
    print("ValueError:", str(e).split(" during")[0])

# খ) product_id: ১৫০০টি ID, কোনো সংকেত নেই। target encoding কী দেখায়?
df = make_orders(seed=42)
tr, te = train_test_split(df, test_size=0.3, random_state=0, stratify=df.returned)
print("train-এ আলাদা ID:", tr.product_id.nunique(), "| সারি:", len(tr))
base = lambda d: np.column_stack([np.log(d.value / d["items"]), d.discount * d.first_time])
means = tr.groupby("product_id").returned.mean()            # নিজের সারিসহ গড়
naive = lambda d: d.product_id.map(means).fillna(tr.returned.mean()).to_numpy()
enc = TargetEncoder(random_state=0)
cross_tr = enc.fit_transform(tr[["product_id"]], tr.returned).ravel()  # cross-fitting
cross_te = enc.transform(te[["product_id"]]).ravel()
plans = {"ID ছাড়া": (base(tr), base(te)),
         "naive TE": (np.c_[base(tr), naive(tr)], np.c_[base(te), naive(te)]),
         "TargetEncoder": (np.c_[base(tr), cross_tr], np.c_[base(te), cross_te])}
for name, (a, b) in plans.items():
    m = LogisticRegression().fit(a, tr.returned)
    auc_tr = roc_auc_score(tr.returned, m.predict_proba(a)[:, 1])
    auc_te = roc_auc_score(te.returned, m.predict_proba(b)[:, 1])
    print(f"{name:14s} train AUC {auc_tr:.3f}  test AUC {auc_te:.3f}")

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

['pay_card' 'pay_cod' 'pay_mobile'] [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]
ValueError: Found unknown categories ['voucher'] in column 0
train-এ আলাদা ID: 1143 | সারি: 2100
ID ছাড়া       train AUC 0.706  test AUC 0.752
naive TE       train AUC 0.958  test AUC 0.651
TargetEncoder  train AUC 0.708  test AUC 0.747

(ক) "ignore"-এ “voucher”-এর সারি পুরো শূন্য; default-এ transform থেমে গিয়ে বলে কোন অচেনা মান পাওয়া গেছে। কোন আচরণ চান, তা আগে থেকে ঠিক করা দরকার, চালু হওয়ার দিন নয়।

(খ) এটিই এই পর্বের সবচেয়ে জরুরি সংখ্যা। Naive target encoding train-এ AUC ০.৯৫৮ দেখায়, কারণ বেশির ভাগ ID-র মান তার নিজের label. Test-এ AUC ০.৬৫১, ID বাদ দেওয়া model-এর (০.৭৫২) চেয়ে অনেক খারাপ: model শিখেছে এই কলামে ভরসা করতে, আর test-এ কলামটি প্রায় সবার জন্য অর্থহীন। Train আর test-এর এই বিশাল ফাঁকই leakage-এর আঙুলের ছাপ। TargetEncoder-এর fit_transform cross-fitting করে (default cv=5; binary লক্ষ্যে StratifiedKFold), তাই train AUC ০.৭০৮, ID ছাড়া model-এর প্রায় সমান, আর test-ও প্রায় সমান: সৎ উত্তর, “এই কলামে কোনো তথ্য নেই”। ID ছাড়া model-এর test AUC train-এর চেয়ে বেশি হয়েছে শুধু এই একটি ভাগের কাকতালে।

একটি খুঁটিনাটি, যা অনেককে বিভ্রান্ত করে: TargetEncoder-এ fit(X, y).transform(X) আর fit_transform(X, y) এক নয়; scikit-learn-এর docs এটি স্পষ্ট বলে। প্রথমটি পুরো train-এর গড় (smoothing-সহ) দিয়ে train-কেই encode করে, তাই naive-এর মতোই ফাঁস করে। পাঁচটি কাল্পনিক ডেটাসেটে সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0); প্রতিটি জোড়া (train AUC, test AUC):

~/bazarghar-ai$ python te_seeds.py
seed  ID ছাড়া         naive TE        fit_transform   fit().transform(train)
0     (0.746, 0.747)  (0.959, 0.624)  (0.746, 0.748)  (0.96, 0.619)
1     (0.733, 0.704)  (0.963, 0.604)  (0.733, 0.704)  (0.965, 0.598)
2     (0.737, 0.752)  (0.958, 0.657)  (0.738, 0.745)  (0.96, 0.651)
3     (0.71, 0.742)   (0.956, 0.63)   (0.711, 0.741)  (0.957, 0.623)
4     (0.723, 0.758)  (0.961, 0.62)   (0.727, 0.753)  (0.963, 0.61)

প্রতিটি ডেটাসেটে একই ছবি। “auto” smoothing থাকা সত্ত্বেও fit().transform()-এ train ০.৯৬, test ০.৬০–০.৬৫. সুখবর: Pipeline.fit প্রতিটি transformer-এর fit_transform ডাকে, তাই train-এ cross-fitting আপনা থেকেই হয়, আর predict-এ পুরো train-এর গড়। ঝুঁকি হাতে আলাদা fit আর transform ডাকায়। Pipeline পর্ব ৬৪ (scikit-learn pipeline — fit, transform আর leakage-মুক্ত কাজ)-এর বিষয়।

৬ · ফাঁকা ঘর আর লেখা — missing indicator, দৈর্ঘ্য আর গণনা

পর্ব ২৩-এ missing value পূরণের কৌশল (imputation) দেখেছেন। Model-এর দিক থেকে একটি প্রশ্ন বাকি: “ফাঁকা ছিল” কথাটি নিজেই কি তথ্য? বাজারঘরে ডেলিভারির দিন ফাঁকা থাকে যখন courier পার্সেল স্ক্যান করেননি, আর সেসব অর্ডার বেশি ফেরত আসে। Median দিয়ে ঘর ভরালে এই ইঙ্গিত মুছে যায়। সমাধান একটি missing indicator: আলাদা 0/1 কলাম days_missing. scikit-learn-এ SimpleImputer(strategy="median", add_indicator=True) দুটো কাজ একসাথে করে, আর MissingIndicator শুধু flag বানায়। Imputer-এর median train থেকে শেখা, তাই সেটিও অংশ ৯-এর নিয়মে।

Missing flag কখন leakage: যদি কোনো মান ফলাফল জানার পরে ভরা হয় (ধরুন ফেরতের অর্ডারে ডেলিভারির দিন পরে কেউ খুঁজে লিখে দেন), তাহলে “ফাঁকা নেই” কথাটিই ফলের ইঙ্গিত দেয়। Flag-টি ভবিষ্যদ্বাণীর মুহূর্তে জানা থাকে কি না, তা পর্ব ৫৬-এর প্রশ্নেই যাচাই করুন: “এই তথ্য কখন জানা যায়?”

লেখা থেকে সহজ সংখ্যা। গ্রাহকের রিভিউ বা অর্ডারের নোট থেকে model-এর আগে কিছু সরল feature বানানো যায়: অক্ষরের সংখ্যা, শব্দের সংখ্যা, “!” কয়টি, সংখ্যা বা ফোন নম্বর আছে কি না। একটি সতর্কতা: Python-এর len() Unicode code point গোনে, তাই “ভালো” শব্দে ৪টি code point (ভ, া, ল, ো), যদিও চোখে দুটি অক্ষর; আর একই লেখা দুই রকম Unicode রূপে থাকলে দৈর্ঘ্যও আলাদা আসতে পারে, তাই আগে NFC normalization (পর্ব ২৩)। পুরো লেখাকে feature বানানো, যেমন TF-IDF, পর্ব ৬৭ (বাংলা লেখার ডেটা — tokenization, TF-IDF আর একটি classifier)-এ।

৭ · সময়ের feature — ঘণ্টা ২৩ আর ০ পাশাপাশি

তারিখ থেকে ঘণ্টা, সপ্তাহের দিন, মাস, ছুটির দিন কি না, “শেষ অর্ডারের পর কত দিন” বের করা পর্ব ২৬-এর .dt দিয়ে সহজ; সেখানে lag feature আর তার leakage-ও দেখেছেন। কিন্তু ঘণ্টাকে সরাসরি ০ থেকে ২৩ সংখ্যা হিসেবে দিলে একটি সমস্যা: রাত ১১টা (২৩) আর রাত ১২টা (০) মাত্র এক ঘণ্টার ব্যবধান, অথচ সংখ্যায় সবচেয়ে দূরে। Linear model ঘণ্টার একটি সহগ শেখে, অর্থাৎ “ঘণ্টা বাড়লে ফেরত বাড়ে” বা “কমে”, কিন্তু “মধ্যরাতের দুই পাশে বেশি” বোঝাতে পারে না।

Cyclical encoding ঘণ্টাকে ঘড়ির কাঁটার মতো একটি বৃত্তে বসায়। কোণ θ = 2π × ঘণ্টা ÷ 24 (π ≈ ৩.১৪১৬, তাই এক পাক 2π রেডিয়ান = ৩৬০°), আর দুটি কলাম: sin θ আর cos θ. মাসের জন্য পর্যায় (P) 12, সপ্তাহের দিনে 7. সাধারণ সূত্র: sin(2πx ÷ P), cos(2πx ÷ P).

ঘণ্টা বৃত্তে: (sin θ, cos θ) বিন্দু হিসেবে ২৪টি ঘণ্টা

Hour h placed at (sin 2πh/24, cos 2πh/24): 23 sits next to 0, and 12 is opposite

Cyclical encoding of the hour on a circle sin cos 061218231 0 ↔ 23: 0.26 0 ↔ 12: 2 কাঁচা সংখ্যায় 23 − 0 = 23, সবচেয়ে দূরে বৃত্তে দূরত্ব 0.26, ঠিক পাশের প্রতিবেশী
θ = 2π × h ÷ 24 · বিন্দু = (sin θ, cos θ)
ধাপ
h = 0
h = 6
h = 12
h = 23
দূরত্ব
  1. কাঁচা ঘণ্টা। সংখ্যা হিসেবে ২৩ আর ০-এর ব্যবধান ২৩, ০ আর ১২-এর মাত্র ১২. অথচ ঘড়িতে ২৩ আর ০ পাশাপাশি, ১২ ঠিক উল্টো দিকে।
  2. কোণ। প্রতি ঘণ্টা ৩৬০° ÷ ২৪ = ১৫°. ৬টা ৯০°, ১২টা ১৮০°, ২৩টা ৩৪৫°, আর ২৪ আবার ৩৬০° = ০°.
  3. বিন্দু। (sin θ, cos θ): ০টা (০, ১), ৬টা (১, ০), ১২টা (০, −১), ২৩টা (−০.২৫৯, ০.৯৬৬). সবাই ব্যাসার্ধ ১-এর বৃত্তে।
  4. ০ আর ২৩. দূরত্ব √((০ + ০.২৫৯)² + (১ − ০.৯৬৬)²) = √(০.০৬৭০ + ০.০০১২) ≈ ০.২৬১, যা ঠিক পাশের দুই ঘণ্টার দূরত্ব, যেমন ০ আর ১-এরও।
  5. দূরের তুলনা। ০ ↔ ৬: √২ ≈ ১.৪১৪; ০ ↔ ১২: ২, বৃত্তের ব্যাস। রাত ১০টা আর রাত ২টা (চার ঘণ্টা ব্যবধান) বৃত্তে ১.০, কাঁচা সংখ্যায় ২০. দূরত্ব এখন ঘড়ির সত্যিকারের ব্যবধানের সঙ্গে মেলে।
  6. দুটি কলাম কেন। sin একা রাত ১টা আর সকাল ১১টাকে একই ০.২৫৯ দেয়; cos তাদের আলাদা করে (০.৯৬৬ বনাম −০.৯৬৬)। দুটি মিলেই বৃত্তের প্রতিটি বিন্দু অনন্য।

Linear model sin আর cos-এর দুটি সহগ a, b শেখে। গণিতের একটি সূত্রে a sin θ + b cos θ = R cos(θ − φ), অর্থাৎ দিনে একটি শিখর, যেকোনো ঘণ্টায়: মধ্যরাতে, দুপুরে বা সন্ধ্যা ৭টায়, model ডেটা থেকে φ বেছে নেয়। দিনে দুটি শিখর হলে (দুপুর আর রাতে বেশি) এক জোড়া sin/cos যথেষ্ট নয়; তখন 2θ-এর sin/cos যোগ করা, ২৪টি ঘণ্টার one-hot, বা scikit-learn-এর SplineTransformer(extrapolation="periodic"). sin/cos কিছু শেখে না, তাই leakage-এর ঝুঁকি নেই।

৮ · কোন feature কাজে লাগল — permutation importance আর তার ফাঁদ

অনেক feature বানানোর পর প্রশ্ন: কোনটি সত্যিই কাজ করছে? Permutation importance একটি সরল, model-নিরপেক্ষ পরীক্ষা: held-out ডেটায় একটি কলামের মানগুলো সারিদের মধ্যে এলোমেলো করে দিন (অন্য সব অপরিবর্তিত), তারপর score কতটা কমল দেখুন। কলামটি জরুরি হলে score অনেক পড়বে; অপ্রয়োজনীয় হলে প্রায় নড়বে না। ক্রিকেটে একজন ব্যাটসম্যানকে বসিয়ে দলের রান কত কমে, দেখার মতো। sklearn.inspection.permutation_importance-এ n_repeats বলে কতবার এলোমেলো করবেন, আর scoring বলে কোন মাপ।

নিচের ব্লক প্রথমে ঘণ্টাকে কাঁচা আর sin/cos রূপে তুলনা করে, তারপর মাস, ডেলিভারির দিন আর missing flag যোগ করে, আর শেষ model-এ test অংশে permutation importance মাপে। Median শুধু train থেকে।

import numpy as np, pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.inspection import permutation_importance
from bazarghar import make_orders

df = make_orders(seed=42)
tr, te = train_test_split(df, test_size=0.3, random_state=0, stratify=df.returned)
med = tr.delivery_days.median()                     # median শুধু train থেকে
def feats(d):
    return pd.DataFrame({
        "log_per_item": np.log(d.value / d["items"]), "disc_x_first": d.discount * d.first_time,
        "cod": (d.pay == "cod").astype(int), "hour": d.hour,
        "hour_sin": np.sin(2 * np.pi * d.hour / 24), "hour_cos": np.cos(2 * np.pi * d.hour / 24),
        "month_sin": np.sin(2 * np.pi * d.month / 12), "month_cos": np.cos(2 * np.pi * d.month / 12),
        "days": d.delivery_days.fillna(med), "days_missing": d.delivery_days.isna().astype(int)})
A, B = feats(tr), feats(te)
keep = ["log_per_item", "disc_x_first", "cod"]
plans = {"hour raw": keep + ["hour"], "hour sin/cos": keep + ["hour_sin", "hour_cos"],
         "+ month, days, missing flag": keep + ["hour_sin", "hour_cos", "month_sin",
                                     "month_cos", "days", "days_missing"]}
for name, cols in plans.items():
    m = make_pipeline(StandardScaler(), LogisticRegression()).fit(A[cols], tr.returned)
    print(f"{name:28s} test AUC {roc_auc_score(te.returned, m.predict_proba(B[cols])[:, 1]):.3f}")

pi = permutation_importance(m, B[cols], te.returned, scoring="roc_auc",
                            n_repeats=20, random_state=0)
order = pi.importances_mean.argsort()[::-1]
for j in order:
    print(f"  {cols[j]:13s} AUC কমে {pi.importances_mean[j]:+.3f} ± {pi.importances_std[j]:.3f}")

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

hour raw                     test AUC 0.762
hour sin/cos                 test AUC 0.813
+ month, days, missing flag  test AUC 0.821
  log_per_item  AUC কমে +0.142 ± 0.013
  hour_cos      AUC কমে +0.097 ± 0.011
  disc_x_first  AUC কমে +0.046 ± 0.007
  days_missing  AUC কমে +0.017 ± 0.005
  cod           AUC কমে +0.008 ± 0.003
  hour_sin      AUC কমে +0.001 ± 0.003
  days          AUC কমে +0.000 ± 0.000
  month_cos     AUC কমে +0.000 ± 0.000
  month_sin     AUC কমে -0.000 ± 0.001

কাঁচা ঘণ্টায় AUC ০.৭৬২, sin/cos-এ ০.৮১৩: একই তথ্য, শুধু রূপ বদল। ডেস্কটপে আরও পাঁচটি কাল্পনিক ডেটাসেটে (seed ০–৪) একই তুলনা প্রতিবার sin/cos-এর পক্ষে গেছে (যেমন ০.৭১২ বনাম ০.৭৭৭)। মনে রাখবেন, ডেটা cos দিয়েই বানানো, তাই মিলটা এখানে আদর্শ; বাস্তবের চক্র এত পরিপাটি হয় না। মাস, দিন আর missing flag যোগে ০.৮২১.

Permutation importance ডেটা বানানোর নিয়মের সঙ্গে সুন্দর মেলে: প্রতি পণ্যের দাম, মধ্যরাতের চক্র (hour_cos), interaction আর missing flag আগে; মাস আর ডেলিভারির দিনের মান প্রায় শূন্য, কারণ নিয়মে তাদের কোনো ভূমিকা নেই। days শূন্য কিন্তু days_missing নয়: সংকেত ছিল “ফাঁকা কি না”-তে, দিনের সংখ্যায় নয়, আর flag ছাড়া median-ভরা কলাম সেটি হারাত। ± হলো ২০ বার এলোমেলো করার standard deviation; ছোট মানে তার চেয়ে কম হলে কলামটি শূন্য থেকে আলাদা বলা যায় না। এটি মাপুন held-out ডেটায়: train-এ মাপলে model যা মুখস্থ করেছে তাকেও “জরুরি” দেখায়।

ফাঁদ: প্রায় একই তথ্যের দুটি কলাম

ধরুন কেউ প্রতি পণ্যের দাম ডলারেও একটি কলাম যোগ করলেন, সামান্য গোলমালসহ; দুই কলামের correlation ০.৯৯৮. একটি এলোমেলো করলে অন্যটি প্রায় একই তথ্য দেয়, তাই score তেমন পড়ে না। সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0); প্রতিটি সংখ্যা test-এ AUC কতটা কমে:

~/bazarghar-ai$ python corr_trap.py
logistic copy=False log_per_item 0.149, disc_x_first 0.046, hour_cos 0.102
logistic copy=True  log_per_item 0.000, disc_x_first 0.046, hour_cos 0.101, log_per_item_usd 0.144
forest   copy=False log_per_item 0.136, disc_x_first 0.038, hour_cos 0.090
forest   copy=True  log_per_item 0.029, disc_x_first 0.034, hour_cos 0.087, log_per_item_usd 0.033

Logistic regression-এ সব ওজন চলে গেছে ডলারের কলামে, আর আসল log_per_item দেখাচ্ছে ০.০০০, যেন অপ্রয়োজনীয়। কোন কলাম ওজন পাবে, তা প্রায় কাকতালীয় (পর্ব ৫৮-এর multicollinearity). Random forest-এ (অনেক decision tree-এর গড়; পর্ব ৬৬-এ পরিচয়) গুরুত্ব দুই ভাগ হয়ে ০.০২৯ আর ০.০৩৩, যোগফলও আগের ০.১৩৬-এর অনেক কম। দুই ক্ষেত্রেই একজন পাঠক ভুল সিদ্ধান্তে পৌঁছাতে পারেন: “দামের তেমন প্রভাব নেই”। উপায়: correlated কলাম আগে খুঁজে (correlation matrix) দলকে একসাথে এলোমেলো করা, বা একটি রেখে বাকিগুলো বাদ দেওয়া। আর মনে রাখুন: permutation importance বলে এই model কোন কলামে নির্ভর করে, বাস্তবে কোন কারণে ফেরত আসে তা নয়।

Linear model আর tree model-এর চাহিদা আলাদা

কৌশলLinear / logisticTree, forest, boosting
Scalingregularization আর gradient descent-এ জরুরিলাগে না: split শুধু ক্রম দেখে
Log, powerপ্রায়ই অনেক সাহায্য করেপ্রায় প্রভাব নেই (ক্রম একই থাকে)
Interactionনিজে দিতে হয়পরপর split-এ নিজে ধরতে পারে, তবে বেশি ডেটা লাগে
Cyclical সময়sin/cos প্রায় জরুরিকাঁচা ঘণ্টাতেও দুটি split-এ ধরতে পারে
বড় one-hotচলে, regularization-সহহাজারো বিরল কলামে প্রায়ই দুর্বল; ordinal বা target encoding প্রচলিত
Outlierসহগ টেনে নেয়কম সংবেদনশীল, কিন্তু train-এর পরিসরের বাইরে extrapolate করতে পারে না

Decision tree কীভাবে split বাছে (information gain, পর্ব ৩৮), আর random forest ও gradient boosting কী, তা পর্ব ৬৬ (Model বাছাই — cross-validation আর hyperparameter search)-এ আসবে। মোট কথা, feature engineering model-নির্ভর।

৯ · কোন রূপান্তর কী শেখে — fit শুধু train-এ

পুরো পর্বের রূপান্তরগুলো দুই দলে পড়ে। কিছু শেখে না (stateless): log, sin/cos, দুটি কলামের ভাগফল বা গুণফল, লেখার দৈর্ঘ্য; প্রতিটি সারি একা হিসাব হয়, তাই ভাগের আগে বা পরে করলে একই ফল। ডেটা থেকে কিছু শেখে (stateful): এদের প্রতিটি fit-এ একটি মাপকাঠি মনে রাখে, আর সেটি শুধু train থেকে আসবে।

fit একবার train-এ, transform সব জায়গায়

Learned parameters come from the training rows only, then the same numbers are reused for test and for new orders

Fit on train only, transform everywhere train 2100 সারি fit μ, σ, median, IQR category, লক্ষ্যের গড় ✕ test থেকে fit নয় train transform test transform নতুন অর্ডার prediction একই শেখা সংখ্যা তিন জায়গায়; test কখনো fit-এ ঢোকে না
রূপান্তরfit-এ যা শেখেপুরো ডেটায় fit করলে
StandardScaler, MinMaxScaler, RobustScalerগড় ও sd, min ও max, median ও IQRTest-এর মান মাপকাঠিতে (preprocessing leakage); প্রভাব সাধারণত ছোট
PowerTransformer, KBinsDiscretizerλ, bin-এর সীমাএকই ধরনের ছোট leakage
SimpleImputerগড় বা medianছোট leakage
OneHotEncoder, OrdinalEncodercategory-র তালিকা, বিরলদের তালিকাTest-এর নতুন মান “চেনা” হয়ে যায়; অচেনা মান সামলানোর পরীক্ষা লুকায়
Count encodingপ্রতি category-র গণনাTest-এর গণনাও ঢোকে
TargetEncoder, naive গড়প্রতি category-র লক্ষ্যের গড়বড় leakage: label সরাসরি feature-এ; train-এর ভেতরেও cross-fitting লাগে
log, sin/cos, ratio, interaction, দৈর্ঘ্যকিছুই নাঝুঁকি নেই, তবে ভাগের আগে-পরে একই কোড চালান

মূল নিয়মটা পর্ব ৫৬-এর: label দেখে এমন ধাপ (target encoding, feature বাছাই) সবচেয়ে বিপজ্জনক, শুধু মান দেখে এমন ধাপ (scaler) কম; কিন্তু অভ্যাস একই রাখা সহজ: সব শেখা রূপান্তর split-এর পরে, train-এ fit. দ্বিতীয় নিয়ম: prediction-এর সময়ও হুবহু একই কোড আর একই শেখা সংখ্যা; নইলে model অচেনা ডেটা পায় (training-serving skew, ভাবনার প্রশ্ন ৫)। দুটোর সমাধান: সব ধাপ একটি Pipeline-এ, একবার fit, একসাথে সংরক্ষণ। ColumnTransformer দিয়ে বিভিন্ন কলামে বিভিন্ন ধাপ আর বাজারঘরের পুরো leakage-মুক্ত pipeline পর্ব ৬৪ (scikit-learn pipeline — fit, transform আর leakage-মুক্ত কাজ)-এ।

১০ · AI-এর যুগে — AI assistant-এর লেখা feature কোড যাচাই

“ভালো feature বানিয়ে দাও” বললে AI assistant দ্রুত অনেক কলাম বানায়, আর কোড চলে; ভুল লুকানো থাকে চলার মধ্যেই।

AI যা প্রায়ই করেকীভাবে ধরবেন
df.groupby("product_id")["returned"].transform("mean") দিয়ে পুরো ডেটায় target encodingTrain আর test AUC পাশাপাশি চান; বিশাল ফাঁক মানে leakage. TargetEncoder-এর fit_transform বা Pipeline চান
StandardScaler().fit_transform(X) split-এর আগেপ্রথম fit-এর লাইন খুঁজুন; তা split-এর পরে আর শুধু train-এ কি না
পুরোনো বা অস্তিত্বহীন parameter, যেমন OneHotEncoder(sparse=False) (এখন sparse_output), বা TargetEncoder(smoothing=...) (আসল নাম smooth)নিজের version-এর অফিসিয়াল docs দেখুন; কোড চালিয়ে সব warning পড়ুন
ঘণ্টা বা মাস কাঁচা সংখ্যা হিসেবে linear model-এচক্রাকার কলাম খুঁজে sin/cos বা one-hot চান
Impurity-ভিত্তিক বা train-এ মাপা importance দিয়ে “কারণ” ব্যাখ্যা করেHeld-out ডেটায় permutation importance, correlated কলাম আলাদা করে দেখা, আর “model-এর নির্ভরতা ≠ কারণ” মনে রাখা

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

আমার orders.csv-তে কলাম: value (টাকা, ডানে লম্বা লেজ), items, discount (0/1), first_time (0/1), hour (0–23), pay (cod/mobile/card, ভবিষ্যতে নতুন ধরন আসতে পারে), product_id (প্রায় ১৫০০টি আলাদা), delivery_days (কিছু ফাঁকা), returned (লক্ষ্য, 0/1). Python 3.14, pandas 3 আর scikit-learn 1.8 দিয়ে কোড লেখো যা: ১. সবার আগে stratified train/test ভাগ করবে; এর আগে কোনো fit বা গড় হিসাব থাকবে না। ২. log(value ÷ items), discount × first_time, hour-এর sin ও cos বানাবে; delivery_days-এ median imputation আর missing indicator দেবে। ৩. pay-তে OneHotEncoder(handle_unknown="ignore"), product_id-তে TargetEncoder; সব শেখা ধাপ একটি Pipeline/ColumnTransformer-এর ভেতরে। ৪. train AUC আর test AUC দুটোই ছাপাবে, সঙ্গে product_id বাদ দেওয়া একটি baseline. ৫. test অংশে permutation_importance ছাপাবে, আর কোনো deprecated parameter ব্যবহার করবে না।

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

আরও গভীরে: Feature hashing-এর একটি পরিচিত বিশ্লেষণ Kilian Weinberger ও সহকর্মীদের ICML ২০০৯-এর প্রবন্ধে, যেখানে দেখানো হয় কেন অনেক মানের ক্ষেত্রেও hash-এর সংঘর্ষ সাধারণত সামান্য ক্ষতি করে; scikit-learn-এর FeatureHasher এই ধারণায় চলে। scikit-learn-এর docs-এ “Permutation Importance with Multicollinear or Correlated Features” নামে একটি উদাহরণ correlated কলামকে hierarchical clustering দিয়ে দলবদ্ধ করার পথ দেখায়। আর দিনে একাধিক শিখরের চক্রের জন্য docs-এর “Time-related feature engineering” উদাহরণ sin/cos, one-hot আর periodic spline পাশাপাশি তুলনা করে।

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

  • Outlier থাকলে কোন scaler? RobustScaler: median আর IQR কয়েকটি বড় মানে প্রায় নড়ে না; তবে আগে outlier ভুল কি না যাচাই করুন।
  • [১০০, ২০০, ৩০০, ৪০০, ৫০০০]-এ RobustScaler-এর পর ৫০০০ কত? (৫০০০ − ৩০০) ÷ (৪০০ − ২০০) = ২৩.৫.
  • Target encoding-এ leakage কেন, আর সমাধান? নিজের সারির label গড়ে ঢোকে; cross-fitting (অন্য fold থেকে গড়) আর smoothing, সব train-এর ভেতরে।
  • ঘণ্টার জন্য sin আর cos দুটিই কেন? sin একা ১টা আর ১১টাকে একই মান দেয়; দুটি মিলে বৃত্তের প্রতিটি বিন্দু অনন্য, আর ২৩ ও ০ পাশাপাশি।
  • Permutation importance-এর একটি সীমা? প্রায় একই তথ্যের দুটি কলামে গুরুত্ব ভাগ হয় বা একটিতে সরে যায়, তাই দুটিকেই কম জরুরি দেখাতে পারে।

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

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

প্র ০১Target encoding দুই দশকের বেশি পুরোনো কৌশল। তাহলে এর leakage নিয়ে এত পরে কেন বিশেষ যন্ত্র (CatBoost-এর ordered statistics, scikit-learn-এর cross-fitting) বানাতে হলো?

Daniele Micci-Barreca ২০০১ সালে SIGKDD Explorations-এ high-cardinality category, যেমন ZIP code বা পণ্যের কোড, সামলানোর একটি পদ্ধতি বর্ণনা করেন: প্রতিটি category-কে লক্ষ্যের গড়ে বদলানো, আর অল্প উদাহরণের category-কে সামগ্রিক গড়ের দিকে টানা। এর পেছনের যুক্তি empirical Bayes: দশ লক্ষ অর্ডারের জেলার গড় বিশ্বাস করা যায়, দুই অর্ডারের পণ্যের গড় নয়। কৌশলটি দ্রুত জনপ্রিয় হয়।

সমস্যাটি তখনো জানা ছিল, কিন্তু চোখে পড়ত কম, কারণ এটি কোনো error দেয় না, আর smoothing-এ কিছুটা ঢাকা পড়ে। এই পর্বের পরীক্ষায় “auto” smoothing থাকা সত্ত্বেও fit().transform()-এ train AUC ০.৯৬ আর test ০.৬০–০.৬৫ এসেছে। ছোট category যত বেশি, ফাঁদ তত গভীর, আর আধুনিক ডেটায় (ব্যবহারকারী, ডিভাইস, পণ্য) এমন কলামই বেশি।

CatBoost-এর লেখকেরা (Liudmila Prokhorenkova ও সহকর্মীরা, NeurIPS ২০১৮) সমস্যাটিকে স্পষ্ট নাম দেন: নিজের সারিসহ গড় নিলে train-এর feature-এর বণ্টন test-এর থেকে সরে যায় (তাঁরা একে target leakage আর prediction shift-এর সঙ্গে যুক্ত করেন)। তাঁদের সমাধান সারিগুলোকে এলোমেলো ক্রমে সাজিয়ে প্রতিটি সারির জন্য শুধু তার আগের সারিগুলো থেকে গড় নেওয়া। scikit-learn ১.৩ সংস্করণে TargetEncoder যোগ করে, যা একই উদ্দেশ্যে k-fold cross-fitting ব্যবহার করে, আর docs-এ স্পষ্ট লেখে যে fit_transform আর fit().transform() আলাদা। দুটি পথই একই নীতি মানে: কোনো সারির feature যেন তার নিজের label থেকে না আসে।

শিক্ষাটি target encoding ছাড়িয়েও যায়। “Feature” বানানোর যেকোনো ধাপ, যা লক্ষ্যের দিকে তাকায়, আসলে একটি ছোট model; তাকে মূল model-এর মতোই সৎভাবে মূল্যায়ন করতে হয়। গ্রাহকের “আগের ফেরতের হার”, দোকানের “গড় রেটিং”, এগুলোর প্রতিটিতে একই প্রশ্ন: এই সারির label কি এই হিসাবে ঢুকেছে?

মূল উপলব্ধি: লক্ষ্য থেকে বানানো feature একটি লুকোনো model; smoothing তাকে শান্ত করে, কিন্তু সৎ করে cross-fitting বা ordered হিসাব।

প্র ০২Permutation importance দেখতে সরল আর নিরপেক্ষ। Correlated feature ছাড়া আর কোথায় এটি বিভ্রান্ত করতে পারে, আর একে “কারণ” বলা কেন ভুল?

Leo Breiman ২০০১ সালে random forest-এর প্রবন্ধে একটি feature-এর গুরুত্ব মাপার জন্য তার মান এলোমেলো করে ভুল কতটা বাড়ে দেখার কথা বলেন। ধারণাটির সৌন্দর্য হলো এটি model-এর ভেতর না দেখেই কাজ করে, তাই linear model, forest বা neural network সবখানেই চলে। পরে Aaron Fisher, Cynthia Rudin আর Francesca Dominici (JMLR, ২০১৯) একে “model reliance” নামে আরও সাধারণ রূপ দেন।

কিন্তু সীমাগুলো গবেষকেরা বারবার দেখিয়েছেন। Carolin Strobl ও সহকর্মীরা ২০০৭ আর ২০০৮ সালে BMC Bioinformatics-এ দেখান, random forest-এর গুরুত্বের মাপ correlated feature আর অনেক মানওয়ালা feature-এর দিকে পক্ষপাতী হতে পারে, আর একটি “conditional” সংস্করণ প্রস্তাব করেন, যেখানে সম্পর্কিত feature-গুলোর মান ধরে রেখে এলোমেলো করা হয়। Giles Hooker, Lucas Mentch আর Siyu Zhou (২০২১) আরেকটি সমস্যা তুলে ধরেন: একটি কলাম এলোমেলো করলে এমন সারি তৈরি হয় যা বাস্তবে অসম্ভব (যেমন ৬ পণ্যের অর্ডারে প্রতি পণ্যের দাম মোট দামের চেয়ে বেশি), আর model তখন তার train-এর এলাকার বাইরে উত্তর দেয়; গুরুত্বের সংখ্যা সেই অবাস্তব জায়গার আচরণ মাপে।

আরও দুটি ব্যবহারিক ফাঁদ। এক, train-এ মাপলে overfit করা model যে কলাম মুখস্থ করেছে, তাকেও জরুরি দেখায়; তাই held-out ডেটা। দুই, ছোট test set-এ সংখ্যাগুলো নড়ে; ± standard deviation আর কয়েকটি ভাগের ফল দেখা দরকার। scikit-learn-এর docs-ও tree-এর impurity-ভিত্তিক গুরুত্ব নিয়ে সতর্ক করে, কারণ তা train ডেটায় হিসাব হয় আর অনেক মানওয়ালা feature-এর দিকে ঝুঁকে থাকে।

সবচেয়ে বড় ভুলটি ব্যাখ্যার। Permutation importance বলে এই model ভবিষ্যদ্বাণীর জন্য কোন কলামে কতটা নির্ভর করে। বাজারঘরে “cod” কলাম জরুরি দেখালে তার মানে এই নয় যে cash on delivery ফেরত ঘটায়; হয়তো cod-এ কেনা গ্রাহকেরা অন্যভাবে আলাদা। কারণ খুঁজতে পরীক্ষা (যেমন A/B test) বা causal inference-এর পদ্ধতি লাগে, যা এই কোর্সের পরিধির বাইরে।

মূল উপলব্ধি: Permutation importance model-এর নির্ভরতার একটি আয়না, পৃথিবীর কারণের নয়; held-out ডেটায়, correlated কলাম দলবদ্ধ করে, আর অনিশ্চয়তাসহ পড়ুন।

প্র ০৩Deep learning feature নিজে শেখে বলে শোনা যায়। তাহলে হাতে feature বানানো কি পুরোনো অভ্যাস, নাকি table-এর ডেটায় এখনো জরুরি?

ছবি, শব্দ আর লেখায় deep learning-এর বড় সাফল্যের একটি অংশ হলো, হাতে বানানো feature-এর (যেমন ছবির edge বা কোণ খোঁজার নিয়ম) বদলে model নিজেই ধাপে ধাপে উপযোগী রূপ শেখে; একে representation learning বলে। তাই কেউ কেউ বলেন feature engineering-এর যুগ শেষ।

Table-এর ডেটায় ছবিটা আলাদা। Pedro Domingos ২০১২ সালে Communications of the ACM-এর একটি পরিচিত প্রবন্ধে লেখেন, ব্যবহারিক ML প্রকল্পের সাফল্য-ব্যর্থতার সবচেয়ে বড় কারণগুলোর একটি হলো কোন feature ব্যবহার হলো। Léo Grinsztajn, Edouard Oyallon আর Gaël Varoquaux ২০২২ সালে NeurIPS-এ মাঝারি আকারের অনেক table-ডেটাসেটে তুলনা করে দেখান, tree-ভিত্তিক model (random forest, gradient boosting) তখনকার deep learning পদ্ধতির চেয়ে প্রায়ই ভালো বা সমান করে, আর তাঁরা এর কারণ হিসেবে অপ্রাসঙ্গিক feature, অমসৃণ সম্পর্ক আর কলামগুলোর আলাদা অর্থের কথা বলেন। এই তুলনা নির্দিষ্ট ডেটাসেট আর সময়ের, আর গবেষণা চলছে, তাই একে চিরন্তন নিয়ম ধরা ঠিক নয়।

তবে একটি জায়গায় কোনো model সাহায্য করতে পারে না: যে তথ্য কলামে নেই। “প্রতি পণ্যের দাম” model দুই কলাম থেকে আঁচ করতে পারে, কিন্তু “ঈদের আগের সপ্তাহ”, “গ্রাহকের আগের ফেরতের সংখ্যা” বা “এই পণ্যের সাইজ-চার্ট ভুল ছিল” শুধু ব্যবসার জ্ঞান থেকে আসে, আর সেগুলো ঠিক সময়ের হিসাবে (leakage ছাড়া) বানানোই আসল কাজ। এই পর্বের পরীক্ষাতেও একটি linear model কাঁচা কলামে ০.৬৯১ থেকে বাছা feature-এ ০.৭৪০-এ উঠেছে।

তাই বাস্তব পরামর্শ মাঝামাঝি: table-এ সরল model-এর সঙ্গে চিন্তাশীল feature, আর শক্তিশালী model-এর সঙ্গে কম কিন্তু অর্থবহ feature; প্রতিটি সিদ্ধান্ত validation-এ যাচাই।

মূল উপলব্ধি: Model রূপ বদল শিখতে পারে, কিন্তু অনুপস্থিত তথ্য বানাতে পারে না; table-এর ডেটায় সঠিক সময়ের, অর্থবহ feature এখনো সবচেয়ে বড় লাভের জায়গা।

প্র ০৪Log transform সবসময় নিরাপদ নয়। শূন্য, ব্যাখ্যা আর ফেরত-রূপান্তরে কী কী সমস্যা হয়, আর Box-Cox ও Yeo-Johnson কেন এল?

George Box আর David Cox ১৯৬৪ সালে Journal of the Royal Statistical Society-এ একটি ঘাত-পরিবার প্রস্তাব করেন: (xλ − ১) ÷ λ, আর λ = ০ হলে log x. ডেটা থেকে λ বেছে নিলে বণ্টন আর সম্পর্ক দুটোই linear model-এর অনুমানের কাছাকাছি আসে। এর একটি সীমা, এটি শুধু ধনাত্মক মানে চলে। In-Kwon Yeo আর Richard Johnson ২০০০ সালে Biometrika-য় এমন একটি রূপ দেন যা শূন্য আর ঋণাত্মক মানেও চলে; scikit-learn-এর PowerTransformer-এর default সেটিই। এর আগে John Tukey-র exploratory data analysis-এর কাজে “ladder of powers” (√x, log x, 1/x) একই ধারণার সহজ রূপ হিসেবে পরিচিত।

ব্যবহারিক সমস্যা তিনটি। এক, শূন্য: বাজারঘরের “গত মাসের ফেরতের সংখ্যা” অনেকের জন্য ০, তখন log −∞. log1p একটি সমাধান, কিন্তু তার ফল এককের ওপর নির্ভর করে (টাকা আর হাজার টাকায় আলাদা)। দুই, ব্যাখ্যা: log-এর সহগ “প্রতি টাকায়” নয়, “প্রতি আনুপাতিক বৃদ্ধিতে”; দাম দ্বিগুণ হলে log বাড়ে log 2 ≈ ০.৬৯৩. এটি প্রায়ই বেশি অর্থবহ, কিন্তু রিপোর্টে স্পষ্ট লিখতে হয়।

তিন, ফেরত-রূপান্তর: যদি লক্ষ্যটিই log-এ নিয়ে regression করেন (যেমন ডেলিভারির সময়ের log), তাহলে অনুমানকে exp দিয়ে ফেরালে সাধারণত গড় নয়, median-এর কাছাকাছি একটি মান পান; ডানে লেজওয়ালা ডেটায় এটি গড়কে কম দেখায়। Naihua Duan ১৯৮৩ সালে এর জন্য “smearing” সংশোধন প্রস্তাব করেন। scikit-learn-এর TransformedTargetRegressor লক্ষ্যের রূপান্তর সামলায়, কিন্তু এই পক্ষপাত নিজে থেকে সারায় না।

শেষে, tree model-এ log প্রায় কোনো প্রভাব ফেলে না, কারণ ক্রম একই থাকে। তাই “log সবসময় ভালো” নয়: model, লক্ষ্য আর ব্যাখ্যার প্রয়োজন মিলিয়ে বাছাই, আর validation-এ যাচাই।

মূল উপলব্ধি: Log আর power transform একটি মাপকাঠি বদল; তা linear model-কে সাহায্য করে, কিন্তু শূন্য, একক, ব্যাখ্যা আর ফেরত-রূপান্তরের দাম জেনে নিতে হয়।

প্র ০৫Notebook-এ দারুণ চলা model চালু হওয়ার পর খারাপ করে, অথচ কোডে কোনো error নেই। Feature-এর দিক থেকে এর পরিচিত কারণগুলো কী?

D. Sculley ও সহকর্মীরা NeurIPS ২০১৫-তে “Hidden Technical Debt in Machine Learning Systems” প্রবন্ধে দেখান, বাস্তব ML সিস্টেমে model-এর কোড ছোট অংশ; চারপাশের ডেটা-প্রস্তুতির কোডই বড়, আর সেখানে নীরব ভুল জমে। Martin Zinkevich-এর লেখা Google-এর “Rules of Machine Learning” নির্দেশিকাতেও training-serving skew নিয়ে বারবার সতর্ক করা হয়েছে: train-এর সময় feature যেভাবে হিসাব হয়েছিল, চালুর সময় তা একটু অন্যভাবে হলে model এমন ইনপুট পায় যা সে চেনে না।

Feature-এর দিক থেকে পরিচিত কারণগুলো এই পর্বের সঙ্গেই মেলে। এক, দুই জায়গায় দুই রকম কোড: notebook-এ ঘণ্টা Asia/Dhaka-য়, সার্ভারে UTC-তে (পর্ব ২৬), ফলে sin/cos ছয় ঘণ্টা সরে যায়। দুই, শেখা সংখ্যা নতুন করে হিসাব: চালুর সময় প্রতিদিনের ডেটায় আবার scaler fit করা, ফলে একই অর্ডার রোজ আলাদা সংখ্যা পায়। তিন, অচেনা category: নতুন পরিশোধের ধরন বা নতুন পণ্য এলে handle_unknown ঠিক না থাকলে error, আর ignore হলে সেই অর্ডারগুলো নীরবে “কোনো ধরন নয়” হয়ে যায়, যা মাপা দরকার।

চার, leakage-এর ফল ধরা পড়া: train-এ target encoding বা ভবিষ্যতের কলাম থেকে আসা আশাবাদী score বাস্তবে থাকে না, কারণ বাস্তবে সেই তথ্য prediction-এর মুহূর্তে নেই। পাঁচ, বণ্টনের বদল: ঈদ বা নতুন courier আগের সম্পর্ক বদলায়, যা feature-এর বণ্টন পাহারা দিয়ে আগে ধরা যায়।

প্রতিকার মূলত শৃঙ্খলার: সব feature একটি জায়গায় কোডে (Pipeline বা একটি function), শেখা সংখ্যা model-এর সঙ্গে সংরক্ষণ, train আর চালু দুই জায়গায় হুবহু একই কোড, অচেনা মান আর ফাঁকা ঘরের সংখ্যা লগে রাখা, আর কয়েকটি নমুনা অর্ডারের feature দুই জায়গায় হিসাব করে মিলিয়ে দেখা একটি স্বয়ংক্রিয় test (পর্ব ১১)।

মূল উপলব্ধি: একটি feature শুধু একটি সূত্র নয়, একটি চুক্তি: train আর prediction-এ একই কোড, একই শেখা সংখ্যা আর একই সময়ের তথ্য; চুক্তি ভাঙলে model error ছাড়াই ভুল করে।

অনুশীলন

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

  1. তিনটি scaler আর smoothing (হিসাব): (ক) ডেলিভারির দিন ২, ৪, ৬, ৮, ৪০. StandardScaler (population sd), MinMaxScaler আর RobustScaler-এর পর প্রতিটি মান কত? (খ) সামগ্রিক ফেরতের হার ০.২. একটি পণ্যের ৪টি অর্ডারের ৩টি ফেরত, আরেকটির ২০০টির ৫০টি। smoothing m = ১০ দিয়ে দুটির target encoding কত?
    • Standard: গড় (২ + ৪ + ৬ + ৮ + ৪০) ÷ ৫ = ১২; বিচ্যুতি −১০, −৮, −৬, −৪, ২৮; বর্গের যোগ ১০০ + ৬৪ + ৩৬ + ১৬ + ৭৮৪ = ১০০০; variance ২০০, σ ≈ ১৪.১৪২. z: −০.৭০৭, −০.৫৬৬, −০.৪২৪, −০.২৮৩, ১.৯৮০.
    • MinMax: (x − ২) ÷ ৩৮: ০, ০.০৫৩, ০.১০৫, ০.১৫৮, ১.
    • Robust: median ৬, Q1 = ৪, Q3 = ৮, IQR = ৪: −১, −০.৫, ০, ০.৫, ৮.৫. চারটি সাধারণ মানের ছড়ানো Standard-এ ০.৪২৪, MinMax-এ ০.১৫৮, Robust-এ ১.৫.
    • (খ) প্রথমটি: (৪ × ০.৭৫ + ১০ × ০.২) ÷ (৪ + ১০) = (৩ + ২) ÷ ১৪ ≈ ০.৩৫৭; কাঁচা ০.৭৫ থেকে অনেক নিচে, কারণ মাত্র ৪টি উদাহরণ। দ্বিতীয়টি: (৫০ + ২) ÷ ২১০ ≈ ০.২৪৮, কাঁচা ০.২৫-এর প্রায় সমান, কারণ ২০০টি উদাহরণ।

    scikit-learn-এ যাচাই (ডেস্কটপ, সত্যিকারের run): StandardScaler [-0.707 -0.566 -0.424 -0.283 1.98], MinMaxScaler [0. 0.053 0.105 0.158 1.], RobustScaler [-1. -0.5 0. 0.5 8.5].

  2. Feature function (কোড): একটি function add_features(d, days_median) লিখুন যা একটি অর্ডারের DataFrame-এ যোগ করে: per_item (পণ্য ০ হলে NaN, শূন্য দিয়ে ভাগ নয়), disc_x_first, ঘণ্টার sin ও cos, days_missing flag, আর delivery_days-এর ফাঁকা ঘরে দেওয়া median. Median কেন argument হিসেবে আসে, function-এর ভেতরে হিসাব হয় না?
    import numpy as np, pandas as pd
    
    def add_features(d, days_median):
        out = d.copy()
        out["per_item"] = d["value"] / d["items"].where(d["items"] > 0)   # ০ পণ্য হলে NaN
        out["disc_x_first"] = d["discount"] * d["first_time"]
        angle = 2 * np.pi * d["hour"] / 24
        out["hour_sin"], out["hour_cos"] = np.sin(angle), np.cos(angle)
        out["days_missing"] = d["delivery_days"].isna().astype(int)
        out["delivery_days"] = d["delivery_days"].fillna(days_median)   # median train থেকে
        return out
    
    d = pd.DataFrame({"value": [1200, 900, 3000], "items": [3, 1, 0],
                      "discount": [1, 1, 0], "first_time": [1, 0, 1],
                      "hour": [23, 6, 0], "delivery_days": [2.0, None, 5.0]})
    f = add_features(d, days_median=4.0).round(3)
    print(f[["per_item", "disc_x_first", "hour_sin", "hour_cos", "delivery_days", "days_missing"]])

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

       per_item  disc_x_first  hour_sin  hour_cos  delivery_days  days_missing
    0     400.0             1    -0.259     0.966            2.0             0
    1     900.0             0     1.000     0.000            4.0             1
    2       NaN             0     0.000     1.000            5.0             0

    Median বাইরে থেকে আসে, কারণ এটি একটি শেখা সংখ্যা: train থেকে একবার হিসাব করে test আর নতুন অর্ডারে একই মান বসাতে হবে। Function-এর ভেতরে d["delivery_days"].median() লিখলে test-এ test-এর median আর চালুর সময় প্রতিদিনের ব্যাচের median বসত, যা leakage আর training-serving skew দুটোই। লক্ষ করুন, flag বানানো হয়েছে ঘর ভরানোর আগে; পরে করলে সব flag ০ হতো। পণ্য ০-এর সারিতে per_item NaN রাখা সৎ সিদ্ধান্ত; inf রাখলে পরের ধাপ ভেঙে যেত।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মীর বিবরণ: “পুরো ডেটায় customer_id-এর ফেরতের গড় বানিয়ে feature করেছি। তারপর StandardScaler দিয়ে সব কলাম scale করেছি, ঘণ্টা আর মাস সংখ্যা হিসেবেই আছে। পণ্যের ১৫০০টি ID one-hot করেছি, OneHotEncoder-এর default সেটিংয়ে। তারপর ৮০/২০ ভাগ করে logistic regression; train AUC ০.৯৭, test AUC ০.৬৮. Feature importance দেখতে training set-এ permutation importance চালিয়েছি।” কী কী সমস্যা, আর আপনি কীভাবে সাজাবেন?
    • Target encoding-এর leakage: পুরো ডেটায়, নিজের সারিসহ গড়; train ০.৯৭ বনাম test ০.৬৮-এর ফাঁকই তার চিহ্ন। Split-এর পরে, train-এর ভেতরে cross-fitting (TargetEncoder-এর fit_transform বা Pipeline).
    • Split-এর আগে scaler: ছোট কিন্তু অপ্রয়োজনীয় leakage; scaler train-এ fit.
    • সময়ের রূপ: ঘণ্টা আর মাস sin/cos (বা one-hot), যাতে ২৩ আর ০, ডিসেম্বর আর জানুয়ারি পাশাপাশি থাকে।
    • Default one-hot: handle_unknown="error", তাই নতুন পণ্য এলেই prediction ভাঙবে; আর ১৫০০টি বিরল কলাম। min_frequency বা target encoding, আর handle_unknown ঠিক করা।
    • Train-এ importance: মুখস্থ করা কলামকে জরুরি দেখায়; held-out ডেটায় মাপা, correlated কলাম আলাদা করে দেখা।

    নতুন নকশা: প্রথমে ভাগ (প্রয়োজনে গ্রাহক বা সময় ধরে, পর্ব ৫৬); শেখা ধাপগুলো একটি Pipeline-এ; train-এর ভেতরে cross-validation দিয়ে বাছাই; শেষে test একবার।

  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে?
    import numpy as np, pandas as pd
    from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
    
    s = MinMaxScaler().fit([[10], [20], [30]])
    print(s.transform([[25], [40]]).ravel())
    e = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
    e.fit(pd.DataFrame({"c": ["b", "a"]}))
    print(e.get_feature_names_out())
    print(e.transform(pd.DataFrame({"c": ["a", "z"]})))
    print(np.round(np.cos(2 * np.pi * np.array([0, 3, 21]) / 24), 3))

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

    [0.75 1.5 ]
    ['c_a' 'c_b']
    [[1. 0.]
     [0. 0.]]
    [1.    0.707 0.707]

    MinMaxScaler শিখেছে min ১০, max ৩০: (২৫ − ১০) ÷ ২০ = ০.৭৫, আর (৪০ − ১০) ÷ ২০ = ১.৫. Train-এর পরিসরের বাইরের মান ১-এর বেশি হয়; ০–১-এর মধ্যে রাখার নিশ্চয়তা নেই (চাইলে clip=True). OneHotEncoder category বর্ণানুক্রমে সাজায়, তাই কলাম c_a, c_b, যদিও fit-এ “b” আগে ছিল; “a” হয় [1, 0], অচেনা “z” [0, 0]. শেষ লাইনে ঘণ্টা ৩ আর ২১-এর cos একই ০.৭০৭, কারণ দুটিই মধ্যরাত থেকে তিন ঘণ্টা দূরে, এক পাশে ও অন্য পাশে; এদের আলাদা করে sin (০.৭০৭ বনাম −০.৭০৭)।

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

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

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

  1. প্রশ্ন ঠিক করে ডেটা train আর test-এ ভাগ করা
  2. শুধু train দেখে কোন রূপান্তর আর নতুন feature লাগবে, তা ঠিক করা
  3. Scaler, imputer আর encoder শুধু train-এ fit করা (target encoding cross-fitting-সহ)
  4. একই শেখা মাপকাঠি দিয়ে train আর test দুটোই transform করা
  5. Model fit করে validation-এ feature-এর দলগুলোর লাভ আর permutation importance দেখা
  6. সব সিদ্ধান্ত শেষে test একবার খুলে ফল রিপোর্ট করা

অর্ডারের দাম 100, 200, 300, 400, 5000 টাকা। RobustScaler (median আর IQR, Q1 = 200, Q3 = 400) দিয়ে scale করলে 5000 কত হয়?

  • 1
  • প্রায় 2
  • 23.5
  • 0, কারণ RobustScaler outlier বাদ দেয়

Train-এ পরিশোধের ধরন ছিল cod, mobile আর card. OneHotEncoder(handle_unknown="ignore") fit করার পর prediction-এ নতুন ধরন "voucher" এলে কী হয়?

  • ValueError দিয়ে transform থেমে যায়
  • তিনটি one-hot কলামই 0 হয়
  • একটি নতুন কলাম pay_voucher তৈরি হয়
  • সবচেয়ে ঘন ঘন ধরন cod-এর কলাম 1 হয়

প্রায় প্রতিটি মান একবার আসে এমন একটি পণ্য-ID কলামে (কোনো আসল সংকেত নেই) কেউ নিজের সারিসহ লক্ষ্যের গড় দিয়ে target encoding করলেন। সাধারণত কী দেখা যাবে?

  • Train আর test দুটোতেই score কাকতালের কাছে
  • Train আর test দুটোতেই score অনেক বেশি
  • Train-এ কম, test-এ বেশি
  • Train-এ খুব বেশি, test-এ ID ছাড়া model-এর চেয়েও কম

ঘণ্টাকে sin(2πh/24) আর cos(2πh/24) দুটি কলামে রাখা হয়, শুধু sin-এ নয়। প্রধান কারণ কী?

  • sin একা ভিন্ন ঘণ্টাকে (যেমন 1 আর 11) একই মান দেয়
  • বেশি কলাম মানেই ভালো score
  • cos train-এ fit হয়, sin হয় না
  • sin ঋণাত্মক মান দিতে পারে না

একটি model-এ "প্রতি পণ্যের দাম" টাকায় আর ডলারে দুটি কলাম আছে (correlation 0.998). Test-এ permutation importance চালালে কী ঘটতে পারে?

  • দুটিই আলাদাভাবে খুব জরুরি দেখাবে
  • permutation_importance দুটিকে একত্রে মেপে একটি সংখ্যা দেবে
  • গুরুত্ব ভাগ হয়ে যায় বা একটিতে সরে যায়, তাই একটি বা দুটিই কম জরুরি দেখায়
  • permutation_importance error দেবে

নিচের কোন রূপান্তর ডেটা থেকে কিছু শেখে না, তাই train/test ভাগের আগে বা পরে করলে একই ফল দেয়?

  • StandardScaler
  • np.log1p দিয়ে log
  • SimpleImputer(strategy="median")
  • TargetEncoder

শব্দকোষ

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

মনে রাখুন

  1. Scaler বাছাই: StandardScaler আর MinMaxScaler outlier-এ মাপকাঠি হারায়; RobustScaler (median, IQR) টেকে (k-NN-এ ০.৬৫ বনাম ০.৭৯)। তবে ভুল মান আগে সারানোই ভালো।
  2. Log আর নতুন feature: বাঁকা দামে log, প্রতি পণ্যের দাম আর ছাড় × প্রথম অর্ডার linear model-এর AUC ০.৬৯১ থেকে ০.৭৪০-এ তুলেছে; লাভ validation-এ মাপুন, কয়েকটি ভাগে।
  3. Category: one-hot-এ handle_unknown আগেই ঠিক করুন; হাজারো ID-তে min_frequency, hashing বা target encoding; ordinal-এ ক্রম নিজে বলে দিন।
  4. Target encoding: নিজের সারিসহ গড় leakage (train ০.৯৫৮, test ০.৬৫১); TargetEncoder-এর fit_transform বা Pipeline cross-fitting করে; fit().transform() train-এ করে না।
  5. সময়, ফাঁকা ঘর আর যাচাই: ঘণ্টা ও মাসে sin/cos (২৩ আর ০-এর দূরত্ব ০.২৬), ফাঁকা ঘরে missing flag, held-out-এ permutation importance আর correlated কলামের ফাঁদ; সব শেখা রূপান্তর শুধু train-এ fit.

পরের পর্বে: এই পর্বের প্রতিটি ধাপ আমরা হাতে আলাদা আলাদা fit করেছি। পর্ব ৬৪ (scikit-learn pipeline — fit, transform আর leakage-মুক্ত কাজ)-এ সবগুলোকে একটি ColumnTransformer আর Pipeline-এ বাঁধব: estimator API, নিজের transformer, get_feature_names_out, সংরক্ষণ আর বাজারঘরের ফেরতের পুরো leakage-মুক্ত pipeline.

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

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