অধ্যায় 5 · পাইপলাইন ও AI
SQL ও pandas দিয়ে ML-এর উপযোগী ডেটা তৈরি
- পৃষ্ঠা 18 / 22
- 19 মিনিট পড়া
ট্রেনিং টেবিলে যা আছে, মডেল ঠিক তা-ই শেখে — টেবিলের ভুল আর উত্তরের দিকে অনিচ্ছাকৃত ইশারাগুলোও। আসল ML প্রজেক্টে বেশিরভাগ সময় অ্যালগরিদম বাছতে যায় না; যায় এই টেবিলটা বানাতে: প্রতিটা উদাহরণের জন্য একটা সারি, ঠিকঠাক ফিচার, সৎ একটা টার্গেট, আর এমন কিছুই নয়, যা প্রেডিকশনের মুহূর্তে মডেলের জানার কোনো উপায় ছিল না। এই টেবিলের বেশিরভাগটাই বানানো হয় SQL-এ, কারণ দরকারি ইতিহাসটা থাকে ডেটাবেসেই।
এই পাতায় শুরু থেকে শেষ পর্যন্ত একটা চার্ন (churn) ডেটাসেট বানাবেন: প্রথমে শপের ডেটায়, তারপর জেনারেট করা বড় একটা গ্রাহক টেবিলে, আর শেষে তার ওপর একটা scikit-learn মডেল ট্রেন করবেন। পথে ইচ্ছে করে একটা লিকেজ (leakage) তৈরি করবেন, আর দেখবেন সেটা মডেলকে আসলের চেয়ে কত ভালো দেখায়।
যা শিখবেন
- একটা প্রেডিকশনের প্রশ্নকে টেবিলে সাজানো: এনটিটি, কাটঅফ তারিখ, ফিচার, টার্গেট আর লেবেল উইন্ডো
- SQL-এ পয়েন্ট-ইন-টাইম ফিচার কোয়েরি লেখা (RFM ধাঁচের ইতিহাস, একাধিক উৎস জয়েন করে), আর বাকি ফিচার pandas-এ শেষ করা
- টেস্ট সেট দূষিত না করে মিসিং ভ্যালু, ভুল রেকর্ড, ক্যাটাগরি, স্কেলিং আর আউটলায়ার সামলানো
- চার্ন মডেলের সৎ পরীক্ষার জন্য কেন র্যান্ডম ভাগ নয়, সময় ধরে ভাগ (time-based split) লাগে, আর ক্লাস অসম হলে ফলাফল কীভাবে পড়বেন
- ডেটাসেট পুনরুৎপাদনযোগ্য (reproducible) রাখা: seed, ভার্সন করা স্ন্যাপশট, Parquet ফাইল, হ্যাশ আর ট্রেনিংয়ের আগের চেক
ML-এর উপযোগী টেবিলের আকার
"এই গ্রাহক কি কেনা বন্ধ করবেন?" প্রশ্নটাকে টেবিলে রূপ দেওয়া যায় কেবল পাঁচটা জিনিস ঠিক করার পরে:
| সিদ্ধান্ত | মানে | এই পাতায় |
|---|---|---|
| এনটিটি (entity) | একটা সারি কীসের বর্ণনা | একটা কাটঅফ তারিখে একজন গ্রাহক |
| কাটঅফ (as-of date) | যে মুহূর্তে প্রেডিকশন করছেন বলে ধরে নিচ্ছেন | 2026-04-01 আর তার আগের কয়েকটা তারিখ |
| ফিচার | কাটঅফের আগে যা জানা ছিল | অর্ডার, খরচ, রিসেন্সি, সাপোর্ট টিকিট, প্ল্যান, শহর |
| টার্গেট (লেবেল) | কাটঅফের পরে যা ঘটেছে | churned = 1, যদি পরের ৯০ দিনে কোনো অর্ডার না থাকে |
| পপুলেশন | কোন কোন এনটিটি আদৌ সারি পাবে | যে গ্রাহকেরা কাটঅফের দিনে ছিলেন (আর সক্রিয় ছিলেন) |
কাটঅফ সময়কে দুই ভাগ করে। বাঁ দিকের সবকিছু ফিচারে যেতে পারে; ডান দিকের সবকিছু যেতে পারে শুধু টার্গেটে:
ফিচার: কাটঅফের আগের ইতিহাস টার্গেট: এরপর কী ঘটে
───────────────────────────────────────────────────|─────────────────────────|──▶ সময়
কাটঅফ কাটঅফ + ৯০ দিন
(2026-04-01) (2026-06-30)
যে ফিচার কাটঅফের ডান দিকের কিছু পড়ে, সেটাই লিক (LEAK)।এই নিয়মের নাম পয়েন্ট-ইন-টাইম শুদ্ধতা (point-in-time correctness)। প্রোডাকশনে মডেল চলে কাটঅফের দিনে, তখন ভবিষ্যৎ এখনো ঘটেইনি; ট্রেনিং টেবিলও এমনভাবে বানাতে হবে যেন সেটাই সত্যি।
শপের ডেটায় পয়েন্ট-ইন-টাইম ফিচার
এখানে ২০২৬-এর ১ এপ্রিলের হিসাবে শপের প্রত্যেক গ্রাহকের RFM ফিচার — Recency (শেষ অর্ডারের পর কত দিন), Frequency (কয়টা অর্ডার) আর Monetary (কত টাকা খরচ) — সাথে টেনিউর (কত দিন ধরে গ্রাহক) আর একটা চার্ন লেবেল। বাতিল অর্ডার গোনা হয় না:
WITH order_totals AS (
SELECT o.order_id, o.customer_id, o.order_date, o.status,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.customer_id, o.order_date, o.status
)
SELECT c.customer_id,
c.city,
CAST(julianday('2026-04-01') - julianday(c.joined_on) AS INTEGER) AS tenure_days,
COUNT(t.order_id) AS frequency,
COALESCE(SUM(t.total), 0) AS monetary,
CAST(julianday('2026-04-01') - julianday(MAX(t.order_date)) AS INTEGER) AS recency_days,
CASE WHEN EXISTS (
SELECT 1 FROM orders AS f
WHERE f.customer_id = c.customer_id
AND f.status <> 'cancelled'
AND f.order_date >= '2026-04-01' AND f.order_date < '2026-07-01'
) THEN 0 ELSE 1 END AS churned
FROM customers AS c
LEFT JOIN order_totals AS t
ON t.customer_id = c.customer_id
AND t.order_date < '2026-04-01' -- পয়েন্ট-ইন-টাইম ফিল্টার
AND t.status <> 'cancelled'
WHERE c.joined_on < '2026-04-01' -- কাটঅফের দিনের পপুলেশন
GROUP BY c.customer_id, c.city, c.joined_on
ORDER BY c.customer_id;+-------------+------------+-------------+-----------+----------+--------------+---------+
| customer_id | city | tenure_days | frequency | monetary | recency_days | churned |
+-------------+------------+-------------+-----------+----------+--------------+---------+
| 1 | Dhaka | 149 | 2 | 5100 | 57 | 0 |
| 2 | Chattogram | 108 | 2 | 19500 | 24 | 0 |
| 3 | Dhaka | 82 | 2 | 6400 | 4 | 0 |
| 4 | Sylhet | 69 | 0 | 0 | NULL | 1 |
| 5 | NULL | 55 | 1 | 4000 | 17 | 1 |
| 6 | Khulna | 42 | 0 | 0 | NULL | 0 |
| 7 | Dhaka | 2 | 0 | 0 | NULL | 0 |
+-------------+------------+-------------+-----------+----------+--------------+---------+- গ্রাহক ৮ নেই: তিনি যোগ দিয়েছেন ১১ এপ্রিল, কাটঅফের পরে। ১ এপ্রিল তাঁকে স্কোর করার কোনো উপায়ই ছিল না।
- ফিল্টারগুলো
WHERE-এ নয়,ON-এ বসানো, তাই যাঁদের কোনো ইতিহাস নেই তাঁদের সারিও থাকে (বিগিনার টিউটোরিয়ালের LEFT, RIGHT, FULL, CROSS আর সেলফ জয়েন দেখুন)। গ্রাহক ৪-এর একমাত্র অর্ডারটা বাতিল হয়েছিল; গ্রাহক ৬ আর ৭ তখনো কোনো অর্ডারই করেননি। - NULL আর 0 এক জিনিস নয়। কোনো অর্ডার নেই মানে frequency 0 আর monetary 0 — এটা একটা তথ্য। কিন্তু
recency_daysNULL: অর্ডারই না থাকলে "শেষ অর্ডারের পর কত দিন" প্রশ্নটার উত্তর হয় না। মডেল এটা কীভাবে দেখবে, তা পরে ঠিক করবেন। - ফিচার আর লেবেল আলাদা সময়ের জানালা দেখে: ফিচার শুধু কাটঅফের আগে, লেবেল শুধু তার পরের তিন মাসে (এখানে এপ্রিল থেকে জুন; নিচের জেনারেট করা ডেটায় ঠিক ৯০ দিন)।
বড়, পুনরুৎপাদনযোগ্য একটা ডেটাসেট
আটজন গ্রাহক দিয়ে মডেল ট্রেন হয় না। নিচের ব্লক shop.db-তে বাস্তবের মতো একটা ডেটাসেট জেনারেট করে: ২,০০০ গ্রাহক, প্রত্যেকের একটা প্ল্যান, শহর আর বয়স (কিছু ফাঁকা, কয়েকটা ভুল টাইপ করা), তাঁদের অর্ডার আর সাপোর্ট টিকিট। গ্রাহকেরা এলোমেলো সময়ে চলে যান; যাওয়ার আগে তাঁরা কম অর্ডার করেন আর বেশি অভিযোগ করেন — মডেলের যে সংকেত খুঁজে পাওয়ার কথা। একটা seed প্রতিবারের রান হুবহু একই রাখে:
import numpy as np
import pandas as pd
from sqlhelp import con, show
rng = np.random.default_rng(42) # একটাই seed: প্রতিবার একই ডেটাসেট
N = 2000
END = pd.Timestamp("2026-06-30") # ডেটার শেষ দিন
signup = pd.Timestamp("2025-01-01") + pd.to_timedelta(rng.integers(0, 450, N), unit="D")
plan = rng.choice(["free", "basic", "pro"], N, p=[0.5, 0.35, 0.15])
city = rng.choice(["Dhaka", "Chattogram", "Sylhet", "Khulna", "Rajshahi", None], N,
p=[0.42, 0.2, 0.12, 0.11, 0.1, 0.05])
age = rng.integers(18, 61, N).astype(float)
age[rng.random(N) < 0.07] = np.nan # কখনো পূরণ করা হয়নি
typo = rng.random(N) < 0.015
age[typo] = rng.choice([0, 7, 230], typo.sum()) # ডেটা এন্ট্রির ভুল
rate = rng.gamma(2.0, 1.0, N) * np.select([plan == "pro", plan == "basic"], [1.6, 1.2], 1.0)
life = rng.exponential(np.select([plan == "pro", plan == "basic"], [500, 360], 220))
churn_at = signup + pd.to_timedelta(life.round(), unit="D")
customers, orders, tickets = [], [], []
oid = tid = 0
for i in range(N):
stop = min(churn_at[i], END)
days = (stop - signup[i]).days
closed = None
if churn_at[i] <= END and rng.random() < 0.6: # চলে যাওয়াদের কেউ কেউ অ্যাকাউন্ট বন্ধ করেন
c = churn_at[i] + pd.Timedelta(days=int(rng.integers(0, 45)))
closed = c.date().isoformat() if c <= END else None
customers.append((i + 1, signup[i].date().isoformat(), plan[i], city[i],
None if np.isnan(age[i]) else int(age[i]), closed))
if days <= 0:
continue
n = rng.poisson(rate[i] * days / 30)
for d in np.sort(rng.integers(0, days, n)):
left = days - d if churn_at[i] <= END else 999
if rng.random() > min(1.0, left / 75): # চলে যাওয়ার আগে আগ্রহ কমে আসে
continue
oid += 1
amount = round(float(rng.lognormal(6.6 + 0.3 * (plan[i] == "pro"), 0.6)))
orders.append((oid, i + 1, (signup[i] + pd.Timedelta(days=int(d))).date().isoformat(), amount))
m = rng.poisson(0.15 * days / 30 + (2.0 if churn_at[i] <= END else 0))
for d in rng.integers(max(0, days - 60) if churn_at[i] <= END else 0, days, m):
tid += 1 # চলে যাওয়ার আগে অভিযোগ বাড়ে
tickets.append((tid, i + 1, (signup[i] + pd.Timedelta(days=int(d))).date().isoformat()))
con.executescript("""
DROP TABLE IF EXISTS sim_tickets;
DROP TABLE IF EXISTS sim_orders;
DROP TABLE IF EXISTS sim_customers;
CREATE TABLE sim_customers (customer_id INTEGER PRIMARY KEY, signup_date TEXT NOT NULL,
plan TEXT NOT NULL, city TEXT, age INTEGER, closed_on TEXT);
CREATE TABLE sim_orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL,
order_date TEXT NOT NULL, amount INTEGER NOT NULL,
FOREIGN KEY (customer_id) REFERENCES sim_customers (customer_id));
CREATE TABLE sim_tickets (ticket_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL,
opened_on TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES sim_customers (customer_id));
""")
with con:
con.executemany("INSERT INTO sim_customers VALUES (?, ?, ?, ?, ?, ?)", customers)
con.executemany("INSERT INTO sim_orders VALUES (?, ?, ?, ?)", orders)
con.executemany("INSERT INTO sim_tickets VALUES (?, ?, ?)", tickets)
show("""SELECT (SELECT COUNT(*) FROM sim_customers) AS customers,
(SELECT COUNT(*) FROM sim_orders) AS orders,
(SELECT COUNT(*) FROM sim_tickets) AS tickets,
(SELECT MAX(order_date) FROM sim_orders) AS last_order""")+-----------+--------+---------+------------+
| customers | orders | tickets | last_order |
+-----------+--------+---------+------------+
| 2000 | 26281 | 4331 | 2026-06-29 |
+-----------+--------+---------+------------+দুটো "সিস্টেম" (সেলস আর সাপোর্ট) থেকে তিনটা টেবিল, ঠিক আসল কোম্পানির মতো। closed_on খেয়াল করুন: গ্রাহক অ্যাকাউন্ট বন্ধ করলে অ্যাপ এটা লিখে দেয়। পরে এর কথা আবার আসবে।
SQL-এ ফিচার: প্রতিটা কাটঅফে একটা স্ন্যাপশট
ফিচার কোয়েরি কাটঅফটা প্যারামিটার হিসেবে নেয়, তাই একই কোড যেকোনো তারিখের স্ন্যাপশট বানায়। প্রতিটা CTE একটা উৎস বা একটা সময়ের জানালা। পপুলেশন হলো "কাটঅফের আগের ৯০ দিনে অন্তত একটা অর্ডার আছে আর অ্যাকাউন্ট এখনো খোলা" — ব্যবসা জানতে চায় কোন সক্রিয় গ্রাহক চলে যেতে বসেছেন:
FEATURES_SQL = """
WITH active AS (
SELECT DISTINCT customer_id
FROM sim_orders
WHERE order_date >= date(:cutoff, '-90 days') AND order_date < :cutoff
),
history AS ( -- sales: everything BEFORE the cutoff
SELECT customer_id,
COUNT(*) AS orders_all,
SUM(CASE WHEN order_date >= date(:cutoff, '-30 days') THEN 1 ELSE 0 END) AS orders_30d,
SUM(CASE WHEN order_date >= date(:cutoff, '-90 days') THEN 1 ELSE 0 END) AS orders_90d,
SUM(amount) AS spend_all,
CAST(julianday(:cutoff) - julianday(MAX(order_date)) AS INTEGER) AS recency_days
FROM sim_orders
WHERE order_date < :cutoff
GROUP BY customer_id
),
support AS ( -- support: tickets in the last 90 days
SELECT customer_id, COUNT(*) AS tickets_90d
FROM sim_tickets
WHERE opened_on >= date(:cutoff, '-90 days') AND opened_on < :cutoff
GROUP BY customer_id
),
future AS ( -- the label window: AFTER the cutoff
SELECT DISTINCT customer_id
FROM sim_orders
WHERE order_date >= :cutoff AND order_date < date(:cutoff, '+90 days')
)
SELECT :cutoff AS cutoff, c.customer_id, c.plan, c.city, c.age,
CAST(julianday(:cutoff) - julianday(c.signup_date) AS INTEGER) AS tenure_days,
h.orders_all, h.orders_30d, h.orders_90d, h.spend_all, h.recency_days,
COALESCE(s.tickets_90d, 0) AS tickets_90d,
CASE WHEN f.customer_id IS NULL THEN 1 ELSE 0 END AS churned
FROM sim_customers AS c
JOIN active AS a ON a.customer_id = c.customer_id
JOIN history AS h ON h.customer_id = c.customer_id
LEFT JOIN support AS s ON s.customer_id = c.customer_id
LEFT JOIN future AS f ON f.customer_id = c.customer_id
WHERE c.closed_on IS NULL OR c.closed_on >= :cutoff
ORDER BY c.customer_id
"""
def snapshot(cutoff):
return pd.read_sql(FEATURES_SQL, con, params={"cutoff": cutoff})
snaps = {cut: snapshot(cut) for cut in ["2025-10-01", "2026-01-01", "2026-04-01"]}
for cut, df in snaps.items():
print(cut, f"rows={len(df)}", f"churn rate={df['churned'].mean():.1%}")
print(snaps["2026-04-01"].drop(columns="cutoff").head(3).to_string(index=False))2025-10-01 rows=744 churn rate=21.2%
2026-01-01 rows=898 churn rate=20.9%
2026-04-01 rows=992 churn rate=22.3%
customer_id plan city age tenure_days orders_all orders_30d orders_90d spend_all recency_days tickets_90d churned
2 free Chattogram 27.0 107 4 0 2 2909 51 2 1
3 free Dhaka 40.0 161 8 1 5 5718 24 0 0
6 free Khulna 50.0 69 1 0 1 389 59 1 1:cutoffপ্যারামিটারটা ড্রাইভার বসায়, স্ট্রিংয়ে জুড়ে দেওয়া হয় না (Python ও ডেটাবেস: কানেকশন, প্যারামিটার আর ট্রানজ্যাকশন দেখুন)।pd.read_sqlparamsসরাসরি ড্রাইভারে পাঠিয়ে দেয়।COALESCE(s.tickets_90d, 0)এখানে ঠিক, কারণ "টিকিটের কোনো সারি নেই" মানে সত্যিই শূন্য টিকিট। কিন্তু অজানা কিছুকে (যেমন বয়স) SQL-এ কখনো বানানো সংখ্যায়COALESCEকরবেন না; NULL রেখে দিন, আর মডেল পাইপলাইনে সিদ্ধান্ত নিন।- প্রতি পাঁচজন সক্রিয় গ্রাহকের মোটামুটি একজন চলে যান। এটাই ক্লাস ইমব্যালান্স: যে মডেল সবসময় "থাকবেন" বলে, সে ৭৮% ক্ষেত্রে ঠিক, অথচ কোনো কাজের নয়। একটু পরে ভালো মেট্রিক দিয়ে মাপবেন।
ageকলাম ফ্লোট (27.0) দেখাচ্ছে, কারণ এতে NULL আছে আর pandas NULL-কেNaNহিসেবে রাখে।
pandas-এ পরিষ্কার করা আর ফিচার ইঞ্জিনিয়ারিং
কিছু ধাপ pandas-এ সহজ: যে নিয়মে বিচার-বিবেচনা লাগে, অনুপাত আর রূপান্তর। অসম্ভব বয়সকে মিসিং করে দেওয়া হয়, ফাঁকা শহর হয়ে যায় আলাদা একটা ক্যাটাগরি, আর একদিকে হেলে থাকা (skewed) টাকার অঙ্কের log নেওয়া হয়:
def prepare(df):
df = df.copy()
df["age"] = df["age"].where(df["age"].between(13, 100)) # 0, 7, 230 -> NaN
df["city"] = df["city"].fillna("unknown") # না থাকাটাও একটা তথ্য
df["log_spend"] = np.log1p(df["spend_all"]) # লম্বা লেজটা সামলানো
df["trend"] = df["orders_30d"] / (df["orders_90d"] / 3) # শেষ মাস বনাম ৩ মাসের গড়
return df
raw = snaps["2026-04-01"]
print("invalid ages:", int((~raw["age"].between(13, 100) & raw["age"].notna()).sum()))
print("missing ages:", int(raw["age"].isna().sum()), "| missing cities:", int(raw["city"].isna().sum()))
print("spend_all: median", raw["spend_all"].median(), "max", raw["spend_all"].max())
print(prepare(raw)[["log_spend", "trend"]].describe().round(2).loc[["mean", "min", "max"]].to_string())invalid ages: 18
missing ages: 73 | missing cities: 46
spend_all: median 9232.0 max 162510
log_spend trend
mean 9.01 1.08
min 5.19 0.00
max 12.00 3.00কোন ধাপ কোথায় রাখবেন? মোটামুটি নিয়ম:
| ধাপ | সেরা জায়গা | কেন |
|---|---|---|
| ফিল্টার, জয়েন, তারিখের জানালা, ইতিহাসের ওপর count আর sum | SQL | ডেটার পাশেই চলে; প্রতি এনটিটির জন্য শুধু একটা সারি পাঠায় |
| অনুপাত, log, সহজ বৈধতার নিয়ম | যেকোনোটা | একটা জায়গা বেছে সেখানেই রাখুন |
| ইমপিউট, স্কেলিং, ওয়ান-হট এনকোডিং | scikit-learn পাইপলাইন | শুধু ট্রেনিং সারি থেকে শিখতে হবে |
| পার্সেন্টাইল ধরে আউটলায়ার ক্লিপ করা | পাইপলাইন (বা ট্রেনিং সেটের সীমা দিয়ে) | পার্সেন্টাইলটাও ডেটা থেকে শেখা জিনিস |
সময় ধরে ভাগ, আর মডেল
র্যান্ডম ভাগ করলে এপ্রিলের সারি ট্রেনিংয়ে আর অক্টোবরের সারি টেস্টে চলে যেতে পারে — যে ভবিষ্যতে মডেলের পরীক্ষা হবে, সেখান থেকেই সে শিখে ফেলবে। চার্ন মডেল সবসময় ট্রেনিংয়ের পরের কোনো তারিখে ব্যবহার হয়, তাই পরীক্ষাও সেভাবে নিন: আগের দুটো কাটঅফে ট্রেন, সর্বশেষটায় টেস্ট। ট্রেনিংয়ের লেবেল (৩১ মার্চ পর্যন্ত) টেস্ট কাটঅফের আগেই শেষ, তাই টেস্টের সময়ের কিছুই ট্রেনিংয়ে ঢোকে না।
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
NUMERIC = ["age", "tenure_days", "orders_all", "orders_90d", "log_spend",
"recency_days", "tickets_90d", "trend"]
CATEGORICAL = ["plan", "city"]
def make_model(numeric=NUMERIC):
features = ColumnTransformer([
("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()), numeric),
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
])
return make_pipeline(features, LogisticRegression(class_weight="balanced", max_iter=1000))
def evaluate(train, test, numeric=NUMERIC):
model = make_model(numeric).fit(train[numeric + CATEGORICAL], train["churned"])
proba = model.predict_proba(test[numeric + CATEGORICAL])[:, 1]
return model, roc_auc_score(test["churned"], proba), (proba >= 0.5).astype(int)
train = prepare(pd.concat([snaps["2025-10-01"], snaps["2026-01-01"]], ignore_index=True))
test = prepare(snaps["2026-04-01"])
model, auc, pred = evaluate(train, test)
print(f"train rows {len(train)}, test rows {len(test)}")
print(f"always 'stays' accuracy: {1 - test['churned'].mean():.3f}")
print(f"model accuracy: {(pred == test['churned']).mean():.3f}")
print(f"ROC AUC {auc:.3f} | precision {precision_score(test['churned'], pred):.2f}"
f" | recall {recall_score(test['churned'], pred):.2f}")
weights = pd.Series(model[-1].coef_[0], index=model[0].get_feature_names_out())
print(weights.sort_values().round(2).iloc[[0, 1, -2, -1]].to_string())train rows 1642, test rows 992
always 'stays' accuracy: 0.777
model accuracy: 0.806
ROC AUC 0.861 | precision 0.55 | recall 0.72
cat__plan_pro -0.52
num__log_spend -0.51
num__recency_days 0.63
num__tickets_90d 1.62- ইমপিউটার আর স্কেলার পাইপলাইনের ভেতরে fit হয়, শুধু ট্রেনিং সারিতে। ভাগ করার আগে সব সারির median বা mean বের করা হলো ট্রেন/টেস্ট কন্টামিনেশন: ছোট লিক, কিন্তু সত্যিকারের লিক।
handle_unknown="ignore"থাকলে প্রোডাকশনে নতুন কোনো শহর এলেও মডেল কাজ করে: নতুন মানটার সব কলামে শুধু 0 বসে। এনকোডার ডিফল্টভাবে একটা sparse ম্যাট্রিক্স ফেরত দেয় (sparse_output=True), পাইপলাইন সেটা নিজেই সামলায়; এনকোড করা কলামগুলো নিজে দেখতে চাইলে তবেইsparse_output=Falseদিন।class_weight="balanced"চলে যাওয়া প্রত্যেক গ্রাহককে থেকে যাওয়া গ্রাহকের প্রায় চার গুণ গুরুত্ব দেয় (দুই ক্লাসের ভাগের উল্টো অনুপাতে, মোটামুটি ২১% বনাম ৭৯%), যাতে মডেল শুধু সংখ্যাগরিষ্ঠ ক্লাসটা না বলে। accuracy "সবসময় থাকবেন"-এর চেয়ে সামান্যই বেশি, কিন্তু recall 0.72 বলছে আসলে যাঁরা চলে যান তাঁদের বেশিরভাগকেই মডেল ধরছে, precision 0.55 বলছে মডেল যাঁদের চিহ্নিত করে তাঁদের প্রায় অর্ধেক সত্যিই চলে যান (সস্তা একটা রিটেনশন মেসেজের জন্য যথেষ্ট, দামি ডিসকাউন্টের জন্য নয়), আর ROC AUC (0.5 = টস, 1.0 = নিখুঁত ক্রম) বলছে ঝুঁকিপূর্ণ গ্রাহকদের মডেল ভালোভাবে সাজাতে পারে।- ওয়েটগুলো যুক্তিসংগত: বেশি সাপোর্ট টিকিট আর শেষ অর্ডারের পর বেশি সময় চার্নের দিকে ঠেলে; pro প্ল্যান আর বেশি খরচ উল্টো দিকে।
লিকেজ, মেপে দেখা
এবার ইচ্ছে করে দুইভাবে ভাঙুন। লিক ১ ক্লাসিক বাগ: history CTE-তে WHERE order_date < :cutoff লিখতে ভুলে গেছেন, তাই "সব অর্ডার"-এর মধ্যে লেবেল উইন্ডোও ঢুকে গেছে। লিক ২ সূক্ষ্ম: আজকের customer টেবিলের একটা কলাম, closed_on, ফিচার হিসেবে ব্যবহার। দেখে গ্রাহক সম্পর্কে একটা তথ্য মনে হয়, কিন্তু পপুলেশনের প্রতিটা অ্যাকাউন্ট কাটঅফের দিনে খোলা ছিল; বন্ধ হয়েছে পরে।
LEAKY_SQL = FEATURES_SQL.replace(" WHERE order_date < :cutoff\n", "") # কাটঅফ দিতে ভুলে গেছি
leaky = {cut: pd.read_sql(LEAKY_SQL, con, params={"cutoff": cut}) for cut in snaps}
leaky_train = prepare(pd.concat([leaky["2025-10-01"], leaky["2026-01-01"]], ignore_index=True))
leaky_test = prepare(leaky["2026-04-01"])
print("leak 1 recency_days min:", leaky_test["recency_days"].min())
print(f"leak 1 ROC AUC: {evaluate(leaky_train, leaky_test)[1]:.3f}")
closed = pd.read_sql("SELECT customer_id, closed_on IS NOT NULL AS account_closed FROM sim_customers", con)
train2, test2 = train.merge(closed, on="customer_id"), test.merge(closed, on="customer_id")
print(f"leak 2 ROC AUC: {evaluate(train2, test2, NUMERIC + ['account_closed'])[1]:.3f}")
print(f"honest ROC AUC: {auc:.3f}")leak 1 recency_days min: -89
leak 1 ROC AUC: 0.979
leak 2 ROC AUC: 0.880
honest ROC AUC: 0.861লিক ১-এ রিসেন্সি ঋণাত্মক — "শেষ অর্ডার" ভবিষ্যতে — আর স্কোর প্রায় নিখুঁত। বাস্তবে লিক ২ আরও খারাপ: বিশ্বাসযোগ্য দুয়েক পয়েন্ট বাড়ায়, কারও সন্দেহ হয় না, অথচ প্রোডাকশনে সক্রিয় গ্রাহকদের জন্য ফিচারটা সবসময় 0, তাই রিপোর্টে মডেল যতটা ভালো দেখায়, বাস্তবে নিঃশব্দে তার চেয়ে খারাপ চলে। AI-এর কাজে লিকেজের সাধারণ উৎস:
- যে কলাম অ্যাপ্লিকেশন বারবার ওভাররাইট করে:
status,closed_on,last_login,lifetime_value— এগুলোতে থাকে আজকের মান, কাটঅফের দিনের মান নয়। - তারিখের ফিল্টার ছাড়া অ্যাগ্রিগেট, বা যে জানালা কাটঅফে না থেমে "এখন" পর্যন্ত যায়।
- টার্গেট থেকেই হিসাব করা ফিচার (রিফান্ড প্রেডিক্ট করার সময় "refund requested" ফ্ল্যাগ)।
- একই উদাহরণ, বা তার প্রায় হুবহু কপি, ট্রেন আর টেস্ট দুই জায়গাতেই: ডুপ্লিকেট সারি, যা র্যান্ডম ভাগে দুই দিকেই চলে যায়, একই টিকিট দুবার, LLM eval সেটে প্রায় একই ডকুমেন্ট। (একই গ্রাহক পরের কোনো কাটঅফে থাকলে, যেমন আমাদের সময় ধরে ভাগে, তাতে সমস্যা নেই: মডেল ঠিক এভাবেই ব্যবহার হয়।)
মোটামুটি নিয়ম: স্কোর অবিশ্বাস্য রকম ভালো দেখালে আনন্দ করার আগে লিক খুঁজুন।
পুনরুৎপাদনযোগ্য স্ন্যাপশট: চেক, Parquet আর হ্যাশ
যে ট্রেনিং সেট আবার বানানো যায় না, সেটা ডিবাগও করা যায় না। ট্রেনিংয়ের আগে সস্তা কয়েকটা চেক চালান; তারপর প্রতিটা স্ন্যাপশট ভার্সন করা ফাইলে রাখুন আর তার কনটেন্টের একটা ফিঙ্গারপ্রিন্ট লিখে রাখুন:
import hashlib
import json
def check(df, cutoff):
assert not df.duplicated(["customer_id", "cutoff"]).any(), "one row per customer and cutoff"
assert set(df["churned"].unique()) <= {0, 1}, "target must be 0/1"
assert (df["recency_days"] >= 0).all(), "a feature looked past the cutoff"
assert 0.05 < df["churned"].mean() < 0.5, "churn rate outside the expected range"
assert df["cutoff"].eq(cutoff).all()
manifest = []
for cut, df in snaps.items():
check(df, cut)
df.to_parquet(f"churn_features_{cut}.parquet", index=False)
digest = hashlib.sha256(df.to_csv(index=False).encode()).hexdigest()[:16]
manifest.append({"file": f"churn_features_{cut}.parquet", "rows": len(df), "sha256_16": digest})
with open("manifest.json", "w") as f:
json.dump({"seed": 42, "query": "FEATURES_SQL v1", "snapshots": manifest}, f, indent=2)
print(json.dumps(manifest[-1]))
try:
check(leaky["2026-04-01"], "2026-04-01")
except AssertionError as e:
print("leaky snapshot rejected:", e){"file": "churn_features_2026-04-01.parquet", "rows": 992, "sha256_16": "ea1a0912c25b37aa"}
leaky snapshot rejected: a feature looked past the cutoff- প্রতিটা র্যান্ডম ধাপে seed দিন (ডেটা জেনারেশন, ভাগ, মডেল)। কোয়েরি আর স্ন্যাপশট ফাইল ভার্সন করুন; গত মাসের ট্রেনিং সেট কখনো ওভাররাইট করবেন না।
- হ্যাশ করুন কনটেন্ট (এখানে একটা নির্দিষ্ট CSV লেখা), Parquet-এর বাইট নয় — লাইব্রেরির ভার্সন বদলালে সেগুলো বদলে যেতে পারে। একই হ্যাশ মানে একই ডেটা, তাই দুজন মানুষ প্রমাণ করতে পারেন যে তাঁরা একই সারিতে ট্রেন করেছেন।
- Parquet ডেটা টাইপ ধরে রাখে, ছোট, আর পড়তে দ্রুত (ইমপোর্ট ও এক্সপোর্ট: CSV, JSON, Excel আর Parquet দেখুন)। আসল ফিচার স্টোর (feature store) বড় মাপে ঠিক এটাই করে: পয়েন্ট-ইন-টাইম জয়েন, ভার্সন করা ফিচার, আর ট্রেনিং ও সার্ভিংয়ে একই কোড।
সাধারণ ভুল
- "এখন" থেকে ফিচার বানানো।
julianday('now') - julianday(MAX(order_date))আজকের জন্য চলে, কিন্তু প্রতিটা ট্রেনিং সারির জন্য ভুল। সমাধান: প্রতিটা জানালা একটা:cutoffপ্যারামিটারের সাপেক্ষে হিসাব করুন, আর প্রতিটা উৎস< :cutoffদিয়ে ফিল্টার করুন। - বদলাতে থাকা কলামের বর্তমান মান ব্যবহার। ট্রেনিং সারিতে
customers.statusবাclosed_onআজকের কথা বলে। সমাধান: তারিখওয়ালা ইভেন্ট থেকে মানটা আবার বানান (WHERE event_date < :cutoff), বা একটা হিস্ট্রি টেবিল রাখুন (ওয়্যারহাউস, লেক, স্ট্রিম আর স্কেল পাতার slowly changing dimension দেখুন)। - ভাগের আগে ইমপিউট বা স্কেল করা। সব সারিতে
df["age"].fillna(df["age"].median())করলে টেস্ট ডেটা ট্রেনিংকে প্রভাবিত করে। সমাধান:SimpleImputerআরStandardScalerপাইপলাইনে রাখুন, আর শুধু ট্রেনে fit করুন। - সময়ের ক্রমে সাজানো ডেটায় র্যান্ডম ভাগ। ভবিষ্যৎ আর অতীত মিশে যায়, মান বাড়িয়ে দেখায়। সমাধান: কাটঅফ তারিখ ধরে ভাগ করুন; ট্রেনিংয়ের লেবেল টেস্ট কাটঅফের আগে শেষ হচ্ছে কি না দেখুন।
- অসম সমস্যায় accuracy দিয়ে বিচার। এখানে ৭৮% accuracy বিনা খাটুনিতে মেলে। সমাধান: ROC AUC, precision আর recall জানান, আর "সবসময় সংখ্যাগরিষ্ঠ" বেসলাইনের সাথে তুলনা করুন। যেখানে র্যান্ডম ভাগই ঠিক (সময়ের ক্রম নেই এমন ডেটা), সেখানে
train_test_split-এstratify=yদিন, যাতে দুই ভাগেই চলে যাওয়া গ্রাহকের অনুপাত একই থাকে।
নিজে চেষ্টা করুন
- সহজ: শপের RFM কোয়েরিটা কাটঅফ
2026-03-01আর2026-06-01পর্যন্ত লেবেল উইন্ডো দিয়ে চালান। কোন গ্রাহকেরা পপুলেশন থেকে বাদ পড়েন, আর কেন? - মাঝারি: স্ন্যাপশটে SQL দিয়ে
avg_order_value(কাটঅফের আগের খরচ ভাগ অর্ডার সংখ্যা) যোগ করুন, তারপর এটাকে বাড়তি একটা সংখ্যাসূচক ফিচার করে আবার ট্রেন করুন আর টেস্টের ROC AUC প্রিন্ট করুন। - কঠিন:
assert_point_in_time(cutoff)লিখুন: এটা স্ন্যাপশট আবার বানাবে, তারপর প্রত্যেক গ্রাহকেরorders_all-কে কাটঅফের ঠিক আগ পর্যন্ত অর্ডারের একটা SQL count-এর সাথে মিলিয়ে নিশ্চিত করবে যে কাটঅফের দিন বা পরের কোনো অর্ডার গোনা হয়নি।
উত্তর
-- ১. গ্রাহক 7 আর 8 যোগ দিয়েছেন 2026-03-01-এর পরে, তাই তাঁরা পপুলেশনে নেই।
WITH order_totals AS (
SELECT o.order_id, o.customer_id, o.order_date, o.status,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.customer_id, o.order_date, o.status
)
SELECT c.customer_id,
COUNT(t.order_id) AS frequency,
COALESCE(SUM(t.total), 0) AS monetary,
CAST(julianday('2026-03-01') - julianday(MAX(t.order_date)) AS INTEGER) AS recency_days,
CASE WHEN EXISTS (
SELECT 1 FROM orders AS f
WHERE f.customer_id = c.customer_id
AND f.status <> 'cancelled'
AND f.order_date >= '2026-03-01' AND f.order_date < '2026-06-01'
) THEN 0 ELSE 1 END AS churned
FROM customers AS c
LEFT JOIN order_totals AS t
ON t.customer_id = c.customer_id
AND t.order_date < '2026-03-01'
AND t.status <> 'cancelled'
WHERE c.joined_on < '2026-03-01'
GROUP BY c.customer_id
ORDER BY c.customer_id;# ২. SQL-এ একটা কলাম বেশি, ফিচার লিস্টে একটা নাম বেশি
AOV_SQL = FEATURES_SQL.replace(
"h.spend_all, h.recency_days,",
"h.spend_all, h.recency_days, ROUND(1.0 * h.spend_all / h.orders_all, 2) AS avg_order_value,")
aov = {cut: pd.read_sql(AOV_SQL, con, params={"cutoff": cut}) for cut in snaps}
aov_train = prepare(pd.concat([aov["2025-10-01"], aov["2026-01-01"]], ignore_index=True))
aov_test = prepare(aov["2026-04-01"])
print(f"ROC AUC with avg_order_value: {evaluate(aov_train, aov_test, NUMERIC + ['avg_order_value'])[1]:.3f}")
# ৩. প্রতিটা ট্রেনিং জবের আগে চালানোর মতো একটা পয়েন্ট-ইন-টাইম টেস্ট
def assert_point_in_time(cutoff):
df = snapshot(cutoff)
truth = pd.read_sql(
"SELECT customer_id, COUNT(*) AS n FROM sim_orders WHERE order_date < :cutoff GROUP BY customer_id",
con, params={"cutoff": cutoff})
merged = df.merge(truth, on="customer_id")
assert len(merged) == len(df), "every customer in the snapshot has history"
assert (merged["orders_all"] == merged["n"]).all(), "orders_all counted orders after the cutoff"
assert (df["recency_days"] >= 0).all(), "a last order after the cutoff"
print(cutoff, "is point-in-time correct")
assert_point_in_time("2026-04-01")সারসংক্ষেপ
- ML-এর উপযোগী টেবিলে প্রতি কাটঅফে প্রতি এনটিটির একটা সারি: কাটঅফের আগের ফিচার, পরের জানালার টার্গেট, আর স্পষ্টভাবে ঠিক করা পপুলেশন।
- ফিচার কোয়েরিতে একটা
:cutoffপ্যারামিটার রাখুন আর প্রতিটা উৎস তা দিয়ে ফিল্টার করুন; জয়েন, জানালা আর RFM অ্যাগ্রিগেটের জায়গা SQL। - অজানা মান SQL-এ NULL রাখুন; ইমপিউট, স্কেল আর এনকোড করুন শুধু ট্রেনিং সারিতে fit করা scikit-learn পাইপলাইনের ভেতরে।
- সময় ধরে ভাগ করুন, ট্রেনিংয়ের লেবেল টেস্ট কাটঅফের আগে শেষ কি না দেখুন, আর সংখ্যাগরিষ্ঠ বেসলাইনের পাশে AUC, precision আর recall জানান।
- লিকেজ লুকিয়ে থাকে "এখন"-এ, ওভাররাইট হওয়া কলামে আর ভুলে যাওয়া তারিখের ফিল্টারে; সন্দেহজনক রকম ভালো স্কোর একটা সূত্র।
- প্রতিটা স্ন্যাপশটে seed, ভার্সন, চেক, Parquet আর হ্যাশ — যাতে যেকোনো মডেলকে তার হুবহু ডেটা পর্যন্ত খুঁজে পাওয়া যায়।
এরপর: AI অ্যাপের জন্য SQL: এমবেডিং, pgvector আর RAG মেটাডেটা ট্রেনিং ডেটা থেকে সরে যায় LLM অ্যাপ্লিকেশন যে ডেটা তৈরি আর ব্যবহার করে তার দিকে: কথোপকথনের লগ, টোকেনের খরচ, JSON, ফুল-টেক্সট সার্চ আর PostgreSQL-এর ভেতরেই ভেক্টর সার্চ।