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

Model serving — FastAPI, batch scoring আর ONNX

Model Serving — Batch, Online and Streaming, FastAPI, Batch Scoring and ONNX

Taught in Bangla, this lesson shows how to serve a scikit-learn pipeline: choosing batch, online or streaming, latency percentiles and Little's Law, input validation, chunked batch scoring and training-serving skew, with real in-browser runs. Desktop runs cover safe loading with skops, a FastAPI service tested with TestClient, ONNX export and a shadow-to-canary rollout.

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

  • Batch, online আর streaming serving-এর মধ্যে বাছাই, আর latency, throughput ও latency budget-এর হিসাব
  • p50, p95, p99 হাতে বের করা, আর কেন গড় দিয়ে service-এর মান বলা যায় না
  • Pipeline, ইনপুট-যাচাই আর model version একসঙ্গে package করা; chunk-এ batch scoring
  • Training-serving skew চেনা ও ঠেকানো; joblib, skops আর metadata দিয়ে নিরাপদ, পুনরুৎপাদনযোগ্য model ফাইল
  • FastAPI-তে একটি আসল service, TestClient দিয়ে পরীক্ষা, ONNX export, আর shadow ও canary দিয়ে নতুন model চালু করা

এ পর্যন্ত কোর্সের প্রায় সব model notebook-এ শিখেছে আর notebook-এই উত্তর দিয়েছে। পর্ব ৬৬-এর dashboard প্রথমবার একটি সংরক্ষিত pipeline load করে মানুষের সামনে অনুমান দেখিয়েছিল। এবার প্রশ্ন বড়: চেকআউট পাতা, রাতের ব্যাচ আর সন্দেহজনক অর্ডারের পাহারা, তিনটিই একই model চাইলে model-কে কীভাবে চালু রাখবেন? Pipeline আর ColumnTransformer-এর মূল কথা AI-এর ভিত্তি কোর্সের পর্ব ৬৪-এ, uv আর version pin করা একই কোর্সের পর্ব ১২-এ আছে; এখানে সেগুলো ধরে নিয়ে গভীরে যাব। চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python; প্রথমবার scikit-learn নামতে প্রায় দশ সেকেন্ড)। FastAPI, skops আর ONNX পাতায় চলে না, তাই সেগুলোর আসল আউটপুট ডেস্কটপের সত্যিকারের run থেকে।

১ · Serving-এর তিন ধরন — batch, online আর streaming

পাড়ার হোটেলের কথা ভাবুন। সকালে বড় ডেকচিতে তেহারি রান্না হয়: দুপুরে যে আসবেন, তিনি সঙ্গে সঙ্গে প্লেট পান, কারণ রান্না আগেই শেষ। এটি batch. চা কিন্তু আগে বানিয়ে রাখা যায় না; কেউ “চিনি কম” বললে তবেই বানানো শুরু, আর তিনি কাউন্টারে দাঁড়িয়ে অপেক্ষা করেন। এটি online. আর রান্নাঘরে চুলার পাশে যিনি বসে প্রতিটি ফেরত আসা প্লেট দেখে সঙ্গে সঙ্গে খাতায় দাগ দেন, কেউ তাঁকে ডাকে না, প্লেট এলেই কাজ: এটি streaming.

Model serving মানে শেখানো model-কে এমনভাবে চালু রাখা, যাতে অন্য সিস্টেম নতুন ডেটার অনুমান পায়। তিন রূপ। Batch scoring: নির্দিষ্ট সময় পরপর (যেমন রাত ২টায়) পুরো টেবিলের অনুমান করে আরেকটি টেবিলে লিখে রাখা; পরে শুধু পড়লেই চলে। Online inference: অনুরোধ এলে model চালিয়ে মিলিসেকেন্ডে উত্তর, সাধারণত একটি API দিয়ে। Streaming inference: queue-তে আসা প্রতিটি ঘটনা পড়ে কয়েক সেকেন্ডের মধ্যে অনুমান, কেউ আলাদা করে না চাইলেও। Batch আর stream processing-এর সাধারণ নকশা (window, watermark) System Design কোর্সের batch ও stream processing পর্বে আছে।

একটি model artifact, তিনটি পথ

বাজারঘরের (কাল্পনিক দোকান) তিনটি কাজ একই ধরনের ফাইল থেকে চলে; বদলায় কখন, কত সারি আর কে অপেক্ষা করেন।

One model artifact served three ways: batch, online and streaming model.joblib pipeline + metadata Batch — রাতে একবার, লাখ সারি Online — অনুরোধে, p95 < 100 ms Streaming — ঘটনায়, কয়েক সেকেন্ডে orderstable nightly jobchunks scorestable checkoutapp POST/predict ETA +version orderevents consumer(queue) fraudflag কেউ অপেক্ষা করেন না; পরদিন সকালে পড়া হয় ক্রেতা স্ক্রিনের সামনে অপেক্ষা করছেন কেউ ডাকে না; ঘটনা এলেই কাজ
ছবি ১.১ — ওপরের একই artifact (pipeline + metadata) তিনটি পথে যায়। মাঝের বাক্সটিই model চালায়: রাতের job, API-র /predict, বা queue থেকে পড়া consumer.

কোনটি বাছবেন, তা চারটি প্রশ্নে ঠিক হয়: ইনপুট কখন জানা যায়, উত্তর কত টাটকা হতে হবে, কত সারি, আর কে অপেক্ষা করেন। নিচের stepper বাজারঘরের তিনটি কাজে প্রশ্নগুলো চালায়।

চার প্রশ্নইনপুট · টাটকা · আকার · কে অপেক্ষা করেন

            
  1. চার প্রশ্ন। Serving-এর ধরন প্রযুক্তি দিয়ে নয়, কাজের সময়সূচি দিয়ে ঠিক হয়। প্রশ্নগুলো খাতায় লিখে প্রতিটি কাজের উত্তর বসান।
  2. Churn-ঝুঁকি। ইনপুট গতকালের মধ্যেই জানা, উত্তর লাগবে কাল সকালে, কেউ অপেক্ষায় নেই। তাই রাতের batch. ফলের টেবিলে model_version আর scored_at রাখলে পরে বোঝা যায় কোন model কবে স্কোর দিয়েছিল।
  3. চেকআউট। ঠিকানা আর ঝুড়ির ওজন ক্লিকের আগে জানা নেই, আর ক্রেতা অপেক্ষা করছেন। তাই online. পুরো পাতার সময় থেকে model-এর ভাগ আলাদা করে লিখতে হয় (অংশ ২)।
  4. সন্দেহজনক অর্ডার। কেউ “চাই” বলে ডাকে না, কিন্তু প্যাকিংয়ের আগে সিদ্ধান্ত লাগবে। Queue থেকে পড়া একটি consumer প্রতিটি ঘটনায় model চালায়। মিলিসেকেন্ডের কড়াকড়ি নেই, কয়েক সেকেন্ড চলে।
  5. নিয়ম আর মিশ্র রূপ। বাস্তবে প্রায়ই দুটি মেশে: ধীরে বদলানো feature (জেলার গড় দেরি) রাতে batch-এ হিসাব হয়, আর online model অনুরোধের সময় সেটি পড়ে নেয়। এভাবে online-এর কাজ হালকা থাকে।

২ · Latency আর throughput — p50, p95, p99 আর budget

দুটি মাপ আলাদা রাখুন। Latency: একটি অনুরোধের শুরু থেকে উত্তর পর্যন্ত সময়। Throughput: প্রতি সেকেন্ডে কতগুলো অনুরোধ বা সারি শেষ হয়। Batch-এ throughput আসল মাপ (রাতের মধ্যে শেষ হবে কি না), online-এ latency. Latency-কে গড়ে বলা ভুল, কারণ কয়েকটি ধীর অনুরোধ গড়কে টানে, আর তাদেরই ক্রেতা বিরক্ত হন। তাই বলা হয় percentile-এ: p95 = ৯৫% অনুরোধ এর চেয়ে কম সময়ে শেষ। ধীর প্রান্তটিকে বলে tail latency. নিচের stepper দশটি মাপ থেকে nearest-rank পদ্ধতিতে percentile বের করে: সাজানো তালিকায় rank = ⌈q × n⌉, যেখানে q ভগ্নাংশে percentile আর n মাপের সংখ্যা।

/predict-এর ১০টি মাপ (ms)n = 10

            
  1. কাঁচা মাপ। দশটি অনুরোধের সময়। গড় ২২.৬ ms, যা প্রায় কোনো অনুরোধের আসল সময়ের কাছে নেই: নয়টি ২৫ ms-এর নিচে, একটি ৯০ ms.
  2. সাজানো। Percentile-এর প্রথম কাজ ছোট থেকে বড়ে সাজানো। এখন প্রতিটি মানের একটি rank আছে।
  3. p50 বা median. Rank ⌈০.৫০ × ১০⌉ = ৫, তাই ১৪ ms. NumPy-র default পদ্ধতি দুই পাশের মানের মাঝে রেখা টানে, তাই ১৪.৫. পদ্ধতিভেদে উত্তর একটু বদলায়; report-এ পদ্ধতি লিখুন।
  4. Tail. p90 = ২৫ ms, কিন্তু p95 আর p99 দুটিই rank ১০, অর্থাৎ সর্বোচ্চ মান ৯০ ms. দশটি মাপে p99 আলাদা করে মাপাই যায় না; p99-এর জন্য অন্তত কয়েকশো মাপ লাগে।
  5. Budget-এর বিচার। লক্ষ্য p95 ≤ ৫০ ms; দুই পদ্ধতিতেই ফেল। কারণ একটিমাত্র ধীর অনুরোধ, তাই প্রথম কাজ সেটির কারণ খোঁজা (প্রথম অনুরোধে model load? garbage collection? বড় ইনপুট?)।

Latency budget আর capacity-র হিসাব। ধরুন চেকআউট পাতার মোট লক্ষ্য p95 ৩০০ ms. Network আর পাতা আঁকায় ১৫০, অর্ডার-সার্ভিসে ৮০, বাকি ৭০ ms-এর মধ্যে feature আনা ২০ আর model ৫০. এভাবে ভাগ করলে প্রতিটি দল জানে তার সীমা। Capacity-র জন্য Little’s Law: L = λ × W, যেখানে λ প্রতি সেকেন্ডে আসা অনুরোধ, W প্রতিটির গড় সময় আর L একসঙ্গে চলমান অনুরোধের গড় সংখ্যা। ঈদের আগে সর্বোচ্চ ২০০ অনুরোধ/সেকেন্ড আর গড় ৫০ ms হলে L = ২০০ × ০.০৫ = ১০; অর্থাৎ গড়ে দশটি অনুরোধ একসঙ্গে চলে, তাই একটি-একটি করে কাজ করা worker অন্তত দশটি, আর ওঠানামার জন্য কিছু বাড়তি। নিয়মটি System Design কোর্সের availability পর্বের SLO ভাবনার সঙ্গে মিলিয়ে পড়ুন।

৩ · Model-কে package করা — একটি pipeline, ইনপুট-যাচাই আর version

সার্ভিসে model মানে শুধু HistGradientBoostingRegressor নয়। জেলার one-hot, ওজন না থাকলে training-এর median বসানো (imputation), আর model, সব মিলে একটি pipeline; সার্ভিস কাঁচা field দেয়, pipeline বাকিটা করে। নিচের module বাজারঘরের ৩,০০০টি ডেলিভারি seed দিয়ে বানায় (দূরত্ব ১–৪০ km, চার জেলা, বৃষ্টি, ওজন; ৮% অর্ডারে ওজন লেখা নেই) আর একটি ColumnTransformer + HistGradientBoosting pipeline দেয়। চারটি ব্লক আর ডেস্কটপের app একই module ব্যবহার করে।

# বাজারঘর (কাল্পনিক দোকান): seed দিয়ে বানানো ডেলিভারির ডেটা আর একটি pipeline
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

DISTRICTS = ["Dhaka", "Chattogram", "Sylhet", "Rajshahi"]
FEATURES = ["km", "district", "rain", "weight_kg"]

def deliveries(n=3000, seed=68):
    rng = np.random.default_rng(seed)
    district = rng.choice(DISTRICTS, n, p=[0.5, 0.2, 0.15, 0.15])
    km = rng.uniform(1, 40, n).round(1)
    rain = rng.binomial(1, 0.3, n)
    weight = rng.gamma(2.0, 1.5, n).round(1)
    extra = pd.Series(district).map(dict(zip(DISTRICTS, [0, 3, 6, 5]))).to_numpy()
    hours = (6 + 0.55 * km + 4 * rain + extra + 0.8 * weight
             + (1 + 0.04 * km) * rng.standard_normal(n))
    weight[rng.random(n) < 0.08] = np.nan        # ৮% অর্ডারে ওজন লেখা হয়নি
    X = pd.DataFrame({"km": km, "district": district,
                      "rain": rain, "weight_kg": weight})
    return X, np.maximum(hours, 1).round(2)

def build_pipeline():
    pre = ColumnTransformer([
        ("district", OneHotEncoder(), ["district"]),
        ("weight", SimpleImputer(strategy="median"), ["weight_kg"]),
        ("num", "passthrough", ["km", "rain"])])
    return Pipeline([("pre", pre),
                     ("model", HistGradientBoostingRegressor(random_state=0))])

সার্ভিসের প্রথম পাহারা input validation: model-এ দেওয়ার আগে প্রতিটি field-এর ধরন, সীমা আর চেনা মান দেখা। Training ডেটায় দূরত্ব ১ থেকে ৪০ km; ৮০ km-এ গাছভিত্তিক model শেষ পাতার মানই দেয়, কোনো সতর্কতা ছাড়া। নিচের predict ঠিক সার্ভিসের মতো একটি dictionary নেয় আর উত্তরে সবসময় model version ফেরত দেয়, যাতে প্রতিটি উত্তর কোন model-এর তা পরে খুঁজে পাওয়া যায়। পাতার Python শুধু ব্লকের নিজের import দেখে package নামায়, তাই module-এর জন্য sklearn-ও ব্লকে import করা আছে।

import pandas as pd, sklearn                 # sklearn: module-টির জন্য
from bazar_serve import DISTRICTS, FEATURES, deliveries, build_pipeline

X, y = deliveries()
pipe = build_pipeline().fit(X.iloc[:2400], y[:2400])
VERSION = "delivery-hgb-2026.11.1"
LIMITS = {"km": (1, 40), "weight_kg": (0.1, 30)}

def validate(p):
    errs = [f"{k}: unknown field" for k in p if k not in FEATURES]
    for k, (lo, hi) in LIMITS.items():
        v = p.get(k)
        if v is None and k == "weight_kg":
            continue                              # ওজন নেই: pipeline-এর imputer সামলাবে
        if isinstance(v, bool) or not isinstance(v, (int, float)):
            errs.append(f"{k}: number required, got {v!r}")
        elif not lo <= v <= hi:
            errs.append(f"{k}: {v} outside [{lo}, {hi}]")
    if p.get("district") not in DISTRICTS:
        errs.append(f"district: unknown {p.get('district')!r}")
    if p.get("rain") not in (0, 1):
        errs.append(f"rain: 0/1 required, got {p.get('rain')!r}")
    return errs
def predict(p):
    if errs := validate(p):
        return {"ok": False, "errors": errs, "model_version": VERSION}
    row = pd.DataFrame([{k: p.get(k) for k in FEATURES}]).astype({"weight_kg": float})
    return {"ok": True, "hours": round(float(pipe.predict(row)[0]), 2), "model_version": VERSION}

ok = {"km": 12.5, "district": "Dhaka", "rain": 0, "weight_kg": 2.0}
for p in [ok, {**ok, "weight_kg": None}, {**ok, "km": "12.5"}, {**ok, "km": 80},
          {**ok, "district": "Cox's Bazar"}, {**ok, "coupon": "EID10"}]:
    r = predict(p)
    print(r["model_version"], r["hours"] if r["ok"] else r["errors"][0])
print("no guard, km=80 ->", round(float(pipe.predict(pd.DataFrame([{**ok, "km": 80}]))[0]), 2))

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

delivery-hgb-2026.11.1 15.11
delivery-hgb-2026.11.1 15.88
delivery-hgb-2026.11.1 km: number required, got '12.5'
delivery-hgb-2026.11.1 km: 80 outside [1, 40]
delivery-hgb-2026.11.1 district: unknown "Cox's Bazar"
delivery-hgb-2026.11.1 coupon: unknown field
no guard, km=80 -> 27.55

প্রথম দুটি বৈধ অনুরোধ: ওজন না দিলেও উত্তর আসে, কারণ pipeline-এর imputer training-এর median বসায়। পরের চারটি model পর্যন্ত পৌঁছায়ই না: লেখা হিসেবে আসা সংখ্যা, সীমার বাইরের দূরত্ব, অচেনা জেলা আর অচেনা field, প্রতিটির পরিষ্কার কারণসহ error. শেষ লাইন বিপদটি দেখায়: পাহারা ছাড়া ৮০ km-এর অর্ডারেও model নিশ্চিন্তে একটি সংখ্যা দেয়, যা ৪০ km-এর অর্ডারের উত্তরের হুবহু সমান (ডেস্কটপে ৮০ আর ৫০০ km দিয়ে যাচাই করা: গাছের শেষ ভাগের পরে সব দূরত্ব একই পাতায় পড়ে)। অচেনা জেলা পাহারা ছাড়া গেলে আবার উল্টো: OneHotEncoder-এর default নিয়মে ValueError ওঠে আর সার্ভিস ৫০০ error দেয়। তাই ইনপুট-যাচাই model-এর অংশ নয়, তার সামনের দরজা।

৪ · Batch scoring — পুরো টেবিল chunk-এ, আর একটি-একটি ডাকের দাম

রাতের job-এ ২০,০০০টি অর্ডারের অনুমান লাগবে। সহজ ভুল: online-এর predict লুপে ২০,০০০ বার ডাকা। প্রতিটি ডাকে DataFrame বানানো, ColumnTransformer-এর প্রতিটি ধাপ, Python-এর function ডাকার খরচ আবার আবার দিতে হয়, আর আসল গাণিতিক কাজ অল্পই। তাই batch-এ টেবিলটিকে কয়েক হাজার সারির টুকরোয় ভাগ করা হয় (chunked processing): প্রতিটি ডাকে অনেক সারি, অথচ memory সীমার মধ্যে। নিচের ব্লক একটি-একটি ডাকের latency (p50, p95) আর তিন মাপের chunk-এর throughput মাপে।

import time, numpy as np
import pandas, sklearn                       # bazar_serve module-এর জন্য
import matplotlib.pyplot as plt
from bazar_serve import deliveries, build_pipeline

X, y = deliveries()
pipe = build_pipeline().fit(X, y)
night, _ = deliveries(n=20000, seed=7)          # আজ রাতে স্কোর করার ২০,০০০ অর্ডার

lat, single = [], []
for i in range(150):                           # একটি একটি করে ১৫০টি ডাক
    t0 = time.perf_counter()
    single.append(pipe.predict(night.iloc[[i]])[0])
    lat.append((time.perf_counter() - t0) * 1000)
p50, p95 = np.percentile(lat, [50, 95])
rate = {"1": 1000 / np.mean(lat)}
print(f"1 row/call : p50 {p50:.2f} ms, p95 {p95:.2f} ms, {rate['1']:,.0f} rows/s")
print(f"  20,000 rows this way ~ {20000 / rate['1']:.0f} s")

for chunk in [200, 2000, 20000]:
    t0 = time.perf_counter()
    out = np.concatenate([pipe.predict(night.iloc[s:s + chunk])
                          for s in range(0, len(night), chunk)])
    sec = time.perf_counter() - t0
    rate[str(chunk)] = len(night) / sec
    print(f"chunk {chunk:>5}: {sec:.2f} s for all, {rate[str(chunk)]:,.0f} rows/s")
print("chunked == one by one:", np.allclose(out[:150], single))

fig, ax = plt.subplots(figsize=(6, 3.2))
ax.bar(list(rate), list(rate.values()), color=["tab:red"] + ["tab:blue"] * 3)
ax.set_yscale("log")
ax.set_xlabel("rows per predict() call")
ax.set_ylabel("rows per second (log)")
ax.set_title("Same model, same 20,000 orders: call size matters")
plt.show()

আসল আউটপুট (ব্রাউজারের Python; সময় যন্ত্রভেদে আর প্রতিবার একটু বদলায়, অনুপাতটিই শিক্ষা):

1 row/call : p50 9.60 ms, p95 10.70 ms, 103 rows/s
  20,000 rows this way ~ 194 s
chunk   200: 1.03 s for all, 19,374 rows/s
chunk  2000: 0.23 s for all, 87,298 rows/s
chunk 20000: 0.14 s for all, 139,470 rows/s
chunked == one by one: True

একটি একটি ডাকে প্রতি অনুরোধে p50 ৯.৬ ms, p95 ১০.৭ ms; সেকেন্ডে মাত্র ১০৩টি সারি, তাই ২০,০০০ সারিতে প্রায় ১৯৪ সেকেন্ড। ২০০ সারির chunk-এ পুরো টেবিল ১.০৩ সেকেন্ডে, ২,০০০-এ ০.২৩, এক ডাকে ০.১৪: সেকেন্ডে প্রায় ১,৩৯,০০০ সারি, একটি-একটি ডাকের এক হাজার গুণের বেশি। কারণ ১০ ms-এর প্রায় সবটাই প্রতিটি ডাকের নির্দিষ্ট খরচ, গাছ দিয়ে হিসাব নয়। ছবির y-অক্ষ log-এ, তাই প্রতিটি দাগ দশ গুণ। শেষ লাইন যাচাই করে যে chunk-এর ফল আর একটি-একটি ডাকের ফল হুবহু এক, অর্থাৎ chunk করা শুধু গতি বদলায়, উত্তর নয়। Chunk কত বড় হবে তা ঠিক হয় memory দিয়ে: ২,০০০ থেকে এক ডাকে লাভ কম, অথচ কোটি সারির টেবিল এক ডাকে memory-তে না-ও ধরতে পারে। ডেস্কটপে একই কাজে (একটি run) একটি-একটি ডাকে p50 ৩.২ ms আর এক ডাকে সেকেন্ডে দশ লাখের বেশি সারি এসেছিল; সংখ্যা বদলায়, অনুপাতের গল্প একই।

রাতের job-এর আরও তিনটি অভ্যাস। (ক) ফলের প্রতিটি সারিতে model_version আর scored_at. (খ) job আবার চালালে যেন দ্বিগুণ সারি না হয়: একই তারিখের পুরোনো ফল মুছে বা বদলে লেখা। (গ) ফল লেখার আগে সংখ্যা যাচাই: সারির সংখ্যা ইনপুটের সমান কি না, NaN আছে কি না, অনুমানের গড় গত রাতের কাছাকাছি কি না। ডেটা আরও বড় হলে, যখন পুরো টেবিল memory-তে ধরে না, তখনকার কৌশল পর্ব ৭০ (বড় ডেটায় ML — out-of-core, incremental learning আর Polars/DuckDB)-এর বিষয়।

৫ · Training–serving skew — একই হিসাব দুই জায়গায় লিখলে

দর্জি মাপ নিলেন ইঞ্চিতে, আর কারিগর কাপড় কাটলেন সেন্টিমিটার ধরে। দুজনেই নিজের কাজ ঠিকঠাক করেছেন, তবু জামা গায়ে লাগে না। ML-এ এই রোগের নাম training-serving skew: model শেখার সময় যে feature-মান দেখেছিল, চালু অবস্থায় তা অন্যভাবে হিসাব হয়ে আসে।

সবচেয়ে চেনা কারণ: data scientist-রা notebook-এ pandas দিয়ে feature বানান, আর সার্ভিসের দল অন্য ভাষায় বা অন্য library-তে সেটি “একই রকম” করে আবার লেখেন। ছোট ছোট অমিল জমে। নিচের ব্লকে সার্ভিসের দল তিনটি ছোট ভুল করেছে: দূরত্ব পূর্ণসংখ্যায় গোল করা, ওজন না থাকলে median-এর বদলে ০ বসানো, আর রাস্তার দূরত্বের বদলে মানচিত্রের সোজা-রেখার দূরত্ব (এখানে রাস্তার দূরত্ব ÷ ১.৩ ধরে নেওয়া)। এগুলো data drift নয়: পৃথিবী বদলায়নি, বদলেছে আমাদের কোড।

import numpy as np
import pandas, sklearn                       # bazar_serve module-এর জন্য
from bazar_serve import deliveries, build_pipeline

X, y = deliveries()
pipe = build_pipeline().fit(X.iloc[:2400], y[:2400])
raw, yt = X.iloc[2400:].reset_index(drop=True), y[2400:]   # সার্ভিসে আসা কাঁচা অনুরোধ
miss = raw.weight_kg.isna()

def report(name, feats):
    err = np.abs(yt - pipe.predict(feats))
    print(f"{name:<24} MAE {err.mean():.2f} h | no-weight rows {err[miss].mean():.2f} h")

# training-এ: রাস্তার দূরত্ব (১ দশমিক), ওজন না থাকলে training median
report("same pipeline", raw)
# সার্ভিস-দল নিজে feature কোড আবার লিখল, তিনটি ছোট অমিলসহ:
report("km rounded", raw.assign(km=raw.km.round()))
report("missing weight -> 0", raw.assign(weight_kg=raw.weight_kg.fillna(0)))
report("straight-line km", raw.assign(km=(raw.km / 1.3).round(1)))
skew = raw.assign(km=(raw.km / 1.3).round(), weight_kg=raw.weight_kg.fillna(0))
report("all three", skew)

print("training median weight:", pipe.named_steps["pre"]
      .named_transformers_["weight"].statistics_)
# প্রতিকার: সার্ভিস শুধু কাঁচা field যাচাই করে একই pipeline-কে দেয়
offline = pipe.predict(raw)
online = np.array([pipe.predict(raw.iloc[[i]])[0] for i in range(50)])
print("max |offline - online| on 50 rows:", np.abs(offline[:50] - online).max())

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

same pipeline            MAE 1.56 h | no-weight rows 1.90 h
km rounded               MAE 1.54 h | no-weight rows 1.92 h
missing weight -> 0      MAE 1.66 h | no-weight rows 3.02 h
straight-line km         MAE 2.78 h | no-weight rows 3.13 h
all three                MAE 2.95 h | no-weight rows 5.46 h
training median weight: [2.6]
max |offline - online| on 50 rows: 0.0

একই pipeline-এ গড় ভুল (MAE) ১.৫৬ ঘণ্টা, ওজন না থাকা অর্ডারে ১.৯০. দূরত্ব গোল করায় প্রায় কোনো ক্ষতি নেই (১.৫৪): সব অমিল সমান ক্ষতিকর নয়, তাই মেপে দেখতে হয়। ওজনের জায়গায় ০ বসালে মোট MAE সামান্য বাড়ে (১.৬৬), কারণ মাত্র ৮% অর্ডারে ওজন নেই; কিন্তু সেই অর্ডারগুলোতে ভুল ১.৯০ থেকে ৩.০২. গড় দেখলে এই ক্ষতি চোখে পড়ে না, ভাগ করে দেখলে পড়ে। সোজা-রেখার দূরত্ব সবচেয়ে বড় ক্ষতি (২.৭৮), কারণ দূরত্বই সবচেয়ে গুরুত্বপূর্ণ feature আর প্রতিটি অর্ডারে তা কম আসে, তাই অনুমান নিয়মিত কম হয়। তিনটি একসঙ্গে ২.৯৫, প্রায় দ্বিগুণ, আর ওজনহীন অর্ডারে ৫.৪৬. কোনো error আসেনি, কোনো সতর্কতাও নয়: skew নীরব রোগ। শেষ লাইন প্রতিকার দেখায়: সার্ভিস যদি কাঁচা অনুরোধ সরাসরি একই pipeline-কে দেয়, একটি-একটি অনুরোধের উত্তর offline হিসাবের সঙ্গে হুবহু মেলে (পার্থক্য ০.০)।

প্রতিকার তিন স্তরে। (১) এক কোড: feature-এর হিসাব pipeline-এর ভেতরে, আর সার্ভিস শুধু কাঁচা field যাচাই করে pipeline-কে দেয়। (২) পরিবেশন করা feature লিখে রাখা: প্রতিটি অনুরোধে model যে feature পেয়েছিল তা log-এ রাখলে পরে training ডেটার সঙ্গে তুলনা করা যায়, আর পরের model সেই log থেকেই শিখতে পারে। (৩) একই উৎস: “গত ৭ দিনে জেলার গড় দেরি”-র মতো feature batch আর online দুই জায়গাতেই একটি ভাণ্ডার থেকে নেওয়া, যাকে feature store বলে; নকশাটি পর্ব ৭১ (ML system design — একটি পুরো নকশা, শুরু থেকে নজরদারি পর্যন্ত)-এ আসবে। চালু হওয়ার পর skew আর drift কীভাবে নজরে রাখবেন, তা পর্ব ৬৯ (ML system-এর test আর নজরদারি — data test, model test আর drift)-এর বিষয়; drift-এর পরিসংখ্যান পর্ব ৫৯-এ।

৬ · সংরক্ষণ, version আর নিরাপদ load — joblib, skops আর metadata

AI-এর ভিত্তি কোর্সের পর্ব ৬৪-এ দেখেছেন, joblib আসলে pickle-এর ওপর চলে, আর pickle ফাইল খোলার সময় ফাইলের ভেতরের নির্দেশে কোড চলতে পারে। তাই নিয়ম: শুধু নিজের বা বিশ্বস্ত উৎসের ফাইল load করুন। আরেকটি সমস্যা version: ফাইলে শেখা সংখ্যা থাকে, কোড নয়; অন্য scikit-learn version-এ খুললে সেই সংখ্যা অন্য কোডের হাতে পড়ে। তাই model ফাইলের সঙ্গে model metadata রাখা হয়, আর পাঁচটি স্থির ইনপুটের অনুমান, যাকে বলা হয় golden set. নতুন যন্ত্রে load করে golden set-এর উত্তর মিলিয়ে দেখলেই বোঝা যায় model “একই” আছে কি না।

import io, hashlib, platform
import joblib
import numpy as np
import pandas as pd
import sklearn
from bazar_serve import FEATURES, deliveries, build_pipeline

X, y = deliveries()
pipe = build_pipeline().fit(X.iloc[:2400], y[:2400])
golden = X.iloc[2400:2405]                      # ৫টি স্থির "golden" সারি
meta = {"model_version": "delivery-hgb-2026.11.1",
        "sklearn": sklearn.__version__, "python": platform.python_version(),
        "features": FEATURES, "train_rows": 2400,
        "data_hash": hashlib.sha256(pd.util.hash_pandas_object(X.iloc[:2400])
                                    .to_numpy().tobytes()).hexdigest()[:12],
        "golden_pred": pipe.predict(golden).round(4).tolist()}
buf = io.BytesIO()
joblib.dump({"pipe": pipe, "meta": meta}, buf)  # ডিস্কের বদলে memory-তে
print(f"saved {buf.tell() / 1024:.0f} KB;", {k: meta[k] for k in list(meta)[:3]})

def check(art):
    m, problems = art["meta"], []
    if m["sklearn"] != sklearn.__version__:
        problems.append(f"sklearn saved {m['sklearn']}, running {sklearn.__version__}")
    if m["features"] != FEATURES:
        problems.append(f"features differ: {m['features']}")
    diff = np.abs(art["pipe"].predict(golden) - m["golden_pred"]).max()
    problems += [f"golden diff {diff:.1e} h"] if diff > 1e-3 else []
    return problems or ["OK"]

buf.seek(0)
art = joblib.load(buf)                          # শুধু নিজের বিশ্বস্ত ফাইল!
print("reloaded :", check(art))
print("desktop  :", check({**art, "meta": {**meta, "sklearn": "1.9.1"}}))
print("old model:", check({**art, "meta": {**meta, "features": FEATURES[:3]}}))

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

saved 339 KB; {'model_version': 'delivery-hgb-2026.11.1', 'sklearn': '1.8.0', 'python': '3.14.2'}
reloaded : ['OK']
desktop  : ['sklearn saved 1.9.1, running 1.8.0']
old model: ["features differ: ['km', 'district', 'rain']"]

পাতায় pipeline আর metadata মিলে প্রায় ৩৩৯ KB (ডেস্কটপে ৩৬২ KB; version আর প্ল্যাটফর্মভেদে আকার বদলায়)। reloaded: version, feature আর golden set তিনটিই মেলে। desktop: একই ফাইল যদি ডেস্কটপের scikit-learn 1.9.1-এ রাখা হতো আর পাতার 1.8.0-এ খোলা হতো, check তা ধরে। এখানে golden set মিলত কি না আলাদা প্রশ্ন; scikit-learn নিজেও অন্য version-এর ফাইল খুললে InconsistentVersionWarning দেয়, আর নিরাপদ পথ একই version-এ আবার বানানো। old model: যে model তিনটি feature-এ শিখেছিল, তাকে চার feature-এর API-র পেছনে বসালে সার্ভিস চালুর আগেই ধরা পড়ে। এমন check সার্ভিস চালুর সময় একবার চালান; না মিললে সার্ভিস চালু হবে না, ভুল উত্তর দিতে দিতে চলবে না।

কী রাখবেনকেন
model_version, তৈরির তারিখপ্রতিটি উত্তর আর ফলের সারি কোন model-এর, তা খোঁজা; rollback
Python, scikit-learn আর অন্য library-র version (lockfile)একই পরিবেশ আবার বানানো; load-এর আগে মিল যাচাই
feature-এর তালিকা, ধরন আর সীমাAPI-র ইনপুট-যাচাই এখান থেকেই; নতুন model পুরোনো অনুরোধ নিতে পারে কি না
training ডেটার পরিচয় (hash বা snapshot-এর নাম) আর কোডের commitmodel আবার বানানো আর ভুল খোঁজা
test metric আর golden set-এর অনুমানload-এর পরে একই আচরণ কি না, এক লাইনে যাচাই

skops দিয়ে নিরাপদ load. skops-এর ফরম্যাট pickle নয়: ফাইলে object-এর গঠন আর ধরনের নাম লেখা থাকে, আর load-এর সময় চেনা-নিরাপদ তালিকার বাইরের কোনো ধরন থাকলে আপনাকে আগে সেটি দেখে নিজে অনুমতি দিতে হয়। এটি ঝুঁকি কমায়, শূন্য করে না: অনুমতি দেওয়া ধরনগুলোর ওপর বিশ্বাস আপনারই। ডেস্কটপে একই pipeline দিয়ে সত্যিকারের run (লেখার সময়ে Python 3.13.5, scikit-learn 1.9.1, skops 0.16.0):

import os
import joblib, numpy as np, skops
import skops.io as sio
from bazar_serve import deliveries

art = joblib.load("model.joblib")
pipe = art["pipe"]
sio.dump(pipe, "model.skops")
print("skops", skops.__version__, "| file", os.path.getsize("model.skops") // 1024, "KB")
untrusted = sio.get_untrusted_types(file="model.skops")
print("untrusted types:", untrusted)
try:
    sio.load("model.skops")                          # কিছু trust না করে
except Exception as e:
    print(type(e).__name__, ":", str(e)[:90])
loaded = sio.load("model.skops", trusted=untrusted)  # তালিকা নিজে পড়ে দেখার পরে
X, _ = deliveries()
print("max |diff| vs joblib:", np.max(np.abs(loaded.predict(X) - pipe.predict(X))))

সত্যিকারের run (uv run python skops_demo.py):

skops 0.16.0 | file 953 KB
untrusted types: ['numpy.dtype', 'sklearn.ensemble._hist_gradient_boosting.predictor.TreePredictor']
UntrustedTypesFoundException : Untrusted types found in the file: ['numpy.dtype', 'sklearn.ensemble._hist_gradient_boosti
max |diff| vs joblib: 0.0

দুটি ধরন skops-এর নিজের নিরাপদ তালিকায় নেই, তাই কিছু trust না করে load করতে গেলে UntrustedTypesFoundException. তালিকাটি পড়ে (একটি NumPy-র dtype, একটি scikit-learn-এর নিজের গাছের class) অনুমতি দেওয়ার পর load হয়, আর অনুমান joblib-এর সঙ্গে হুবহু মেলে (পার্থক্য ০.০)। দাম: এই run-এ skops ফাইল ৯৫৩ KB, joblib ৩৬২ KB. অচেনা ফাইলে trusted=-এ চোখ বুজে পুরো তালিকা বসিয়ে দিলে নিরাপত্তার লাভটুকু হারায়।

৭ · FastAPI-তে আসল service — /health, /predict আর batch endpoint

এবার online পথ। FastAPI একটি Python web framework: function-এর type hint থেকে ইনপুট-যাচাই আর API-র docs নিজেই বানায়। যাচাইয়ের কাজ করে Pydantic-এর model; অংশ ৩-এর হাতে লেখা validate-এর সব নিয়ম এখানে কয়েকটি ঘোষণায় আসে। FastAPI নিজে বিস্তারিত শিখতে Full-Stack Web Frameworks কোর্সের FastAPI পর্ব দেখুন; এখানে শুধু model-এর দিকটি। আলাদা ফোল্ডারে uv দিয়ে project বানিয়ে সত্যিকারের run করা হয়েছে:

$ uv init --vcs none --author-from none --python 3.13 bazarghar-serve
$ cd bazarghar-serve
$ uv add fastapi uvicorn httpx scikit-learn pandas joblib skops skl2onnx onnxruntime
 …
 + fastapi==0.162.0
 + pydantic==2.14.3
 + scikit-learn==1.9.1
 + skops==0.16.0
 + uvicorn==0.54.0
 …
$ uv run python train.py
{"model_version": "delivery-hgb-2026.11.1", "sklearn": "1.9.1", "python": "3.13.5",
 "features": ["km", "district", "rain", "weight_kg"], "test_mae_h": 1.573}

train.py অংশ ৬-এর মতো pipeline আর metadata একটি model.joblib-এ রাখে (৬০০টি test অর্ডারে গড় ভুল ১.৫৭ ঘণ্টা)। app.py:

from contextlib import asynccontextmanager
from typing import Literal

import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field

from bazar_serve import FEATURES

class Order(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    km: float = Field(ge=1, le=40)
    district: Literal["Dhaka", "Chattogram", "Sylhet", "Rajshahi"]
    rain: bool
    weight_kg: float | None = Field(default=None, gt=0, le=30)

class Estimate(BaseModel):
    hours: float
    model_version: str

class Batch(BaseModel):
    orders: list[Order] = Field(min_length=1, max_length=1000)

ART = {}

@asynccontextmanager
async def lifespan(app):
    ART.update(joblib.load("model.joblib"))    # পুরো pipeline + meta, একবারই
    yield

app = FastAPI(title="Bazarghar delivery ETA", lifespan=lifespan)

def score(orders):
    df = pd.DataFrame([o.model_dump() for o in orders], columns=FEATURES)
    df = df.astype({"rain": int, "weight_kg": float})
    return ART["pipe"].predict(df).round(2).tolist()

@app.get("/health")
def health():
    return {"status": "ok", "model_version": ART["meta"]["model_version"]}

@app.post("/predict", response_model=Estimate)
def predict(order: Order):
    return Estimate(hours=score([order])[0],
                    model_version=ART["meta"]["model_version"])

@app.post("/predict/batch")
def predict_batch(batch: Batch):
    return {"model_version": ART["meta"]["model_version"],
            "hours": score(batch.orders)}

চারটি নকশার সিদ্ধান্ত। (ক) extra="forbid" অচেনা field আটকায়, আর strict=True লেখা হিসেবে আসা সংখ্যা আটকায়; Pydantic-এর default “lax” নিয়মে {"km": "12.5", "rain": "yes"} চুপচাপ km=12.5 rain=True হয়ে যায় (একই venv-এ যাচাই করা)। (খ) Model load হয় lifespan-এ, একবারই, প্রতিটি অনুরোধে নয়। (গ) Endpoint-গুলো সাধারণ def, async def নয়: FastAPI সাধারণ function-কে আলাদা thread pool-এ চালায়, তাই CPU-র কাজ চলার সময়ও event loop অন্য অনুরোধ নিতে পারে। (ঘ) উত্তরে সবসময় model_version. Health check-এর endpoint /health-ও version বলে, যাতে load balancer আর মানুষ দুজনেই জানেন কোন model চলছে।

পরীক্ষায় TestClient network ছাড়াই app-এ অনুরোধ পাঠায়; with ব্লকে খুললে lifespan চলে আর model load হয়। API-র পরীক্ষার সাধারণ কৌশল Full-Stack কোর্সের API integration testing পর্বে।

import fastapi, pydantic, sklearn
from fastapi.testclient import TestClient
from app import app

print("fastapi", fastapi.__version__, "pydantic", pydantic.VERSION, "sklearn", sklearn.__version__)
ok = {"km": 12.5, "district": "Dhaka", "rain": False, "weight_kg": 2.0}
cases = {
    "valid": ok,
    "no weight": {**ok, "weight_kg": None},
    "km as text": {**ok, "km": "12.5"},
    "km=80": {**ok, "km": 80},
    "unknown district": {**ok, "district": "Cox's Bazar"},
    "extra field": {**ok, "coupon": "EID10"},
}
with TestClient(app) as client:                 # with: lifespan চলে, model load হয়
    print("GET /health ->", client.get("/health").json())
    for name, body in cases.items():
        r = client.post("/predict", json=body)
        out = r.json() if r.status_code == 200 else [
            (e["loc"][-1], e["type"]) for e in r.json()["detail"]]
        print(f"{name:<17} {r.status_code} {out}")
    r = client.post("/predict/batch", json={"orders": [ok, {**ok, "rain": True}, {**ok, "km": 30.0}]})
    print("batch of 3        ", r.status_code, r.json())
    r = client.post("/predict/batch", json={"orders": [ok] * 1001})
    print("batch of 1001     ", r.status_code, r.json()["detail"][0]["type"])

সত্যিকারের run (uv run python test_app.py; প্রথম লাইনের পথ ছোট করা হয়েছে):

.venv/…/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
fastapi 0.162.0 pydantic 2.14.3 sklearn 1.9.1
GET /health -> {'status': 'ok', 'model_version': 'delivery-hgb-2026.11.1'}
valid             200 {'hours': 15.23, 'model_version': 'delivery-hgb-2026.11.1'}
no weight         200 {'hours': 15.94, 'model_version': 'delivery-hgb-2026.11.1'}
km as text        422 [('km', 'float_type')]
km=80             422 [('km', 'less_than_equal')]
unknown district  422 [('district', 'literal_error')]
extra field       422 [('coupon', 'extra_forbidden')]
batch of 3         200 {'model_version': 'delivery-hgb-2026.11.1', 'hours': [15.23, 18.55, 24.48]}
batch of 1001      422 too_long

বৈধ দুটি অনুরোধের উত্তর ১৫.২৩ আর ১৫.৯৪ ঘণ্টা; অংশ ৩-এর পাতার ব্লক একই ডেটা আর একই seed-এ দেয় ১৫.১১ আর ১৫.৮৮. পাতায় scikit-learn 1.8.0 আর প্ল্যাটফর্ম আলাদা, তাই দশমিকের পরে হুবহু মিল আশা করবেন না; এ কারণেই metadata-তে version রাখা। খারাপ চারটি অনুরোধ model পর্যন্ত যায় না; FastAPI নিজেই 422 দেয়, সঙ্গে কোন field আর কোন নিয়ম। Batch endpoint তিনটি অর্ডারে একটি ডাকে উত্তর দেয়, আর ১,০০১টিতে too_long: একটি অনুরোধে কত সারি চলবে, তার সীমা না থাকলে একজন ক্লায়েন্টই সার্ভিস আটকে দিতে পারে। প্রথম লাইনের সতর্কবার্তাটিও শিক্ষা: এই version-এ Starlette-এর TestClient httpx-এর বদলে নতুন একটি package চাইছে; আপনার version-এ বার্তাটি না-ও থাকতে পারে, তাই নিজের version-এর docs দেখুন।

শেষে আসল server: Uvicorn দিয়ে 127.0.0.1-এর একটি অপ্রচলিত port-এ চালু করে, একটি script ৫০০টি অনুরোধ একটি একটি করে আর ১,০০০ অর্ডারের ২০টি batch অনুরোধ পাঠিয়ে সময় মেপেছে, তারপর নিজের চালানো process-টিই তার PID ধরে বন্ধ করেছে।

proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
                         "--host", "127.0.0.1", "--port", "18768"])
…   # /health সাড়া দেওয়া পর্যন্ত অপেক্ষা, ২০টি warm-up, তারপর মাপ
proc.terminate()      # শুধু নিজের process, Popen-এর PID ধরে

সত্যিকারের run (uv run python serve_bench.py; Windows ডেস্কটপ, একটি worker; সংখ্যাগুলো একটি স্থানীয় run, সাধারণ benchmark নয়):

health: {'status': 'ok', 'model_version': 'delivery-hgb-2026.11.1'}
/predict x500 one by one: p50=7.3 ms p95=9.3 ms p99=9.6 ms, 140 orders/s
/predict/batch (1000 orders) x20: p50=12.1 ms, 80030 orders/s, first hours=[8.78, 9.15, 9.4]
server stopped, exit code 1

একটি অর্ডারের অনুরোধে p50 ৭.৩ ms, অর্থাৎ HTTP, JSON আর যাচাই মিলে model-এর নিজের সময়ের (প্রায় ৩ ms, অংশ ৮) দ্বিগুণের বেশি। একজন ক্লায়েন্ট একটার পর একটা পাঠালে সেকেন্ডে ১৪০টি। ১,০০০ অর্ডারের batch অনুরোধ ১২.১ ms-এ শেষ, সেকেন্ডে প্রায় ৮০,০০০ অর্ডার: অংশ ৪-এর শিক্ষা HTTP-র ওপরেও খাটে। উইন্ডোজে terminate() process-কে সরাসরি থামায়, তাই exit code ১; Linux-এ একই ডাক SIGTERM পাঠায় আর Uvicorn গুছিয়ে বন্ধ হয়। আসল deploy-এ একাধিক worker, container আর load balancer লাগে; container-এর নিয়ম Cloud & DevOps কোর্সের Dockerfile পর্বে (এই পর্বে container চালানো হয়নি)।

৮ · ONNX — Python ছাড়াও চলে এমন model ফাইল

ONNX (Open Neural Network Exchange) model-এর একটি খোলা ফরম্যাট: model-কে একটি গণনার graph হিসেবে লেখা হয় (OneHot, Imputer, TreeEnsemble-এর মতো নোড), যা যেকোনো ONNX runtime চালাতে পারে। ONNX Runtime এমন একটি C++ engine, যা Python, C#, Java বা JavaScript থেকে ডাকা যায়। লাভ তিনটি: সার্ভিসে scikit-learn আর তার version-জট লাগে না, ছোট ইনপুটে ডাকার খরচ কম, আর ফাইলটি pickle নয়। scikit-learn-এর pipeline রূপান্তর করে skl2onnx; প্রতিটি কাঁচা column-এর জন্য একটি ইনপুট ঘোষণা করতে হয়।

import time
import numpy as np, onnxruntime as ort, skl2onnx, sklearn
from skl2onnx import to_onnx
from skl2onnx.common.data_types import FloatTensorType, StringTensorType
from bazar_serve import deliveries, build_pipeline

X, y = deliveries()
pipe = build_pipeline().fit(X.iloc[:2400], y[:2400])
types = [("km", FloatTensorType([None, 1])), ("district", StringTensorType([None, 1])),
         ("rain", FloatTensorType([None, 1])), ("weight_kg", FloatTensorType([None, 1]))]
onx = to_onnx(pipe, initial_types=types, target_opset=18)
open("model.onnx", "wb").write(onx.SerializeToString())
sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
print("sklearn", sklearn.__version__, "| skl2onnx", skl2onnx.__version__, "| onnxruntime", ort.__version__)
print("inputs:", [(i.name, i.type) for i in sess.get_inputs()])

big, _ = deliveries(n=20000, seed=7)
def feeds(df):
    return {c: (df[[c]].to_numpy(dtype=np.float32) if c != "district"
                else df[[c]].to_numpy(dtype=object)) for c in df.columns}
p_sk = pipe.predict(big)
p_ox = sess.run(None, feeds(big))[0].ravel()
d = np.abs(p_sk - p_ox)
print(f"max |diff| = {d.max():.2e} h, mean = {d.mean():.2e} h, rows > 0.01 h: {(d > 0.01).sum()}")

def p50(fn, n):
    t = []
    for _ in range(n):
        t0 = time.perf_counter(); fn(); t.append((time.perf_counter() - t0) * 1000)
    return np.percentile(t, 50)
one = big.iloc[[0]]; f1 = feeds(one); fa = feeds(big)
print(f"1 row   p50: sklearn {p50(lambda: pipe.predict(one), 300):.3f} ms | onnxruntime {p50(lambda: sess.run(None, f1), 300):.3f} ms")
print(f"20,000  p50: sklearn {p50(lambda: pipe.predict(big), 10):.1f} ms | onnxruntime {p50(lambda: sess.run(None, fa), 10):.1f} ms")

সত্যিকারের run (ডেস্কটপ, একই venv). প্রথম চেষ্টা ব্যর্থ হয়েছিল, আর সেটিও শেখার মতো:

$ uv run python onnx_demo.py
…
TypeError: Field onnx.AttributeProto.ints: Expected an int, got a boolean.
ValueError: Unable to create node 'TreeEnsembleRegressor' with name='TreeEnsembleRegressor' and attributes={…}
$ uv add "protobuf<7"
 - protobuf==7.36.2
 + protobuf==6.33.6
$ uv run python onnx_demo.py
sklearn 1.9.1 | skl2onnx 1.20.0 | onnxruntime 1.31.1
inputs: [('km', 'tensor(float)'), ('district', 'tensor(string)'), ('rain', 'tensor(float)'), ('weight_kg', 'tensor(float)')]
max |diff| = 9.62e-06 h, mean = 1.08e-06 h, rows > 0.01 h: 0
1 row   p50: sklearn 2.973 ms | onnxruntime 0.021 ms
20,000  p50: sklearn 21.8 ms | onnxruntime 11.3 ms

ব্যর্থতা: লেখার সময়ের সবচেয়ে নতুন package-গুলো একসঙ্গে বসালে (protobuf 7.36.2) রূপান্তরের মাঝপথে গাছের একটি bool-তালিকা গৃহীত হয়নি। আলাদা একটি venv-এ scikit-learn 1.8.0 দিয়েও একই error এসেছিল, আর protobuf 7-এর নিচে নামালে দুটিতেই ঠিক হয়েছে। এটি এই সময়ের একটি নির্দিষ্ট অমিল, পরের release-এ হয়তো ঠিক হয়ে যাবে; শিক্ষাটি স্থায়ী: export-এর পরিবেশও lockfile-এ বাঁধুন। সমতা: ২০,০০০ অর্ডারে সর্বোচ্চ পার্থক্য প্রায় ১০−৫ ঘণ্টা (এক সেকেন্ডেরও কম ভগ্নাংশ), কারণ ONNX-এ ইনপুট আর গাছের মান float32, scikit-learn-এ float64; ০.০১ ঘণ্টার বেশি পার্থক্য একটিতেও নেই। গতি: এক সারিতে onnxruntime ০.০২১ ms বনাম ২.৯৭ ms, মোটামুটি ১০০ গুণের বেশি, কারণ pipeline-এর Python-স্তরের খরচ (DataFrame, ColumnTransformer) নেই। ২০,০০০ সারিতে ফারাক মাত্র দ্বিগুণ: বড় batch-এ দুজনেই আসল গণনায় সময় দেয়।

সীমাও আছে। নিজের লেখা transformer বা সব estimator-এর converter থাকে না; থাকলেও পুরো pipeline export হবে কি না, চালিয়ে দেখতে হয়। float32-এর কারণে threshold-এর একেবারে ধারে থাকা সারির শ্রেণি বদলে যেতে পারে, তাই classifier-এ সমতা যাচাই আরও জরুরি। আর সার্ভিসের ইনপুট-যাচাই ONNX করে না; সেটি আগের মতোই API-র কাজ।

৯ · নতুন model চালু করা — backward compatibility, shadow আর canary

Model নিয়মিত বদলায়, কিন্তু চেকআউট অ্যাপ প্রতি সপ্তাহে বদলায় না। তাই backward compatibility: নতুন model পুরোনো অনুরোধ নিতে পারবে। নিয়মগুলো সহজ: নতুন feature এলে API-তে ঐচ্ছিক field হিসেবে আসবে আর না এলে pipeline-এর imputer সামলাবে; পুরোনো field সরানো বা অর্থ বদলানো (km থেকে মিটার) চলবে না; উত্তরের গঠন একই থাকবে, শুধু model_version বদলাবে। গঠন বদলাতেই হলে নতুন পথ (/v2/predict) আর পুরোনোটি কিছুদিন পাশাপাশি; API version-এর নিয়ম Full-Stack কোর্সের API versioning পর্বে।

তারপর চালু করার ধাপ। Shadow deployment-এ নতুন model আসল অনুরোধের একটি copy পায়, অনুমান লিখে রাখে, কিন্তু ক্রেতা শুধু পুরোনো model-এর উত্তর দেখেন; ঝুঁকি প্রায় শূন্য, তবে ব্যবসার ফল মাপা যায় না। Canary release-এ অল্প অংশ অনুরোধ সত্যিই নতুন model-এর উত্তর পায়, আর guardrail ভাঙলে rollback. সাধারণ নকশাগুলো (rolling, blue-green, canary) Cloud & DevOps কোর্সের deployment strategy পর্বে আছে। নিচের stepper বাজারঘরের ডেলিভারি model v2 চালুর একটি উদাহরণ; সংখ্যাগুলো ব্যাখ্যার জন্য বানানো, কোনো run-এর ফল নয়।

v1 চলছে · v2 নতুনguardrail: p95 ≤ 120 ms, error ≤ 0.1%

            
  1. Shadow. নতুন model এক সপ্তাহ পাশাপাশি চলে। ডেলিভারি শেষ হলে আসল সময় আসে, তখন দুই model-এর গড় ভুল তুলনা করা যায়: v2 ভালো, latency সামান্য বেশি, error নেই।
  2. Canary ১%. ক্রেতার id-এর hash দিয়ে ভাগ, যাতে একজন ক্রেতা একবার ৩০ মিনিট, একবার ৫০ মিনিট না দেখেন। Guardrail ঠিক, আর গড় অনুমানের বদলও প্রত্যাশিত দিকে।
  3. চাপে সমস্যা। ২৫%-এ অনুরোধ বেশি, নতুন feature আনার lookup ধীর হলো, p95 সীমা পেরোল। Guardrail-ভিত্তিক স্বয়ংক্রিয় rollback সবাইকে v1-এ ফেরাল। এটি সম্ভব হলো কারণ v1-এর artifact মুছে ফেলা হয়নি।
  4. ঠিক করে আবার। কারণ ঠিক করে load test, তারপর একই সিঁড়ি নতুন করে। Canary-র কাজই এমন সমস্যা ২৫% ক্রেতার মধ্যে আটকে রাখা, ১০০%-এ ছড়াতে না দেওয়া।
  5. পুরো চালু। ধাপে ধাপে ১০০%. পুরোনো model কিছুদিন প্রস্তুত থাকে। Canary দেখায় নতুন model ভাঙছে কি না; ব্যবসার ফল (চেকআউট বাতিল কমল কি না) মাপার জন্য পরিকল্পিত A/B test লাগে, যা পর্ব ৬২ আর পর্ব ৬৩-এর বিষয়।

Kubernetes-এ rolling update কীভাবে হয়, তা Cloud & DevOps কোর্সের Deployment পর্বে দেখুন (এই পর্বে Kubernetes বা Docker চালানো হয়নি)। Model registry, স্বয়ংক্রিয় retraining আর পুরো MLOps pipeline পরে আলাদা একটি MLOps কোর্সে আসবে।

আরও গভীরে: বড় সার্ভিসে একটি অনুরোধ প্রায়ই অনেক ছোট অনুরোধে ভাগ হয় (fan-out); প্রতিটির ১% ধীর হলে ১০০টির মধ্যে অন্তত একটি ধীর হওয়ার সম্ভাবনা ১ − ০.৯৯১০০ ≈ ৬৩%, তাই tail latency গড়ের চেয়ে বেশি গুরুত্বপূর্ণ (Dean ও Barroso, “The Tail at Scale”, ২০১৩)। Online সার্ভিসে কয়েকটি অনুরোধ এক সেকেন্ডের ভগ্নাংশ জমিয়ে একসঙ্গে model-এ দেওয়ার কৌশলকে dynamic batching বলে; এতে throughput বাড়ে, latency সামান্য বাড়ে। Pickle-এর বিকল্প হিসেবে skops scikit-learn-এর জগতে, আর গভীর শিখনের ওজনের জন্য safetensors-এর মতো ফরম্যাট জনপ্রিয় হয়েছে; দুটিরই মূল ভাবনা, ফাইলে কোড নয়, শুধু ডেটা।

পরীক্ষা ও ইন্টারভিউতে: (১) Batch, online না streaming? ইনপুট আগে জানা আর কেউ অপেক্ষায় নেই হলে batch; মুহূর্তে জানা আর কেউ অপেক্ষায় হলে online; ঘটনার স্রোতে দ্রুত সিদ্ধান্ত হলে streaming. (২) ১০টি মাপ ১১, ১২, ১২, ১৩, ১৪, ১৫, ১৬, ১৮, ২৫, ৯০ ms: nearest-rank p50 আর p95? ১৪ আর ৯০ ms. (৩) ২০০ অনুরোধ/সেকেন্ড, গড় ৫০ ms: একসঙ্গে কতগুলো? Little’s Law-এ ১০. (৪) Training-serving skew কী, ঠেকাবেন কীভাবে? একই feature দুই জায়গায় দুইভাবে হিসাব; এক pipeline, পরিবেশন করা feature-এর log. (৫) Shadow আর canary-র পার্থক্য? Shadow-এ নতুন model-এর উত্তর কেউ দেখেন না; canary-তে অল্প অংশ সত্যিই দেখেন।

১০ · AI-এর যুগে — serving কোড যাচাই

AI assistant-কে “আমার model-এর জন্য একটি FastAPI app লেখো” বললে চলনসই কোড আসে, যা প্রথম পরীক্ষায় কাজও করে। ভুলগুলো চুপচাপ থাকে আর ধরা পড়ে চাপ বা অদ্ভুত ইনপুট এলে।

AI প্রায়ই যা করেকেন সমস্যাকীভাবে ধরবেন
পুরোনো @app.on_event("startup") বা Pydantic 1-এর .dict(), class Configনতুন version-এ অবচিত বা সতর্কবার্তানিজের version-এর docs; lifespan, model_dump, model_config
শুধু model ফাইল রাখে, preprocessing সার্ভিসে আবার লেখেTraining-serving skewপুরো pipeline একটি artifact; golden set মেলান
ইনপুটের সীমা বা অচেনা মানের পাহারা নেই৮০ km-এও উত্তর, অচেনা জেলায় ৫০০ errorঅদ্ভুত ইনপুট দিয়ে TestClient-এ পরীক্ষা
ব্যবহারকারীর আপলোড করা ফাইল joblib.load করেখোলার সময় অচেনা কোড চলতে পারেশুধু নিজের artifact; skops বা ONNX
প্রতিটি অনুরোধে model load বা আবার fitধীর, আর প্রতিবার আলাদা modelStartup-এ একবার load; latency মাপুন
“গড় latency ৮ ms” দিয়ে মান বোঝায়, একটি মাপে benchmarkTail লুকায়, warm-up-এর আগের মাপ মেশেWarm-up-এর পর শতশত মাপ, p95/p99
উত্তরে model version নেই, কোনো log নেইকোন model কোন ভুল করল, খোঁজা যায় নাউত্তর আর log-এ model_version

AI-কে কোডের সঙ্গে নকশার যুক্তি আর পরীক্ষাও চাইতে বলুন:

আমার কাছে scikit-learn-এর একটি fit করা Pipeline আছে (ColumnTransformer + HistGradientBoostingRegressor), joblib ফাইলে, সঙ্গে metadata: model_version, sklearn version, feature-এর তালিকা। ১. কোন FastAPI, Pydantic আর scikit-learn version ধরে লিখছ, প্রথমে বলো। ২. Pydantic model-এ প্রতিটি field-এর ধরন আর সীমা দাও (km 1–40, district চারটি নামের একটি, rain bool, weight_kg ঐচ্ছিক), অচেনা field নিষেধ, strict mode. ৩. lifespan-এ model একবার load করো; load-এর পর golden set দিয়ে অনুমান মিলিয়ে নাও, না মিললে সার্ভিস চালু হবে না। ৪. /health, /predict আর সর্বোচ্চ ১০০০ সারির /predict/batch দাও; প্রতিটি উত্তরে model_version. ৫. TestClient দিয়ে পরীক্ষা লেখো: বৈধ অনুরোধ, সীমার বাইরের মান, অচেনা জেলা, অচেনা field, বড় batch. ৬. preprocessing আলাদা করে আবার লিখবে না; পুরো pipeline-ই ব্যবহার করবে।

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

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

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

প্র ০১ML-এর অভিজ্ঞ দলগুলো কেন বারবার বলে যে model-এর কোড পুরো সিস্টেমের ছোট একটি অংশ, আর training-serving skew কেন এত পুরোনো অথচ এখনো চেনা সমস্যা?

২০১৫ সালে Google-এর একদল গবেষক NIPS (এখন NeurIPS) সম্মেলনে “Hidden Technical Debt in Machine Learning Systems” নামে একটি প্রবন্ধ প্রকাশ করেন (প্রথম লেখক D. Sculley). এর বিখ্যাত ছবিতে মাঝখানে ছোট্ট একটি বাক্স “ML code”, আর তার চারপাশে অনেক বড় বড় বাক্স: ডেটা সংগ্রহ, যাচাই, feature বানানো, serving-এর কাঠামো, নজরদারি। প্রবন্ধটি দেখায়, এই আশপাশের কোডে “glue code” আর জট পাকানো pipeline জমে, যা সাধারণ software-এর চেয়েও দ্রুত ঋণ বাড়ায়, কারণ ML-এর আচরণ কোডের পাশাপাশি ডেটার ওপরও নির্ভর করে।

কাছাকাছি সময়ে Google-এর Martin Zinkevich-এর প্রকাশ্য “Rules of Machine Learning” নামের নির্দেশিকায় training-serving skew-এর জন্য আলাদা অংশ রাখা হয়। সেখানকার পরামর্শের সারকথা: সার্ভিসের সময় যে feature ব্যবহার হলো তা log-এ রাখুন আর পরের training সেই log থেকে করুন, আর training ও serving-এর মধ্যে যতটা সম্ভব একই কোড ব্যবহার করুন। এই পর্বের ব্লকে ঠিক এটিই দেখেছেন: সার্ভিসের দলের তিনটি “ছোট” অমিলে গড় ভুল প্রায় দ্বিগুণ, আর ওজন না থাকা অর্ডারে তার চেয়েও বেশি।

সমস্যাটি টিকে আছে কারণ দুই জগতের চাহিদা আলাদা। Training-এ ধীর কিন্তু নমনীয় pandas বা SQL চলে, serving-এ দ্রুত আর কম dependency-র কোড চাই, অনেক সময় অন্য ভাষায়। আরেকটি কারণ সময়: training-এ “গত ৭ দিন” হিসাব হয় পুরো ইতিহাস হাতে রেখে, অথচ সার্ভিসে সেই মুহূর্তে যা আছে তা দিয়ে; ডেটা দেরিতে এলে দুটি আলাদা হয়ে যায়। Feature store, একই pipeline আর feature-এর log মূলত এই একটি রোগেরই তিন রকম ওষুধ।

মূল উপলব্ধি: Model-এর নির্ভুলতা notebook-এ মাপা হয়, কিন্তু টেকে সার্ভিসের feature notebook-এর feature-এর সঙ্গে হুবহু মিললে তবেই।

প্র ০২Pickle-ভিত্তিক model ফাইল এত সুবিধাজনক হলেও কেন skops, safetensors বা ONNX-এর মতো ফরম্যাটের দিকে ঝোঁক বাড়ছে? এর দাম কী?

Python-এর pickle যেকোনো object প্রায় হুবহু ফিরিয়ে আনতে পারে, কারণ ফাইলের ভেতরে লেখা থাকে কোন function কোন argument দিয়ে ডেকে object বানাতে হবে। এই ক্ষমতাই বিপদ: খারাপ উদ্দেশ্যে বানানো ফাইল খোলার মুহূর্তেই কোড চালাতে পারে। Python-এর অফিসিয়াল docs বহু বছর ধরে সতর্ক করছে যে শুধু বিশ্বস্ত ডেটা unpickle করা উচিত। যতদিন model ফাইল একটি দলের ভেতরে থাকত, ঝুঁকি সীমিত ছিল। কিন্তু এখন model hub থেকে অন্যের ফাইল নামানো রোজকার কাজ, আর কয়েকটি নিরাপত্তা প্রতিষ্ঠান ২০২৩–২০২৪ সালে জানিয়েছিল যে জনপ্রিয় hub-এ ক্ষতিকর কোডসহ pickle-ভিত্তিক ফাইল পাওয়া গেছে।

সাড়া এসেছে কয়েক দিক থেকে। scikit-learn-এর জগতে skops (২০২২ সালের দিকে শুরু হওয়া একটি open-source প্রকল্প) object-এর গঠন আলাদা করে লেখে আর অচেনা ধরন load-এর আগে আপনার অনুমতি চায়। গভীর শিখনের ওজনের জন্য safetensors শুধু সংখ্যার array রাখে। ONNX model-কে গণনার graph হিসেবে রাখে, Python object হিসেবে নয়।

দাম আছে। এই পর্বের run-এ skops ফাইল joblib-এর প্রায় আড়াই গুণ বড় (৯৫৩ বনাম ৩৬২ KB). নিজের লেখা class থাকলে skops-এ সেটিকে trust করতে হয়, তখন নিরাপত্তা আবার আপনার বিচারের ওপর। ONNX-এ সব estimator-এর converter নেই, আর float32-এর ছোট পার্থক্য মেনে নিতে হয়। তাই বাস্তব নিয়ম মিশ্র: নিজের দলের ভেতরে, নিজের CI-তে বানানো ফাইল joblib-এ রাখা চলে, যদি ফাইলটি কোথা থেকে এল তা নিশ্চিত থাকে; বাইরে থেকে আসা বা বাইরে দেওয়া ফাইলে নিরাপদ ফরম্যাট।

মূল উপলব্ধি: Model ফাইল আসলে একটি প্রোগ্রামের মতো আচরণ করতে পারে; তার উৎস আর ফরম্যাট বাছাই নিরাপত্তার সিদ্ধান্ত, শুধু সুবিধার নয়।

প্র ০৩ONNX কেন তৈরি হয়েছিল, আর একটি scikit-learn pipeline ONNX-এ নেওয়া কখন লাভজনক, কখন অপ্রয়োজনীয় জটিলতা?

২০১৭ সালের সেপ্টেম্বরে Microsoft আর Facebook (এখন Meta) ONNX ঘোষণা করে। তখন গভীর শিখনের framework অনেক, আর একটিতে শেখানো model অন্যটিতে বা মোবাইলে চালানো কঠিন ছিল। লক্ষ্য ছিল একটি সাধারণ ফরম্যাট, যাতে শেখানো যায় এক জায়গায়, চালানো যায় অন্য জায়গায়। পরে আরও প্রতিষ্ঠান যোগ দেয়, আর প্রকল্পটি Linux Foundation-এর AI-বিষয়ক শাখার অধীনে যায় (২০১৯ সালের দিকে)। ONNX Runtime-এর মতো engine সেই ফরম্যাট দ্রুত চালানোর কাজ করে, আর skl2onnx-এর মতো converter প্রচলিত ML-এর model-কেও সেখানে আনে।

লাভজনক যখন: সার্ভিস Python-এ নয় (Java বা C#-এর backend, মোবাইল বা ব্রাউজার); একটি একটি অনুরোধে latency কম চাই (এই পর্বের run-এ এক সারিতে ১০০ গুণের বেশি দ্রুত); বা সার্ভিসের container ছোট রাখতে scikit-learn বাদ দিতে চান। ফাইলটি pickle নয়, সেটিও সুবিধা।

অপ্রয়োজনীয় যখন: সার্ভিস এমনিতেই Python-এ, latency budget ঢিলেঢালা, আর কাজটি মূলত রাতের batch; সেখানে ২০,০০০ সারিতে লাভ মাত্র দ্বিগুণ, অথচ যোগ হয় আরেকটি রূপান্তর-ধাপ, যার নিজস্ব version-জট আছে (এই পর্বের run-এ protobuf-এর অমিলে export ভেঙেছিল)। নিজের লেখা transformer থাকলে converter-ও নিজে লিখতে হয়। আর রূপান্তরের পর প্রতিবার সমতা-পরীক্ষা লাগে, কারণ float32 আর float64-এর পার্থক্য সিদ্ধান্তের সীমায় থাকা সারির ফল বদলাতে পারে।

মূল উপলব্ধি: ONNX বহনযোগ্যতা আর ছোট-ইনপুটের গতি কেনে, দাম দেয় একটি বাড়তি রূপান্তর আর তার পরীক্ষায়; দরকার মাপুন, তারপর বাছুন।

প্র ০৪Service-এর মান বোঝাতে গড় latency কেন বিভ্রান্তিকর, আর “tail” কেন বড় সিস্টেমে আরও গুরুত্বপূর্ণ হয়ে ওঠে?

Latency-র বণ্টন সাধারণত ডান দিকে লম্বা লেজওয়ালা: বেশিরভাগ অনুরোধ দ্রুত, কয়েকটি অনেক ধীর (garbage collection, cache miss, অন্য কাজের ভিড়, network-এর পুনঃপ্রেরণ)। গড় এই কয়েকটির টানে বাড়ে, অথচ বেশিরভাগ ব্যবহারকারীর অভিজ্ঞতা বলে না; আবার ধীর ব্যবহারকারীর দুর্ভোগও আলাদা করে দেখায় না। এই পর্বের stepper-এ দশটির মধ্যে নয়টি ২৫ ms-এর নিচে, অথচ গড় ২২.৬, আর p95 ৯০।

২০১৩ সালে Jeffrey Dean আর Luiz André Barroso Communications of the ACM-এ “The Tail at Scale” প্রবন্ধে দেখান, বড় সিস্টেমে একটি অনুরোধ প্রায়ই অনেক server-এ ভাগ হয়, আর উত্তর দিতে সবার ফেরার অপেক্ষা করতে হয়। প্রতিটি server-এ মাত্র ১% অনুরোধ ধীর হলেও ১০০টি server-এ ভাগ হওয়া অনুরোধের প্রায় ৬৩% অন্তত একটি ধীর অংশের অপেক্ষায় পড়ে (১ − ০.৯৯১০০)। তাঁরা hedged request (দেরি হলে একই অনুরোধ আরেকটি server-এ পাঠানো) আর এমন কৌশলের কথা বলেন, যা লেজ ছাঁটে।

ML serving-এ এর সরাসরি প্রয়োগ আছে। একটি ETA অনুরোধ যদি তিনটি feature-ভাণ্ডার আর দুটি model ডাকে, প্রতিটির লেজ যোগ হয়। Capacity-র হিসাবে Little’s Law (John Little ১৯৬১ সালে যার একটি সাধারণ প্রমাণ প্রকাশ করেন) বলে, latency বাড়লে একই চাপে একসঙ্গে চলমান অনুরোধ বাড়ে, ফলে queue লম্বা হয়ে latency আরও বাড়ে। তাই budget লেখা হয় p95 বা p99-এ, মাপা হয় warm-up-এর পর শতশত অনুরোধে, আর লেজের কারণ আলাদা করে খোঁজা হয়।

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

প্র ০৫“Canary” নামটি কোথা থেকে এল, আর একটি ভুল deploy-এর ইতিহাস থেকে model চালু করার নিয়মে কী শেখা যায়?

খনিতে বিষাক্ত গ্যাসের আগাম সংকেত পেতে একসময় খনি-শ্রমিকেরা খাঁচায় ক্যানারি পাখি নিয়ে নামতেন; ছোট পাখি মানুষের আগে অসুস্থ হতো। যুক্তরাজ্যে এই প্রথা ১৯৮০-র দশকের মাঝামাঝি পর্যন্ত চলেছিল বলে জানা যায়। Software-এ canary মানে ঠিক এটিই: অল্প অংশে নতুন সংস্করণ, যাতে বিপদ ছড়ানোর আগে ধরা পড়ে।

একটি বহুল-উদ্ধৃত সতর্ক-গল্প ২০১২ সালের আগস্টের, যুক্তরাষ্ট্রের শেয়ারবাজারের ট্রেডিং প্রতিষ্ঠান Knight Capital-এর। পরে প্রকাশিত মার্কিন নিয়ন্ত্রক সংস্থা SEC-এর ২০১৩ সালের প্রতিবেদন অনুযায়ী, নতুন কোড আটটি server-এর একটিতে পৌঁছায়নি, আর নতুন কাজের জন্য আবার ব্যবহার করা একটি পুরোনো flag সেই server-এ বহুদিনের অব্যবহৃত পুরোনো কোড জাগিয়ে তোলে। প্রায় ৪৫ মিনিটে প্রতিষ্ঠানটির ক্ষতি ৪৪ থেকে ৪৬ কোটি ডলারের মতো (সূত্রভেদে সংখ্যা একটু আলাদা)। এখানে তিনটি শিক্ষা model serving-এও খাটে। এক, পুরোনো field বা flag-এর অর্থ বদলানো ভয়ংকর; এই পর্বের নিয়ম “km-কে মিটার বানাবেন না” এর ছোট রূপ। দুই, আংশিক deploy-এ কোন server-এ কোন version, তা দেখার উপায় চাই; তাই প্রতিটি উত্তরে model_version. তিন, guardrail ভাঙলে দ্রুত আর স্বয়ংক্রিয় rollback চাই, আর তার জন্য আগের artifact প্রস্তুত রাখতে হয়।

Model-এ একটি বাড়তি জটিলতা আছে: ভুল model প্রায়ই error দেয় না, শুধু খারাপ অনুমান দেয়। আর আসল ফল (ডেলিভারির সময়) আসে দেরিতে। তাই canary-র guardrail-এ latency আর error-এর পাশে অনুমানের বণ্টনও থাকে, আর shadow পর্যায়ে দেরিতে আসা আসল ফল দিয়ে ভুল মাপা হয়।

মূল উপলব্ধি: নতুন model-কে ধাপে ধাপে, মাপা guardrail আর প্রস্তুত rollback-সহ চালু করুন; সবচেয়ে বড় ক্ষতি আসে নীরবে ছড়ানো ভুল থেকে।

অনুশীলন

হিসাবগুলো খাতায় করুন, তারপর উত্তর মেলান। চাইলে পাতার ব্লকগুলোতে সংখ্যা বদলে যাচাই করুন।

  1. পূর্ণ numerical: একটি ETA সার্ভিসের একটি worker একবারে একটি অনুরোধ নেয়, গড়ে ১০ ms-এ। (ক) একটি worker-এর সর্বোচ্চ throughput কত? (খ) সর্বোচ্চ চাপ ৪৫০ অনুরোধ/সেকেন্ড, আর প্রতিটি worker-কে ৭০%-এর বেশি ব্যস্ত রাখা চলবে না; কয়টি worker লাগবে? (গ) Little’s Law-এ একসঙ্গে গড়ে কতগুলো অনুরোধ চলে? (ঘ) রাতে ২০ লাখ সারি স্কোর করতে হবে; chunk-এ প্রতি সেকেন্ডে ৪,০০,০০০ সারি, আর একটি-একটি ডাকে ৩০০ সারি। দুই পথে সময় কত?

    (ক) ১ ÷ ০.০১০ s = ১০০ অনুরোধ/সেকেন্ড। (খ) ৭০% ব্যস্ততায় একটি worker ৭০টি নেয়; ৪৫০ ÷ ৭০ ≈ ৬.৪৩, তাই ৭টি worker (ওপরের দিকে গোল করুন)। (গ) L = λ × W = ৪৫০ × ০.০১০ = ৪.৫টি অনুরোধ একসঙ্গে, গড়ে। (ঘ) Chunk-এ ২০,০০,০০০ ÷ ৪,০০,০০০ = ৫ সেকেন্ড; একটি-একটি ডাকে ২০,০০,০০০ ÷ ৩০০ ≈ ৬,৬৬৭ সেকেন্ড ≈ ১ ঘণ্টা ৫১ মিনিট। একই model, একই ডেটা, প্রায় ১,৩০০ গুণ পার্থক্য।

  2. সমালোচনা ও নকশা: একজন সহকর্মী নিচের সার্ভিস লিখেছেন। অন্তত পাঁচটি সমস্যা খুঁজুন আর প্রতিটির প্রতিকার লিখুন।
    from fastapi import FastAPI, UploadFile
    import joblib, pandas as pd
    app = FastAPI()
    
    @app.post("/predict")
    def predict(data: dict):
        model = joblib.load("model.joblib")
        df = pd.DataFrame([data])
        df["km"] = df["km"].round()
        df["weight_kg"] = df["weight_kg"].fillna(0)
        return {"hours": float(model.predict(df)[0])}
    
    @app.post("/upload-model")
    def upload(f: UploadFile):
        open("model.joblib", "wb").write(f.file.read())

    (১) data: dict: কোনো ধরন, সীমা বা চেনা মানের যাচাই নেই; Pydantic model-এ field আর সীমা, extra="forbid". (২) প্রতিটি অনুরোধে joblib.load: ধীর; lifespan-এ একবার। (৩) সার্ভিসে নিজের feature-কোড (গোল করা, ০ বসানো): training-serving skew; কাঁচা field সরাসরি সেই pipeline-কে। (৪) Upload endpoint যে কাউকে model ফাইল বদলাতে দেয়, আর পরের joblib.load সেই অচেনা ফাইল খুলে কোড চালাতে পারে: endpoint সরান; model আসবে নিজের CI থেকে, skops বা ONNX-এ, checksum মিলিয়ে। (৫) উত্তরে model_version নেই, /health নেই। (৬) Batch endpoint নেই, তাই রাতের কাজ একটি-একটি ডাকে হবে। (৭) Load-এর পর golden set বা metadata-র কোনো যাচাই নেই।

  3. আউটপুট অনুমান করুন: চালানোর আগে লিখুন কী ছাপবে।
    import math
    lat = [30, 12, 14, 11, 13, 200, 15, 12, 16, 14]
    s = sorted(lat)
    def pct(q): return s[math.ceil(q / 100 * len(s)) - 1]
    print(pct(50), pct(90), pct(99), sum(lat) / len(lat))
    14 30 200 33.7

    সাজানো তালিকা ১১, ১২, ১২, ১৩, ১৪, ১৪, ১৫, ১৬, ৩০, ২০০. p50-এর rank ⌈০.৫ × ১০⌉ = ৫, মান ১৪; p90-এর rank ৯, মান ৩০; p99-এর rank ⌈৯.৯⌉ = ১০, মান ২০০. যোগ ৩৩৭, গড় ৩৩.৭: মাঝের অনুরোধের (১৪) দ্বিগুণের বেশি, শুধু একটি ২০০-র জন্য। sum/len Python 3-এ float দেয়, তাই 33.7.

  4. কোড লিখুন: একটি function score_in_chunks(pipe, df, version, chunk=5000) লিখুন, যা টেবিলকে chunk-এ স্কোর করে hours আর model_version column-সহ একটি DataFrame ফেরত দেয়, মূল index রেখে, আর সারির সংখ্যা ও NaN যাচাই করে। পাতার bazar_serve module দিয়ে পরীক্ষা করুন যে ফল একবারে পুরো টেবিলের predict-এর সমান।
    import numpy as np, pandas as pd, sklearn
    from bazar_serve import deliveries, build_pipeline
    
    def score_in_chunks(pipe, df, version, chunk=5000):
        parts = []
        for s in range(0, len(df), chunk):
            part = df.iloc[s:s + chunk]
            parts.append(pd.DataFrame({"hours": pipe.predict(part)}, index=part.index))
        out = pd.concat(parts)
        out["model_version"] = version
        assert len(out) == len(df) and out["hours"].notna().all()
        return out
    
    X, y = deliveries()
    pipe = build_pipeline().fit(X, y)
    night, _ = deliveries(n=20000, seed=7)
    res = score_in_chunks(pipe, night, "delivery-hgb-2026.11.1", chunk=3000)
    print(res.shape, np.allclose(res["hours"], pipe.predict(night)))
    # (20000, 2) True

    শেষ chunk ছোট (২০,০০০ ÷ ৩,০০০-এর ভাগশেষ ২,০০০), তবু iloc নিজেই সীমা সামলায়। মূল index রাখায় ফলকে অর্ডারের id-র সঙ্গে জোড়া সহজ। assert দুটি রাতের job-এর নীরব ভুল (সারি হারানো, NaN) লেখার আগেই থামায়। ফল ডেস্কটপে চালিয়ে যাচাই করা।

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

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

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

  1. Pipeline, metadata আর golden set-সহ artifact সংরক্ষণ
  2. Test ডেটা আর golden set-এ offline যাচাই
  3. Shadow-এ পাশাপাশি চালিয়ে latency আর ভুল তুলনা
  4. অল্প অংশে canary, guardrail নজরে রেখে
  5. ধাপে ধাপে বাড়িয়ে সব অনুরোধে চালু
  6. কিছুদিন পর পুরোনো version-এর artifact অবসরে পাঠানো

একটি অনলাইন দোকান প্রতি রাতে সব ক্রেতার churn-ঝুঁকি হিসাব করে, আর পরদিন সকালে সেই তালিকা দেখে SMS পাঠায়। এই কাজে model serving-এর কোন ধরন সবচেয়ে উপযুক্ত?

  • Batch scoring
  • প্রতিটি ক্রেতার জন্য online API ডাক
  • Streaming consumer
  • ক্রেতার ব্রাউজারে model চালানো

একটি API-র দশটি latency (ms): ২০, ১১, ১৩, ১২, ১৫, ১৪, ৪০, ১৬, ১২, ১৮. Nearest-rank পদ্ধতিতে (rank = ⌈q × n⌉) p90 কত?

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

Training-এর সময় ওজন না থাকলে training ডেটার median বসানো হয়েছিল। চালু সার্ভিসে আরেকজন developer নিজের কোডে ওজন না থাকলে ০ বসান, তারপর model-এ পাঠান। এটি কোন সমস্যার উদাহরণ?

  • Concept drift
  • Training-serving skew
  • Target leakage
  • Overfitting

joblib বা pickle দিয়ে রাখা scikit-learn model ফাইল নিয়ে কোন কথাটি সঠিক?

  • ফাইলে scikit-learn-এর পুরো কোড থাকে, তাই যেকোনো version-এ একই আচরণ
  • .joblib extension থাকলে ফাইলটি নিরাপদ
  • ফাইল এনক্রিপ্ট করা থাকে, তাই অন্য কেউ পড়তে পারে না
  • অচেনা উৎসের ফাইল load করলে তার ভেতরের নির্দেশে কোড চলতে পারে

নতুন model চালুর সময় shadow deployment আর canary release-এর মূল পার্থক্য কী?

  • Shadow শুধু test ডেটায় চলে, canary আসল অনুরোধে
  • Shadow-এ নতুন model-এর উত্তর ব্যবহারকারী দেখেন না, canary-তে অল্প অংশ দেখেন
  • Canary-তে একবারে সব ব্যবহারকারী নতুন model পান
  • Shadow-এ rollback সম্ভব, canary-তে নয়

একটি prediction API-তে প্রতি সেকেন্ডে গড়ে ৪০০টি অনুরোধ আসে, আর প্রতিটি অনুরোধে গড়ে ২৫ ms লাগে। Little’s Law অনুযায়ী একসঙ্গে গড়ে কতগুলো অনুরোধ চলমান থাকে?

  • ১৬
  • ২৫
  • ১০
  • ৪০০

শব্দকোষ

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

মনে রাখুন

  1. ধরন বাছাই: আগে জানা ইনপুট আর অপেক্ষাহীন কাজে batch, মুহূর্তের ইনপুট আর অপেক্ষারত ব্যবহারকারীতে online, ঘটনার স্রোতে streaming; প্রায়ই মিশ্র।
  2. Percentile-এ মাপ: p50, p95, p99 (rank = ⌈q × n⌉), latency budget স্তরে ভাগ, আর capacity-তে Little’s Law L = λ × W.
  3. এক artifact: preprocessing + model একটি pipeline-এ, সামনে ইনপুট-যাচাই, সঙ্গে metadata আর golden set; উত্তরে সবসময় model_version.
  4. Batch আর skew: একটি-একটি ডাক নয়, chunk; feature-এর হিসাব দুই জায়গায় লিখবেন না, একই pipeline-ই ব্যবহার করুন।
  5. নিরাপদে চালু: অচেনা pickle কখনো নয় (skops বা ONNX), FastAPI-তে Pydantic দিয়ে কড়া যাচাই, TestClient-এ পরীক্ষা, তারপর shadow → canary → সবাই, rollback প্রস্তুত রেখে।

পরের পর্বে: Model চালু হলো, কিন্তু সে ঠিক থাকছে কি না কে দেখবে? পর্ব ৬৯ (ML system-এর test আর নজরদারি — data test, model test আর drift) দেখাবে ডেটা আর model-এর জন্য কী কী test লিখবেন, আর চালুর পর drift, দেরিতে আসা label আর alert কীভাবে সামলাবেন।

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

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