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

ML দিয়ে forecasting — lag feature, backtesting আর অনেক series একসঙ্গে

Forecasting with Machine Learning — Lag Features, Backtesting, Direct vs Recursive, Global Models and Quantile Forecasts

Taught in Bangla, this lesson turns daily orders from six fictional districts into a supervised table and forecasts them with HistGradientBoosting. In-browser runs expose rolling-mean leakage, match a hand-written rolling-origin backtest to TimeSeriesSplit, compare recursive and direct multi-step forecasts and global versus local models, and measure quantile-band coverage; a desktop statsforecast run adds AutoETS and AutoARIMA.

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

  • একটি সময়ের ধারাকে lag, rolling আর ক্যালেন্ডার feature-এর টেবিলে রূপ দেওয়া, প্রতিটি কলামে leakage আছে কি না যাচাই করে
  • নিজে rolling-origin backtest লিখে TimeSeriesSplit-এর সঙ্গে মেলানো, আর expanding, sliding ও gap কখন লাগে তা জানা
  • Direct আর recursive পদ্ধতিতে কয়েক দিন সামনের অনুমান, আর horizon বাড়লে ভুল কেন বাড়ে তা হিসাব দিয়ে বোঝা
  • অনেকগুলো series-এর global model বনাম আলাদা local model, আর স্তরের অনুমান মেলানোর (reconciliation) ধারণা
  • Quantile forecast দিয়ে মজুদের সিদ্ধান্ত, backtest-এ coverage মাপা, আর intermittent demand-এ Croston-এর পদ্ধতি

AI-এর ভিত্তি কোর্সের পর্ব ২৬-এ pandas-এ তারিখ, resample, shift আর rolling শিখেছেন; পর্ব ৫৬-এ দেখেছেন সময়ের ডেটা এলোমেলো ভাগ করা কেন ভুল, আর TimeSeriesSplit-এর প্রথম পরিচয় পেয়েছেন; পর্ব ৬৩-এ lag feature এসেছিল একটি টীকা হিসেবে। আগের পর্ব ৫১ (Time series forecasting — decomposition, ARIMA আর exponential smoothing) একটি series-এর নিজের ছন্দ শেখার ক্লাসিক পথ দেখায়, সঙ্গে naive baseline আর MASE. এই পর্ব অন্য পথে হাঁটে: forecasting-কে সাধারণ regression বানিয়ে tree model দিয়ে শেখানো, আর তার সব ফাঁদ হাতে-কলমে ধরা। চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, pandas, scikit-learn আর matplotlib); প্রথমবার scikit-learn নামতে প্রায় দশ সেকেন্ড লাগে।

১ · সময়ের ধারা থেকে টেবিল — forecasting-কে supervised learning বানানো

পাড়ার চায়ের দোকানি প্রতি রাতে ঠিক করেন কাল কত লিটার দুধ আনবেন। তিনি কোনো সূত্র লেখেন না, কিন্তু মাথায় তিনটি জিনিস দেখেন: আজ কত বিক্রি হলো, গত সপ্তাহের এই বারে কত হয়েছিল, আর সামনে ঈদ বা হাটবার আছে কি না। এই তিনটিই একটি ML model-এর feature. আর তিনি কখনো কালকের বিক্রি দেখে কালকের দুধ ঠিক করতে পারেন না: সেটিই leakage-এর নিয়ম।

Forecasting মানে অতীতের ধারা দেখে ভবিষ্যতের মান অনুমান। ক্লাসিক model (ARIMA, ETS) একটি series-এর নিজের গতিবিধি শেখে। ML পথে আমরা time series-টিকে একটি টেবিলে ভাঙি: প্রতিটি (তারিখ, জেলা) একটি সারি, লক্ষ্য সেই দিনের অর্ডার, আর কলামে এমন তথ্য, যা অনুমানের মুহূর্তে হাতে ছিল। এই মুহূর্তের নাম forecast origin, আর origin থেকে কত দিন সামনের অনুমান, তার নাম horizon h. টেবিল তৈরি হয়ে গেলে যেকোনো regression model চলে; এই পর্বে HistGradientBoosting, যা পর্ব ৩৬-এর LightGBM-ধাঁচের histogram boosting.

একটি তারিখ = টেবিলের একটি সারি

বরিশাল hub, মে ২০২৬; origin ১৪ মে রাত, লক্ষ্য ১৫ মে (শুক্রবার)

Turning a daily series into one supervised-learning row ৭৮৯ ১০১১১২ ১৩১৪১৫ মে 467860 523556 5960? roll7 = ৮–১৪ মে-র গড় = 57.1 lag1 = গতকাল lag7 lag7lag1roll7 বারঈদ বাকিলক্ষ্য y 786057.1 শুক্র1275 • প্রতিটি (তারিখ, জেলা) = একটি সারি • বাঁয়ের কলাম: ১৪ মে রাতে যা জানা ছিল • ডানের লক্ষ্য: পরদিন জানা যায়, শেখার সময় লাগে • ৬ জেলা × ৭১৬ দিন = ৪,২৯৬টি সারি • তারপর যেকোনো regression model
ছবি ১.১ — একটি দৈনিক series থেকে টেবিলের একটি সারি। বাঁয়ের পাঁচটি কলাম origin-এ জানা; লক্ষ্য ৭৫ জানা যায় পরদিন, তাই সেটি শুধু শেখার সময় (পুরোনো সারিতে) কাজে লাগে।

এই রূপান্তরের তিনটি লাভ। এক, একটি model একসঙ্গে অনেক series শিখতে পারে (অংশ ৫)। দুই, বাইরের তথ্য (ঈদ, দামের ছাড়, প্রচারণা) শুধু একটি কলাম যোগ করার ব্যাপার। তিন, tree model নিজে থেকেই অ-রৈখিক সম্পর্ক আর মিথস্ক্রিয়া ধরে, যেমন “ঈদের আগের শুক্রবার”। দামও তিনটি। Tree model প্রশিক্ষণের পরিসরের বাইরে যেতে পারে না: অর্ডার বছরে ২০% বাড়লে model নিজে সেই ঢাল আঁকে না, তাই স্তর ধরে রাখার কাজ lag-এর। সময়ের হিসাব আপনাকে নিজে রাখতে হয়, আর ভুল হলে leakage চুপচাপ ঢোকে (অংশ ২)। আর কয়েক দিন সামনের অনুমান কীভাবে হবে, তা আলাদা সিদ্ধান্ত (অংশ ৪)।

২ · Lag, rolling আর ক্যালেন্ডার feature — leakage ছাড়া

প্রতিটি কলামের জন্য একটিই প্রশ্ন: origin-এ দাঁড়িয়ে এই সংখ্যাটি কি সত্যিই জানা ছিল? এমন কলামকে বলে point-in-time feature. তিন ধরনের feature এই পরীক্ষায় পাস করে, যদি ঠিকভাবে বানানো হয়:

  • Lag feature: k দিন আগের মান, pandas-এ groupby("district").shift(k). groupby বাদ দিলে এক জেলার প্রথম সারিতে আগের জেলার শেষ মান ঢুকে পড়ে (অনুশীলন ৩)। আগামীকালের অনুমানে (h = ১) k ≥ ১ নিরাপদ; h দিন সামনের অনুমানে k ≥ h হতে হবে।
  • Rolling পরিসংখ্যান: আগের সাত দিনের গড়, যা দৈনিক এলোমেলো ওঠানামা মসৃণ করে স্তর দেখায়। নিয়ম: আগে shift, পরে rolling, উল্টো নয়। rolling(7).mean() সরাসরি লিখলে জানালার শেষ ঘর আজকের দিন।
  • ক্যালেন্ডার feature: সপ্তাহের বার, ঈদ আসতে কত দিন, ঈদ গেছে কত দিন। তারিখ থেকেই হিসাব হয়, তাই ভবিষ্যতের দিনের জন্যও আগে জানা। এগুলো seasonality ধরে। আবহাওয়া এমন নয়: আগামীকালের বৃষ্টি আজ জানা নেই, তাই সেটি নিতে হলে আবহাওয়ার পূর্বাভাস নিতে হয়, আসল মাপ নয়।
origin: ১৪ মে রাত · লক্ষ্য: ১৫ মেh = 1

            
  1. কী জানা, কী নয়। ১৪ মে রাতে বরিশাল hub পরের দিনের মাল ঠিক করে। ১৪ মে পর্যন্ত সব অর্ডার জানা, ১৫ মে-র অর্ডার (পরে দেখা গেছে ৭৫) অজানা।
  2. Lag. shift(k) পুরো কলামটিকে k ঘর নিচে নামায়, তাই ১৫ মে-র সারিতে বসে ১৪ মে (৬০), ৮ মে (৭৮) আর ১ মে (৬১)-এর মান। তিনটিই origin-এ জানা।
  3. আগে shift, পরে rolling. জানালা ৮ থেকে ১৪ মে: যোগফল ৪০০, গড় ৫৭.১৪. এটি add_features-এর roll7.
  4. Shift ভুলে গেলে। জানালা এক দিন সরে ৯ থেকে ১৫ মে হয়: যোগফল ৩৯৭, গড় ৫৬.৭১. এর সাত ভাগের এক ভাগ আজকের অর্ডার, যা ১৪ মে রাতে কেউ জানে না।
  5. Leak কত গভীর। ভুল গড় আর ঠিক গড়ের পার্থক্যের সাত গুণ হলো আজকের মান বিয়োগ ৭ দিন আগের মান। তাই দুটি গড় আর lag7 থেকে ৩৯৭ − ৪০০ + ৭৮ = ৭৫, লক্ষ্য হুবহু। শক্তিশালী model এমন সম্পর্ক খুঁজে নেয়।
  6. ক্যালেন্ডার। বার আর ঈদের দূরত্ব তারিখ থেকেই আসে; ঈদ ২৭ মে, তাই ১২ দিন বাকি, আর আগের ঈদ ১০ দিনের সীমার বাইরে। চূড়ান্ত সারির সাতটি সংখ্যাই ১৪ মে রাতে জানা।

এবার পুরো ডেটা। ছয় জেলার দুই বছরের দৈনিক অর্ডার নিচের module seed দিয়ে বানায়: জেলাভেদে আলাদা স্তর আর বৃদ্ধি, শুক্র-শনিবারে বেশি, ঈদের আগের দুই সপ্তাহে ভিড় আর ঈদের দিন ও পরের দুদিন কম, সঙ্গে কয়েক দিন টিকে থাকা এলোমেলো ধাক্কা। ঈদের তারিখগুলো আনুমানিক, আর প্রভাবের মাপ পুরোপুরি বানানো; কোনো আসল দোকানের বিক্রি নয়। চারটি ব্লকই এই module থেকে ডেটা নেয়।

# বাজারঘর (কাল্পনিক দোকান): ছয় জেলার দৈনিক অর্ডার, seed দিয়ে বানানো
import numpy as np
import pandas as pd

DISTRICTS = ["Dhaka", "Chattogram", "Sylhet", "Rajshahi", "Khulna", "Barishal"]
BASE = [220, 140, 70, 60, 55, 35]          # শুরুর দিকের গড় দৈনিক অর্ডার
# ঈদের আনুমানিক তারিখ; প্রভাবের আকার বানানো, কোনো আসল বিক্রির নয়
EID = pd.to_datetime(["2024-06-17", "2025-03-31", "2025-06-07",
                      "2026-03-20", "2026-05-27", "2027-03-10"])

def eid_features(dates):
    d = pd.DatetimeIndex(dates).values[:, None]
    diff = (EID.values[None, :] - d) / np.timedelta64(1, "D")
    ahead = np.where(diff >= 0, diff, 999).min(axis=1)   # ঈদ আসতে কত দিন
    since = np.where(diff <= 0, -diff, 999).min(axis=1)  # ঈদ গেছে কত দিন
    return np.minimum(ahead, 30), np.minimum(since, 10)

def make_orders(start="2024-07-01", days=730, seed=52):
    rng = np.random.default_rng(seed)
    dates = pd.date_range(start, periods=days, freq="D")
    t, dow = np.arange(days) / 365, dates.day_of_week.values
    ahead, since = eid_features(dates)
    eid = np.where(ahead <= 14, 1 + 0.06 * (14 - ahead), 1.0)  # ঈদের আগে ভিড়
    eid = np.where(since <= 2, 0.45, eid)       # ঈদের দিন আর পরের দুদিন কম
    parts = []
    for name, base in zip(DISTRICTS, BASE):
        fri, sat = rng.uniform(0.15, 0.40), rng.uniform(0.0, 0.15)
        week = 1 + fri * (dow == 4) + sat * (dow == 5) - 0.08 * (dow == 1)
        growth = 1 + rng.uniform(0.10, 0.35) * t
        mu = base * growth * week * eid * (1 + 0.08 * np.sin(2 * np.pi * t))
        shock = np.zeros(days)                  # টানা কয়েক দিন থাকা ধাক্কা (AR(1))
        for i in range(1, days):
            shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.09)
        y = rng.poisson(mu * np.exp(shock))
        parts.append(pd.DataFrame({"date": dates, "district": name, "orders": y}))
    return pd.concat(parts, ignore_index=True)

FEATS = ["lag1", "lag7", "lag14", "roll7", "dow", "eid_ahead", "eid_since"]

def add_features(df):
    df = df.sort_values(["district", "date"]).reset_index(drop=True)
    g = df.groupby("district")["orders"]
    for k in (1, 7, 14):
        df[f"lag{k}"] = g.shift(k)
    df["roll7"] = g.transform(lambda s: s.shift(1).rolling(7).mean())  # আগে shift
    df["dow"] = df["date"].dt.day_of_week
    df["eid_ahead"], df["eid_since"] = eid_features(df["date"])
    df["district_id"] = df["district"].map(DISTRICTS.index)
    return df.dropna().reset_index(drop=True)

প্রথম ব্লক feature বানায়, ১৫ মে-র সারিটি ছাপে (স্টেপারের সঙ্গে মিলিয়ে দেখুন), তারপর একজন বিশ্লেষকের সাধারণ ভুল নকল করে: “trend” নামে একটি rolling(7).mean() কলাম, shift ছাড়া। শেষে শেষ আট সপ্তাহের test-এ চারটি অনুমানের MAE: seasonal naive (গত সপ্তাহের একই বার), naive (গতকাল), সৎ HistGradientBoosting, আর trend-যুক্ত HistGradientBoosting.

import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from bazar_orders import make_orders, add_features, FEATS

raw = make_orders()
df = add_features(raw)
print(raw.shape, "->", df.shape, "| rows with a missing lag dropped")
g = df.groupby("district")["orders"]
df["trend7"] = g.transform(lambda s: s.rolling(7).mean())   # shift ভুলে গেছি!
one = df[(df.district == "Barishal") & (df.date == "2026-05-15")]
print(one[["orders"] + FEATS + ["trend7"]].round(2).to_string(index=False))
back = 7 * df.trend7 - 7 * df.roll7 + df.lag7               # leak থেকে আজকের অর্ডার
print("max |rebuilt - orders| =", round((back - df.orders).abs().max(), 6))

cut = df.date.max() - pd.Timedelta(days=56)                 # শেষ ৮ সপ্তাহ test
tr, te = df[df.date <= cut], df[df.date > cut]
mae = lambda p: np.mean(np.abs(te.orders.to_numpy() - p))
print(f"seasonal naive (lag7)   MAE = {mae(te.lag7.to_numpy()):5.2f}")
print(f"naive (lag1)            MAE = {mae(te.lag1.to_numpy()):5.2f}")
for name, cols in [("HGB honest", FEATS), ("HGB + trend7 (leak)", FEATS + ["trend7"])]:
    m = HistGradientBoostingRegressor(random_state=0).fit(tr[cols], tr.orders)
    print(f"{name:23s} MAE = {mae(m.predict(te[cols])):5.2f}")

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

(4380, 3) -> (4296, 11) | rows with a missing lag dropped
 orders  lag1  lag7  lag14  roll7  dow  eid_ahead  eid_since  trend7
     75  60.0  78.0   61.0  57.14    4       12.0       10.0   56.71
max |rebuilt - orders| = 0.0
seasonal naive (lag7)   MAE = 44.48
naive (lag1)            MAE = 27.86
HGB honest              MAE = 19.35
HGB + trend7 (leak)     MAE = 15.59

সারির সংখ্যা ৪,৩৮০ থেকে ৪,২৯৬: প্রতিটি জেলার প্রথম ১৪ দিনে lag14 নেই, তাই ৬ × ১৪ = ৮৪টি সারি বাদ। ১৫ মে-র সারি স্টেপারের সঙ্গে হুবহু মেলে: roll7 ৫৭.১৪, ভুল trend7 ৫৬.৭১. পুনর্গঠনের লাইন বলছে, ৭·trend7 − ৭·roll7 + lag7 সব ৪,২৯৬টি সারিতে অর্ডারের সমান, পার্থক্য শূন্য। MAE-এ seasonal naive ৪৪.৪৮ আর গতকালের মান ২৭.৮৬; সৎ model ১৯.৩৫. Trend7 যোগ করলে ১৫.৫৯, প্রায় ২০% “উন্নতি”। কিন্তু ১৪ মে রাতে ১৫ মে-র trend7 হিসাব করা যায় না, কারণ তার জন্য ১৫ মে-র অর্ডার লাগে। Test set-ও একই ভুল কলাম থেকে বানানো, তাই সেখানে ভুলটি ধরা পড়ে না; ধরা পড়ে চালু করার পর, যখন কলামটি আর পাওয়া যায় না। নিয়মটি মনে রাখুন: অবিশ্বাস্য রকম ভালো score পেলে আগে leakage খুঁজুন (temporal leakage), তারপর উদযাপন।

৩ · Backtesting — rolling origin, expanding আর sliding window

শেষ আট সপ্তাহের একটি time-based split সৎ, কিন্তু একটি মাত্র নমুনা। সেই আট সপ্তাহে ঈদ পড়লে এক রকম ফল, না পড়লে আরেক রকম। Backtesting বা rolling-origin evaluation তাই অতীতের কয়েকটি তারিখে দাঁড়ায়: প্রতিটি origin-এ শুধু তার আগের ডেটায় শেখায়, পরের কয়েক সপ্তাহে মাপে, তারপর origin সামনে সরায়। এটি সময়ের ডেটার cross-validation (AI-এর ভিত্তি কোর্সের পর্ব ৬৬-এর KFold-এর বদলে)। Train অংশ কেমন হবে, তার দুটি ধরন আছে: expanding window, যা শুরু থেকে origin পর্যন্ত সব নেয়, আর sliding window, যা শুধু শেষ নির্দিষ্ট কয়েক দিন নেয়।

দিন 0 = 2024-07-15 · দিন 715 = 2026-06-30█ train · ▓ test · ░ বাদ

            
  1. সময়রেখা। lag14-এর জন্য প্রথম ১৪ দিন বাদ দিয়ে ৭১৬ দিন। শেষ ১৪০ দিনকে ২৮ দিনের পাঁচটি test অংশে ভাগ করা হয়; প্রথম test দিন ২০২৬-০২-১১.
  2. Expanding, প্রথম fold. শুরু থেকে দিন ৫৭৫ পর্যন্ত ৫৭৬ দিনে শেখা, তার ঠিক পরের ২৮ দিনে মাপা। Test সবসময় train-এর পরে।
  3. Expanding, শেষ fold. Origin চার ধাপ সামনে, train ৬৮৮ দিন। প্রতিটি fold-এ train ২৮ দিন বড় হয়, কারণ বাস্তবেও সময়ের সঙ্গে ইতিহাস জমে।
  4. Sliding. TimeSeriesSplit(max_train_size=365) train-কে শেষ ৩৬৫ দিনে সীমিত রাখে। পুরোনো আচরণ যখন আর প্রাসঙ্গিক নয় (নতুন দাম-নীতি, নতুন কুরিয়ার), তখন এটি কাজে লাগে; দাম হলো কম ডেটা।
  5. Gap. gap=7 origin আর test-এর মাঝে সাত দিন ফেলে দেয়। দরকার হয় যখন origin থেকে প্রথম অনুমানের দিন দূরে, বা লক্ষ্য নিজেই সামনের কয়েক দিনে ছড়ানো (নিচে দেখুন)।

দ্বিতীয় ব্লক এই ভাগ নিজে লেখে, gap = ০ আর ৭ দুই ক্ষেত্রেই scikit-learn-এর TimeSeriesSplit-এর সঙ্গে মেলায়, তারপর পাঁচটি fold-এ seasonal naive আর HistGradientBoosting তুলনা করে। একটি খুঁটিনাটি গুরুত্বপূর্ণ: ছয়টি জেলার সারি একটির নিচে আরেকটি সাজানো, তাই ভাগ হয় তারিখের তালিকায়, সারির নম্বরে নয়। সারির নম্বরে TimeSeriesSplit চালালে “train” পায় ঢাকা আর চট্টগ্রাম, “test” পায় বরিশাল, আর সময়ের ক্রম ভেঙে যায়।

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import TimeSeriesSplit
from sklearn.ensemble import HistGradientBoostingRegressor
from bazar_orders import make_orders, add_features, FEATS
df = add_features(make_orders())
days = np.sort(df.date.unique())              # ভাগ হবে তারিখে, সারিতে নয়
def rolling_origin(n, n_splits, test_size, gap=0):
    for k in range(n_splits, 0, -1):          # প্রথম fold সবচেয়ে পুরোনো
        end = n - (k - 1) * test_size
        yield np.arange(0, end - test_size - gap), np.arange(end - test_size, end)
for gap in (0, 7):
    mine = list(rolling_origin(len(days), 5, 28, gap))
    skl = list(TimeSeriesSplit(n_splits=5, test_size=28, gap=gap).split(days))
    same = all((a == c).all() and (b == d).all() for (a, b), (c, d) in zip(mine, skl))
    print(f"gap={gap}: identical to TimeSeriesSplit: {same} | last fold: "
          f"train 0..{mine[-1][0][-1]}, test {mine[-1][1][0]}..{mine[-1][1][-1]}")
rows, folds = [], list(rolling_origin(len(days), 5, 28))   # h = 1, তাই gap = 0
for k, (tr_i, te_i) in enumerate(folds, 1):
    tr, te = df[df.date.isin(days[tr_i])], df[df.date.isin(days[te_i])]
    m = HistGradientBoostingRegressor(random_state=0).fit(tr[FEATS], tr.orders)
    err = lambda p: np.mean(np.abs(te.orders.to_numpy() - p))
    rows.append([k, str(days[te_i[0]])[:10], err(te.lag7.to_numpy()),
                 err(m.predict(te[FEATS]))])
res = pd.DataFrame(rows, columns=["fold", "test_from", "snaive_MAE", "HGB_MAE"])
print(res.round(2).to_string(index=False))
print("mean MAE:", res[["snaive_MAE", "HGB_MAE"]].mean().round(2).to_dict())
fig, ax = plt.subplots(figsize=(6, 2.6)); fig.subplots_adjust(bottom=0.2)
for k, (tr_i, te_i) in enumerate(folds, 1):
    ax.barh(k, len(tr_i), color="#93c5fd"); ax.barh(k, 28, left=te_i[0], color="#f97316")
ax.set_xlabel("day index (train = blue, test = orange)"); ax.set_ylabel("fold")
ax.invert_yaxis(); plt.show()

আসল আউটপুট (ব্রাউজারের Python; fold-এর ছবি ব্লকের নিচে):

gap=0: identical to TimeSeriesSplit: True | last fold: train 0..687, test 688..715
gap=7: identical to TimeSeriesSplit: True | last fold: train 0..680, test 688..715
 fold  test_from  snaive_MAE  HGB_MAE
    1 2026-02-11       22.04    13.68
    2 2026-03-11       59.16    21.19
    3 2026-04-08       22.19    14.88
    4 2026-05-06       62.43    22.27
    5 2026-06-03       26.52    16.24
mean MAE: {'snaive_MAE': 38.47, 'HGB_MAE': 17.65}

প্রথম দুই লাইন: নিজে লেখা ভাগ আর TimeSeriesSplit হুবহু এক, gap ছাড়া আর gap-সহ; gap = ৭-এ শেষ fold-এর train শেষ হয় ৬৮০-তে, test শুরু ৬৮৮-এ। Fold-এর টেবিল একটি holdout-এর বিপদ দেখায়। Seasonal naive-এর MAE ২২ থেকে ৬২: fold ২ (১১ মার্চ থেকে, ২০ মার্চের ঈদসহ) আর fold ৪ (৬ মে থেকে, ২৭ মে-র ঈদসহ)-এ “গত সপ্তাহের একই বার” ঈদের ভিড় বা ফাঁকা দিন জানে না। HistGradientBoosting-এর ঈদের কলাম আছে, তাই সেখানে ২১–২২, আর অন্য fold-এ ১৪–১৬. গড় ১৭.৬৫ বনাম ৩৮.৪৭. শুধু শেষ fold দেখলে seasonal naive-কে অনেক ভালো (২৬.৫২) মনে হতো। তাই সব fold-এর সংখ্যা রিপোর্ট করুন, আর দুটি model-এর তুলনা fold ধরে ধরে করুন (পর্ব ২২-এর paired তুলনার মতো)। ছবিতে নীল train প্রতিটি fold-এ লম্বা হয়েছে, আর কমলা test ঠিক তার পরে বসেছে।

Gap কখন লাগে? এখানে h = ১, আর প্রতিটি সারি লক্ষ্য-তারিখ দিয়ে চিহ্নিত, তাই gap = ০ সঠিক। কিন্তু ধরুন সারি চিহ্নিত origin দিয়ে, আর লক্ষ্য h দিন পরের অর্ডার (বা “সামনের সাত দিনের মোট”)। তখন train-এর শেষ h − ১টি সারির লক্ষ্য test-এর সময়ের ভেতরে পড়ে: origin-এ সেগুলো এখনো ঘটেনি। gap = h − 1 সেই সারিগুলো ফেলে দেয়। ডেটা দেরিতে চূড়ান্ত হলেও (ফেরত আর বাতিল দুদিন পরে হিসাবে আসে) একই কাজ করতে হয়।

একই backtest-এ ক্লাসিক model — ডেস্কটপে সত্যিকারের run

পর্ব ৫১-এর ক্লাসিক model কি এই ছয় জেলায় আরও ভালো? statsmodels-এ প্রতি জেলায় প্রতিটি fold-এ SARIMA fit পাতায় ধীর, তাই একটি ডেস্কটপ প্রকল্পে Nixtla-র statsforecast চালানো হয়েছে, একই module আর একই পাঁচটি fold দিয়ে: প্রতি ২৮ দিনে নতুন করে fit (refit=28), আর মাঝের দিনগুলোতে আগের দিনের আসল মান দেখে পরের দিনের অনুমান, অর্থাৎ HistGradientBoosting-এর মতোই h = ১.

$ uv init --vcs none --author-from none --python 3.14 bazarghar-forecast
Initialized project `bazarghar-forecast` at `~/bazarghar-forecast`
$ cd bazarghar-forecast
$ uv add statsforecast
…
 + statsforecast==2.1.1
 + statsmodels==0.15.0
…
$ uv pip list | grep -E "^(numpy|pandas|statsforecast) "
numpy             2.5.4
pandas            2.3.3
statsforecast     2.1.1
$ uv run python sf_backtest.py
import sys
import time
import numpy as np
import pandas as pd
import statsforecast
from statsforecast import StatsForecast
from statsforecast.models import AutoARIMA, AutoETS
from bazar_orders import make_orders

raw = make_orders()
data = raw.rename(columns={"district": "unique_id", "date": "ds", "orders": "y"})
sf = StatsForecast(models=[AutoETS(season_length=7),
                           AutoARIMA(season_length=7)], freq="D")
t0 = time.perf_counter()
cv = sf.cross_validation(df=data, h=1, step_size=1, n_windows=140, refit=28)
secs = time.perf_counter() - t0
cv = cv.sort_values(["unique_id", "ds"]).reset_index(drop=True)
cv["fold"] = (cv.groupby("unique_id").cumcount() // 28) + 1   # 28 দিনের ৫টি fold
models = ["AutoETS", "AutoARIMA"]
mae = cv.groupby("fold")[models].apply(
    lambda g: (g.sub(cv.loc[g.index, "y"], axis=0)).abs().mean())
print(mae.astype(float).round(2).to_string())
print("first test day per fold:", cv.groupby("fold").ds.min().dt.date.astype(str).tolist())
print("mean:", mae.astype(float).mean().round(2).to_dict())
print(f"rows: {len(cv)} | fit + forecast: {secs:.1f} s")
print("python", sys.version.split()[0], "| statsforecast", statsforecast.__version__,
      "| pandas", pd.__version__, "| numpy", np.__version__)

সত্যিকারের run (ডেস্কটপ, লেখার সময়ের version; শেষ লাইনে):

      AutoETS  AutoARIMA
fold                    
1       13.44      15.65
2       28.98      28.14
3       15.01      16.42
4       30.45      33.12
5       14.07      19.51
first test day per fold: ['2026-02-11', '2026-03-11', '2026-04-08', '2026-05-06', '2026-06-03']
mean: {'AutoETS': 20.39, 'AutoARIMA': 22.57}
rows: 840 | fit + forecast: 124.6 s
python 3.14.4 | statsforecast 2.1.1 | pandas 2.3.3 | numpy 2.5.4

একই পাঁচটি test অংশে (প্রথম দিনের তারিখ ব্রাউজারের ব্লকের সঙ্গে মেলে) AutoETS-এর গড় MAE ২০.৩৯, AutoARIMA (season ৭ দিনের SARIMA-র স্বয়ংক্রিয় বাছাই) ২২.৫৭; পাতার HistGradientBoosting ১৭.৬৫, seasonal naive ৩৮.৪৭. পার্থক্যের বড় অংশ ঈদের দুটি fold-এ, কারণ ক্লাসিক model দুটিকে ঈদের কোনো খবর দেওয়া হয়নি, আর HistGradientBoosting-কে দেওয়া হয়েছে। সৎ তুলনার জন্য ARIMA-তেও ঈদের exogenous variable দেওয়া যায়। আরও দুটি সতর্কতা: এটি একটি বানানো ডেটায় একটি run, আর ঈদ ছাড়া তিনটি fold-এ ETS আর HistGradientBoosting প্রায় সমান, দুটিই ১৩ থেকে ১৬-এর মধ্যে। দুটি তথ্যও লক্ষ করুন: statsforecast 2.1.1 নিজের নির্ভরতায় pandas<3.0.0 লেখে, তাই uv pandas 2.3.3 বসিয়েছে; আর এই ডেস্কটপে পুরো run-এ প্রায় দুই মিনিট লেগেছে।

৪ · কয়েক দিন সামনে — recursive বনাম direct

ঈদের আগে hub-এ মাল যায় এক বা দুই সপ্তাহ আগে, তাই আগামীকালের অনুমান যথেষ্ট নয়; দরকার ১ থেকে ১৪ দিন সামনের অনুমান। সমস্যা: lag1 দিয়ে শেখা model পরশুর অনুমানে আগামীকালের lag1 চায়, যা origin-এ অজানা। দুটি প্রচলিত পথ আছে।

  • Recursive (iterated): একটি h = ১ model. আগামীকাল অনুমান করে সেই সংখ্যাকে আসল মানের জায়গায় বসান, তারপর পরের দিন, এভাবে ১৪ বার। Model একটি, কিন্তু প্রতিটি ধাপের ভুল পরের ধাপের feature-এ ঢোকে।
  • Direct: প্রতিটি h-এর জন্য আলাদা model, যার feature শুধু origin পর্যন্ত জানা: h = ১০-এর model-এর সবচেয়ে কাছের lag হলো lag10, আর “একই বার”-এর lag হলো lag14. নিজের অনুমান ফেরত খায় না, কিন্তু ১৪টি model, আর দূরের h-এর model সাম্প্রতিক তথ্য কম পায়।
Model: ŷt+1 = 0.8·yt + 20 · origin-এ জানা y0 = 120 · প্রতিদিনের নিজস্ব ভুলের SD σ = 10
ধাপ
model-এ যা ঢোকে
অনুমান
আসল (পরে জানা)
ভুল
  1. প্রথম ধাপ। Origin-এ আসল y০ = ১২০ জানা, তাই অনুমান ১১৬. পরদিন দেখা গেল আসল ১১৩: ভুল −৩, শুধু এই দিনের এলোমেলোতা।
  2. দ্বিতীয় ধাপ। y১ = ১১৩ origin-এ অজানা, তাই model নিজের ১১৬ খায়। আসলটি জানা থাকলে অনুমান হতো ০.৮ × ১১৩ + ২০ = ১১০.৪; নিজের অনুমান খেয়ে ১১২.৮. পার্থক্য ২.৪ = ০.৮ × ৩: আগের ধাপের ভুল বয়ে এসেছে।
  3. তৃতীয় ধাপ। ১১২.৮ থেকে ১১০.২৪; আসল ১০১, ভুল −৯.২৪. প্রতিটি ধাপে পুরোনো ভুলগুলো ৮০% হারে টিকে থাকে, আর নতুন ভুল যোগ হয়।
  4. বিস্তারের হিসাব। ভুল eh = ০.৮·eh−১ + নতুন ভুল, তাই variance σ²(১ + ০.৬৪ + ০.৬৪² + …). SD ১০ থেকে ১২.৮১, তারপর ১৪.৩২: h বাড়লে অনিশ্চয়তা বাড়ে, model ঠিক থাকলেও।
  5. দূরের দিকে। h = ১৪-এ SD ১৬.৬৫, প্রায় সীমা ১৬.৬৭ (series-এর নিজের ছড়ানো)। এর ওপর model-এর নিজের ভুল (যেমন ০.৮-এর জায়গায় ০.৯ শেখা) প্রতিটি ধাপে গুণ হয়ে জমে; direct পদ্ধতি এই দ্বিতীয় অংশটি এড়ায়।

তৃতীয় ব্লক দুটি পথই চালায়। একটি origin পর্যন্ত (শেষ ১৫৫ দিনের আগে) শেখানো হয়, তারপর আর refit নয়; পরের ১৪১টি দিনের প্রতিটিকে origin ধরে ১ থেকে ১৪ দিন সামনের অনুমান, ছয় জেলায়। Feature-এর function X(P, t, h) একটিই: h দিলে শুধু t − h পর্যন্ত জানা মান নেয়। Recursive পথে P-তে origin-এর পরের সব মান মুছে দিয়ে নিজের অনুমান বসানো হয়; direct পথে প্রতিটি h-এর আলাদা model আসল ইতিহাস থেকে সরাসরি পড়ে। HistGradientBoosting প্রথমবার ১৪টি model শিখতে কয়েক সেকেন্ড নিতে পারে।

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import HistGradientBoostingRegressor as HGB
from bazar_orders import make_orders, eid_features
raw = make_orders()
Y = raw.pivot(index="date", columns="district", values="orders").to_numpy(float)
days = np.sort(raw.date.unique()); H, n_d = 14, Y.shape[1]
cal = [pd.DatetimeIndex(days).day_of_week.to_numpy(), *eid_features(days)]
def X(P, t, h):    # target দিন t-এর feature, শুধু t − h পর্যন্ত জানা তথ্য দিয়ে
    w, j = 7 * int(np.ceil(h / 7)), np.arange(len(t))   # w: একই বারের জানা দিন
    cols = [P[j, t - h], P[j, t - w], P[j, t - w - 7],
            np.mean([P[j, t - h - k] for k in range(7)], axis=0)]
    cols += [np.tile(v[t][:, None], (1, n_d)) for v in cal]
    return np.stack(cols + [np.tile(np.arange(n_d), (len(t), 1))], -1).reshape(-1, 8)
T0, t_tr = len(Y) - 155, np.arange(28, len(Y) - 154)   # T0-র পরে আর refit নয়
YY = np.broadcast_to(Y, (len(t_tr),) + Y.shape)
direct = {h: HGB(random_state=0, categorical_features=[7]).fit(X(YY, t_tr, h),
          Y[t_tr].ravel()) for h in range(1, H + 1)}       # প্রতি h-এ একটি model
o = np.arange(T0, len(Y) - H); J = np.arange(len(o))       # প্রতিদিন একটি origin
P = np.repeat(Y[None], len(o), 0); err = np.zeros((3, H))
P[np.arange(len(Y))[None, :] > o[:, None]] = np.nan       # origin-এর পরের সব মুছে দেওয়া
for h in range(1, H + 1):
    t, truth = o + h, Y[o + h].ravel()
    P[J, t] = direct[1].predict(X(P, t, 1)).reshape(-1, n_d)   # নিজের অনুমান ফেরত
    d = direct[h].predict(X(np.broadcast_to(Y, P.shape), t, h))
    s = Y[t - 7 * int(np.ceil(h / 7))].ravel()                  # seasonal naive
    err[:, h - 1] = [np.abs(truth - p).mean() for p in (P[J, t].ravel(), d, s)]
tab = pd.DataFrame(err.T, index=pd.Index(range(1, H + 1), name="h"),
                   columns=["recursive", "direct", "snaive"])
print(len(o), "origins x", n_d, "districts\n", tab.iloc[[0, 1, 3, 6, 9, 13]].round(1))
plt.figure(figsize=(6, 3)).subplots_adjust(bottom=0.17)
plt.plot(tab.index, tab.to_numpy(), marker="o", label=list(tab.columns))
plt.xlabel("horizon h (days ahead)"); plt.ylabel("MAE (orders)"); plt.legend(); plt.show()

আসল আউটপুট (ব্রাউজারের Python; horizon বনাম MAE-এর ছবি ব্লকের নিচে):

141 origins x 6 districts
     recursive  direct  snaive
h                            
1        17.3    17.3    38.4
2        20.3    20.0    38.4
4        22.7    22.6    38.5
7        25.3    23.5    38.4
10       26.6    25.1    42.9
14       27.4    26.0    42.8

h = ১-এ দুটি পথ এক (১৭.৩), কারণ direct-এর h = ১ model-ই recursive-এর model. তারপর দুটিই বাড়ে: recursive ১৪ দিনে ২৭.৪, direct ২৬.০. বাড়ার প্রধান কারণ ডেটায় কয়েক দিন টিকে থাকা ধাক্কা: আজকের খবর দুদিন পরের জন্য প্রাসঙ্গিক, দুই সপ্তাহ পরের জন্য প্রায় অপ্রাসঙ্গিক। Recursive ৭ দিন থেকে একটু বেশি পিছিয়ে পড়ে, স্টেপারের মতো জমে থাকা ভুলের জন্য। Seasonal naive ৩৮–৪৩, প্রায় সমান, কারণ তার ভুল মূলত দৈনিক এলোমেলোতা। এখানে direct সামান্য এগিয়ে, কিন্তু ১৪টি model-এর দামে; ডেটা কম, h বেশি বা linear model হলে ক্রম উল্টেও যেতে পারে। দুটি মাঝামাঝি পথও আছে: একটি model-এ h-কে feature করা, আর একটি model থেকে একসঙ্গে সব h-এর output নেওয়া, যাকে multi-output পদ্ধতি বলে।

একটি নীরব ভুল: recursive model-কে backtest করার সময় অনেকে প্রতিটি h-এ আসল lag দিয়ে দেন, কারণ test টেবিলে আসল মানগুলো আছেই। তখন “১৪ দিন সামনের” সংখ্যাটি আসলে ১৪ বার “আগামীকালের” অনুমান, আর ভুল h-এর সঙ্গে বাড়েই না। ব্লকে origin-এর পরের সব মান NaN করে দেওয়া হয়েছে ঠিক এই কারণে।

৫ · অনেক series একসঙ্গে — global বনাম local model

বাজারঘরের ছয়টি জেলা, আর বাস্তবে হাজারো পণ্য। দুটি পথ। Local model: প্রতিটি series-এর আলাদা model, শুধু নিজের ইতিহাস থেকে; ক্লাসিক ARIMA আর ETS এভাবেই চলে। Global model: সব series-এর সারি একটি টেবিলে, series-এর পরিচয় একটি কলাম, আর একটি model. Global model-এর সুবিধা: শুক্রবারের উল্লম্ফন বা ঈদের ভিড়ের মতো সবার মধ্যে মিল থাকা ধরন সে সব জেলার ডেটা থেকে একসঙ্গে শেখে, আর ছোট বা নতুন series বড়দের থেকে ধার নেয়। ঝুঁকি: বড় series (ঢাকা) loss-এ বেশি জায়গা নেয়, আর আকারে খুব আলাদা series-এ নিজের স্তর আলাদা রাখতে হয়। এখানে district_id একটি category কলাম, যা HistGradientBoosting-এর native categorical support দিয়ে split-এ জেলাগুলোকে দলে ভাগ করতে পারে; অনেকে প্রতিটি series-কে তার সাম্প্রতিক গড় দিয়ে ভাগ করে আকারও সমান করেন।

চতুর্থ ব্লকে একটি বাস্তব পরিস্থিতি: বরিশাল hub খুলেছে মাত্র ৯০ দিন আগে, তাই তার ইতিহাস ছোট। একই শেষ আট সপ্তাহে global আর local model তুলনা, তারপর global model দিয়ে ১০, ৫০ আর ৯০তম quantile-এর অনুমান (অংশ ৬)।

import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor as HGB
from bazar_orders import make_orders, add_features, FEATS

df = add_features(make_orders())
cut = df.date.max() - pd.Timedelta(days=56)              # শেষ ৮ সপ্তাহ test
new = (df.district == "Barishal") & (df.date <= cut - pd.Timedelta(days=90))
df = df[~new]                                            # বরিশাল hub নতুন: ৯০ দিনের ইতিহাস
tr, te = df[df.date <= cut], df[df.date > cut]
G = FEATS + ["district_id"]

glob = HGB(categorical_features=["district_id"], random_state=0).fit(tr[G], tr.orders)
te = te.assign(global_=glob.predict(te[G]), local=np.nan)
for d, part in tr.groupby("district"):                   # জেলাপ্রতি আলাদা model
    m = HGB(random_state=0).fit(part[FEATS], part.orders)
    te.loc[te.district == d, "local"] = m.predict(te.loc[te.district == d, FEATS])
ae = te[["global_", "local"]].sub(te.orders, axis=0).abs()
tab = ae.groupby(te.district, sort=False).mean()
tab["train_rows"] = tr.groupby("district", sort=False).size()
print(tab.round(1)); print("all:", ae.mean().round(2).to_dict())

q = {a: HGB(loss="quantile", quantile=a, categorical_features=["district_id"],
            random_state=0).fit(tr[G], tr.orders).predict(te[G]) for a in (0.1, 0.5, 0.9)}
inside = (te.orders >= q[0.1]) & (te.orders <= q[0.9])
print("coverage of 10-90 band:", round(inside.mean(), 3),
      "| below q0.1:", round((te.orders < q[0.1]).mean(), 3),
      "| above q0.9:", round((te.orders > q[0.9]).mean(), 3))
print(inside.groupby(te.district, sort=False).mean().round(2).to_dict())
row = te.district.eq("Dhaka").to_numpy().nonzero()[0][-1]
print("Dhaka, last day: q10/q50/q90 =", [round(q[a][row]) for a in q], "actual =", te.orders.iloc[row])

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

            global_  local  train_rows
district                              
Barishal        7.5   10.5          90
Chattogram     30.6   37.2         660
Dhaka          43.5   44.0         660
Khulna         10.6   10.6         660
Rajshahi       11.8   14.5         660
Sylhet         13.4   13.6         660
all: {'global_': 19.56, 'local': 21.73}
coverage of 10-90 band: 0.729 | below q0.1: 0.098 | above q0.9: 0.173
{'Barishal': 0.77, 'Chattogram': 0.68, 'Dhaka': 0.66, 'Khulna': 0.77, 'Rajshahi': 0.7, 'Sylhet': 0.8}
Dhaka, last day: q10/q50/q90 = [261, 307, 331] actual = 328

Global model সব মিলিয়ে MAE ১৯.৫৬, local ২১.৭৩. সবচেয়ে বড় পার্থক্য নতুন বরিশাল hub-এ (৭.৫ বনাম ১০.৫): মাত্র ৯০টি সারি থেকে local model শুক্রবার আর ঈদের ধরন ভালোভাবে শিখতে পারেনি, global model অন্য পাঁচ জেলা থেকে শিখে এনেছে। চট্টগ্রামেও global এগিয়ে (৩০.৬ বনাম ৩৭.২); ঢাকা, খুলনা আর সিলেটে প্রায় সমান। একটি বানানো ডেটার একটি split, তাই সংখ্যা নয়, ধরনটি মনে রাখুন: ইতিহাস যত ছোট, global model-এর লাভ তত বেশি। শেষ চার লাইনের quantile-এর ফল অংশ ৬-এ।

স্তরের অনুমান মেলানো। বাজারঘরের series একটি স্তরবিন্যাসে সাজানো: দেশ → বিভাগ → জেলা → পণ্য। প্রতিটি স্তরে আলাদা model বানালে সংখ্যাগুলো মেলে না: ছয় জেলার অনুমানের যোগফল ৭৬০, অথচ দেশের model বলছে ৮০০. Hierarchical forecasting-এ এই অমিল দূর করার কাজকে বলে reconciliation. Bottom-up: শুধু নিচের স্তর অনুমান করে যোগ করা (৭৬০); সহজ, কিন্তু নিচের series শব্দ-ভরা। Top-down: দেশের ৮০০-কে ইতিহাসের অনুপাতে জেলায় ভাগ; মসৃণ, কিন্তু জেলার নিজস্ব ধরন হারায়। আরও আধুনিক একটি পথ সব স্তরের অনুমান একসঙ্গে নিয়ে ভুলের covariance দিয়ে ওজন করে মেলায়, যার পরিচিত রূপ MinT (Wickramasuriya, Athanasopoulos আর Hyndman, ২০১৯)।

৬ · Quantile forecast আর মজুদের সিদ্ধান্ত

Hub-এর ম্যানেজার একটি সংখ্যা চান না, চান “কতটা রাখলে বেশিরভাগ দিন কম পড়বে না”। পর্ব ২৪-এ দেখেছেন, pinball loss একটি quantile শেখায়; HistGradientBoosting-এ loss="quantile" আর quantile=τ দিলে সেই loss-এ gradient boosting চলে। এভাবে পাওয়া quantile forecast-এর ১০ আর ৯০তম মান মিলিয়ে একটি ৮০% prediction interval.

ব্লক ৪-এর শেষ অংশ এই interval-কে শেষ আট সপ্তাহে যাচাই করে। Band ধরেছে ৭২.৯%, লক্ষ্য ৮০%. নিচে পড়েছে ৯.৮% (লক্ষ্য ১০%, ঠিক আছে), কিন্তু ওপরে বেরিয়েছে ১৭.৩% (লক্ষ্য ১০%): ওপরের লেজ ছোট করে ধরা। কারণ দুটি। অর্ডার বছরে বাড়ছে, আর tree model প্রশিক্ষণের সর্বোচ্চ মানের ওপরে যেতে পারে না; আর test-এ ঈদের ভিড় আছে। জেলাভেদে coverage ০.৬৬ (ঢাকা) থেকে ০.৮০ (সিলেট)। শিক্ষা: interval-এর নাম “৮০%” হলেই সে ৮০% ধরে না; backtest-এ মাপুন, জেলা ধরে ধরে। প্রতিকার: backtest-এর ভুল দেখে band চওড়া করা, লক্ষ্যকে স্তর দিয়ে ভাগ করে আকারহীন করা, বা conformal পদ্ধতি, যা পর্ব ৫৮ (Conformal prediction — নিশ্চয়তাসহ অনুমানের পরিসর)-এ আসবে।

কোন quantile মজুদ করবেন? এটি একটি সিদ্ধান্তের প্রশ্ন (AI-এর ভিত্তি কোর্সের পর্ব ৫৪-এর expected cost-এর হিসাব)। এক ইউনিট কম রাখলে হারানো লাভ আর ক্রেতার বিরক্তি মিলিয়ে দাম cu, বেশি রাখলে গুদাম আর ফেরত পাঠানোর দাম co. ক্লাসিক newsvendor problem-এর উত্তর: চাহিদার τ = cu/(cu + co) quantile পরিমাণ রাখুন। cu = ৳৮০ আর co = ৳২০ হলে τ = ৮০/১০০ = ০.৮; অর্থাৎ ৮০তম percentile-এর model দরকার, গড়ের নয়। ব্লকের শেষ লাইনে ঢাকার শেষ দিনে q১০/q৫০/q৯০ = ২৬১/৩০৭/৩৩১, আসল ৩২৮: median অনুযায়ী মাল রাখলে সেদিন ২১টি অর্ডার কম পড়ত।

অনেক শূন্যের চাহিদা: দামি ব্লেন্ডার বা বিরল বইয়ের মতো পণ্যে বেশিরভাগ দিন বিক্রি শূন্য, মাঝে মাঝে কয়েকটি। এমন intermittent demand-এ MAPE অর্থহীন (শূন্য দিয়ে ভাগ), আর গড়ভিত্তিক model প্রতিদিন ভগ্নাংশ বলে। ক্লাসিক উত্তর Croston-এর পদ্ধতি (J. D. Croston, ১৯৭২): বিক্রির আকার z আর দুটি বিক্রির মাঝের ব্যবধান p আলাদা করে exponential smoothing, অনুমান দৈনিক হার z/p. যেমন ১২ দিনের বিক্রি ০, ০, ৬, ০, ০, ০, ৪, ০, ৩, ০, ০, ৫ আর α = ০.৫ হলে শেষে z = ৪.৫, p = ২.৮৭৫, হার ১.৫৬৫ (অনুশীলন ৪)। পরে Syntetos আর Boylan দেখান যে মূল পদ্ধতিতে একটি পদ্ধতিগত bias আছে, আর একটি সংশোধন দেন। ML পথে loss="poisson" (পর্ব ২০-এর Poisson) আর অনেক পণ্যের একটি global model প্রায়ই ভালো বিকল্প।

৭ · Forecasting-এর library আর প্রতিযোগিতার শিক্ষা

এই পর্বের সব কিছু pandas আর scikit-learn দিয়ে নিজে লেখা, যাতে প্রতিটি ধাপ চোখে দেখা যায়। কাজের জায়গায় কয়েকটি library এই হিসাব গুছিয়ে দেয়। নিচের version লেখার সময়ে PyPI-তে দেখা; API বদলায়, তাই নিজের version-এর docs দেখুন।

Libraryকী দেয়কখন ভাববেন
statsforecast (2.1.1)দ্রুত ETS, ARIMA, Theta, Croston; cross_validation দিয়ে backtestঅনেক series-এ ক্লাসিক baseline; ওপরের run
mlforecastএই পর্বের পথ: lag, rolling আর তারিখের feature বানিয়ে যেকোনো scikit-learn-ধাঁচের model, recursive বা directglobal tree model, অনেক series
sktime (1.2.0)scikit-learn-এর মতো একটি সাধারণ interface; regression model-কে forecaster বানানোর “reduction”অনেক পদ্ধতি এক API-তে তুলনা
Darts (0.48.1)ক্লাসিক, tree আর deep learning model এক interface-এ, backtest-এর সুবিধাএকই কোডে নানা ধরনের model
Prophet (1.5.0)trend, বছর ও সপ্তাহের ছন্দ আর ছুটির দিনের যোগফলের modelদ্রুত বোঝা যায় এমন প্রথম model; তবু baseline-এর সঙ্গে backtest জরুরি

Prophet নিয়ে একটি সতর্কতা: এটি সহজ বলে জনপ্রিয়, কিন্তু কয়েকটি প্রকাশিত তুলনায় দেখা গেছে, অনেক ডেটায় এটি seasonal naive বা ETS-এর মতো সরল পদ্ধতিকে নিয়মিত হারায় না। যেকোনো library-র “auto” model-কে নিজের backtest-এ seasonal naive-এর পাশে মেপে তবেই বিশ্বাস করুন।

প্রতিযোগিতা থেকে। Spyros Makridakis-এর M প্রতিযোগিতাগুলো কয়েক দশক ধরে forecasting পদ্ধতির খোলা পরীক্ষা। ২০১৮ সালের M4-এ ছিল এক লাখ series; জয়ী পদ্ধতি ছিল Slawek Smyl-এর exponential smoothing আর recurrent neural network-এর সংকর, আর কয়েকটি পদ্ধতির মিশ্রণ (combination) ভালো করেছিল, যেখানে একা ML পদ্ধতিগুলো সাধারণত পিছিয়ে ছিল বলে আয়োজকেরা লিখেছিলেন। ২০২০ সালের M5-এ একটি বড় খুচরা বিক্রেতার হাজারো পণ্যের দৈনিক বিক্রি, স্তরবিন্যাসসহ; শীর্ষ সমাধানগুলোর বেশিরভাগ LightGBM-ভিত্তিক global model, lag আর ক্যালেন্ডার feature দিয়ে, ঠিক এই পর্বের পথে। M5-এর একটি আলাদা অংশে quantile অনুমানও চাওয়া হয়েছিল। দুটি ফলাফল একসঙ্গে পড়ুন: অনেক সম্পর্কিত series আর বাইরের তথ্য থাকলে global ML model শক্তিশালী; একটি-দুটি ছোট series-এ ক্লাসিক model আর মিশ্রণ এখনো কঠিন প্রতিদ্বন্দ্বী।

আরও গভীরে: Hyndman আর Athanasopoulos-এর বিনামূল্যে পড়া যায় এমন বই Forecasting: Principles and Practice backtesting (“time series cross-validation”), reconciliation আর accuracy-র মাপের মানদণ্ড। Multi-step কৌশলের তুলনার জন্য Ben Taieb ও সহলেখকদের ২০১২ সালের পর্যালোচনা (NN5 প্রতিযোগিতার ডেটায়) দেখুন। Global আর local model-এর তাত্ত্বিক আলোচনা Montero-Manso আর Hyndman-এর ২০২১ সালের প্রবন্ধে (International Journal of Forecasting), আর M4 ও M5-এর ফলাফল ও পদ্ধতির বিবরণ সেই জার্নালেই আয়োজকদের প্রবন্ধে। ছোট time series-এ cross-validation কখন বৈধ, তা নিয়ে Bergmeir, Hyndman আর Koo-র ২০১৮ সালের কাজও প্রাসঙ্গিক।

পরীক্ষা ও ইন্টারভিউতে: (১) Rolling mean-এ leakage কীভাবে ঢোকে? rolling(7).mean()-এর জানালায় আজকের মান থাকে; আগে shift(1), h দিন সামনের অনুমানে shift(h). (২) ৭১৬ দিনে TimeSeriesSplit(n_splits=5, test_size=28, gap=7)-এর শেষ fold? test ৬৮৮–৭১৫, train ০–৬৮০, অর্থাৎ ৬৮১ দিন। (৩) Recursive আর direct-এর পার্থক্য? একটি model নিজের অনুমান ফেরত খায়, ভুল জমে; বনাম প্রতি h-এ আলাদা model, ভুল জমে না কিন্তু model বেশি। (৪) cu = ৮০, co = ২০: কোন quantile? ৮০/(৮০ + ২০) = ০.৮. (৫) Global model কখন বেশি লাভ দেয়? অনেক সম্পর্কিত series, যাদের অনেকের ইতিহাস ছোট।

৮ · AI-এর যুগে — forecasting কোড যাচাই

“আগামী মাসের বিক্রির forecast model বানাও” বললে AI assistant দ্রুত চলনসই কোড দেয়, আর তার score প্রায়ই সুন্দর। সমস্যা হলো, সময়ের ডেটার ভুলগুলো score-কে খারাপ করে না, ভালো করে। নিচের ভুলগুলো বারবার দেখা যায়।

AI প্রায়ই যা করেকেন সমস্যাকীভাবে ধরবেন
train_test_split বা shuffle-সহ KFoldভবিষ্যতের দিন দিয়ে অতীত শেখা; score ফোলানোতারিখ দিয়ে ভাগ; TimeSeriesSplit বা নিজের rolling origin
rolling(7).mean(), shift ছাড়া; বা groupby ছাড়া shiftআজকের মান বা অন্য series-এর মান feature-এএকটি তারিখের সারি হাতে মিলিয়ে দেখুন, স্টেপারের মতো
অনেক series-এর টেবিলে সারির নম্বরে TimeSeriesSplitভাগ হয় জেলা ধরে, সময় ধরে নয়অনন্য তারিখের তালিকায় ভাগ করে সারিতে ফেরানো
Recursive model-এর backtest-এ প্রতিটি h-এ আসল lag“১৪ দিন সামনের” ভুল আসলে আগামীকালের ভুলorigin-এর পরের মান মুছে অনুমান চালান; h ধরে ভুল ছাপুন
শূন্য-ভরা পণ্যে MAPE, বা একটি holdout-এর একটি সংখ্যাঅসীম বা অর্থহীন শতাংশ; ভাগ্যের ওপর নির্ভরMAE বা MASE, সব fold-এর সংখ্যা, seasonal naive-এর পাশে
Quantile interval-কে “৮০% নিশ্চিত” বলা, যাচাই ছাড়াবাস্তবে ৭৩% ধরতে পারে, ঢাকায় আরও কমbacktest-এ coverage, series ধরে ধরে

AI-কে শুধু কোড নয়, সময়ের হিসাবও চাইতে বলুন:

আমার কাছে ছয়টি জেলার দৈনিক অর্ডারের টেবিল; তার কলাম date, district আর orders নামে। আমি HistGradientBoostingRegressor-এর একটি global model দিয়ে আগামী ১৪ দিনের অনুমান চাই। ১. প্রতিটি feature (lag, rolling mean, বার, ঈদের দূরত্ব) কীভাবে বানাবে দেখাও, আর প্রতিটির পাশে লেখো origin-এ সেটি কেন জানা। groupby আর shift কোথায় লাগবে, বলো। ২. অনন্য তারিখের ওপর rolling-origin backtest লেখো (২৮ দিনের ৫টি fold), TimeSeriesSplit-এর সঙ্গে মিলিয়ে; gap কত দেবে আর কেন, ব্যাখ্যা করো। ৩. Recursive আর direct দুই পদ্ধতিতে ১ থেকে ১৪ দিনের অনুমান দাও; recursive-এর backtest-এ origin-এর পরের আসল মান যেন কোনোভাবে না ঢোকে। ৪. প্রতিটি fold আর প্রতিটি h-এর MAE seasonal naive-এর পাশে ছাপো, আর loss="quantile" দিয়ে ১০–৯০ band-এর coverage জেলা ধরে। ৫. তুমি যে parameter নাম ব্যবহার করছ, সেগুলো কোন scikit-learn আর pandas version-এর docs অনুযায়ী, লেখো।

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

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

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

প্র ০১M প্রতিযোগিতার ইতিহাস বহু বছর বলেছে “সরল পদ্ধতি জটিলের সমান বা ভালো”, অথচ M5-এ tree-ভিত্তিক ML জিতল। বদলটা কী ছিল, আর এর থেকে নিজের প্রকল্পের জন্য কী শিক্ষা নেবেন?

Spyros Makridakis আর সহকর্মীরা ১৯৮০-এর দশকের শুরুতে প্রথম বড় তুলনা (M-competition) চালান, তারপর M2 আর ২০০০ সালের দিকে প্রকাশিত M3, যেখানে তিন হাজারের মতো series ছিল। এই পর্বগুলোর বহুল উদ্ধৃত সিদ্ধান্ত ছিল: পরিসংখ্যানের দিক থেকে জটিল পদ্ধতি সরল পদ্ধতির চেয়ে নিয়মিত ভালো নয়, আর কয়েকটি পদ্ধতির গড় (combination) প্রায়ই সেরা পদ্ধতির চেয়েও স্থিতিশীল। ২০১৮ সালের M4-এ series এক লাখে ওঠে; আয়োজকদের মূল্যায়নে একা ML পদ্ধতিগুলো সাধারণত পিছিয়ে ছিল, আর জয়ী ছিল exponential smoothing আর neural network-এর একটি সংকর।

২০২০ সালের M5-এ ডেটার চরিত্র ছিল আলাদা: একটি খুচরা বিক্রেতার হাজারো পণ্য আর দোকানের দৈনিক বিক্রি, স্তরবিন্যাসসহ, সঙ্গে দাম, প্রচার আর ছুটির দিনের মতো বাইরের তথ্য। অনেক series একই রকম আচরণ করে, আর প্রতিটি series একা ছোট ও শব্দ-ভরা। এই অবস্থায় একটি global tree model সব series থেকে একসঙ্গে শিখতে পারে, আর ক্যালেন্ডার ও দামের মতো feature সহজে নেয়। শীর্ষ সমাধানগুলোর বেশিরভাগ তাই LightGBM-ভিত্তিক ছিল বলে আয়োজকেরা লিখেছেন।

অর্থাৎ বদলটা ছিল ডেটার, পদ্ধতির “জাদুর” নয়। M3-এর series ছিল বছর, ত্রৈমাসিক বা মাসের, ছোট আর একে অপরের সঙ্গে সম্পর্কহীন; সেখানে local ক্লাসিক model শক্তিশালী। M5-এর মতো ডেটায় সম্পর্কিত series আর বাইরের তথ্য বেশি, সেখানে global ML শক্তিশালী। অংশ ৫-এর ফলও তাই: ছোট ইতিহাসের বরিশাল hub-এ global model-এর লাভ সবচেয়ে বেশি।

নিজের প্রকল্পের জন্য শিক্ষা তিনটি। প্রথমে seasonal naive আর একটি ক্লাসিক model দিয়ে baseline বানান, একই backtest-এ। তারপর ডেটার চরিত্র দেখুন: কতগুলো series, কত লম্বা, কতটা সম্পর্কিত, বাইরের তথ্য কতটা। শেষে কয়েকটি পদ্ধতির গড়ও পরীক্ষা করুন, কারণ মিশ্রণ প্রায়ই সস্তা বিমা।

মূল উপলব্ধি: কোনো forecasting পদ্ধতি সব ডেটায় সেরা নয়; প্রতিযোগিতার ফল ডেটার চরিত্রের কথা বলে, আর নিজের ডেটায় backtest-ই শেষ বিচারক।

প্র ০২Google Flu Trends সার্চের ডেটা দিয়ে ফ্লুর প্রকোপ আগেভাগে বলার প্রতিশ্রুতি দিয়েছিল, পরে বড় ভুল করে। Forecasting model-এর মূল্যায়ন আর নজরদারি নিয়ে এই ঘটনা কী শেখায়?

২০০৮ সালে Google-এর গবেষকেরা Nature-এ দেখান, কিছু সার্চ শব্দের হার থেকে যুক্তরাষ্ট্রের ফ্লুর মতো অসুখের প্রকোপ সরকারি রিপোর্টের আগেই অনুমান করা যায়। প্রথম কয়েক বছর মিল ভালো ছিল। কিন্তু ২০১২–১৩ মৌসুমে অনুমান সরকারি হিসাবের প্রায় দ্বিগুণে পৌঁছায়। ২০১৪ সালে Lazer ও সহলেখকেরা Science-এ এর বিশ্লেষণ প্রকাশ করেন, যেখানে কয়েকটি সম্ভাব্য কারণ আসে।

প্রথম কারণ, প্রশিক্ষণের সময় বিপুল সংখ্যক সার্চ শব্দ থেকে বাছাই করা হয়েছিল, আর এমন অনেক শব্দ মৌসুমের সঙ্গে মিলে যায় কিন্তু অসুখের সঙ্গে সম্পর্কহীন; অল্প কয়েকটি মৌসুমে বহু শব্দ যাচাই করলে কাকতালীয় মিল আসে। দ্বিতীয়, সার্চ ইঞ্জিন নিজেই সময়ের সঙ্গে বদলেছে (প্রস্তাবিত সার্চ, ফলাফলের নকশা), ফলে মানুষের সার্চের আচরণ বদলেছে; অর্থাৎ feature-এর অর্থ সময়ের সঙ্গে সরে গেছে। তৃতীয়, model-কে সরকারি সাম্প্রতিক তথ্য দিয়ে নিয়মিত সংশোধন করা হলে ভুল অনেক কম হতো বলে বিশ্লেষকেরা দেখান।

এই পর্বের ভাষায় তিনটি শিক্ষা। এক, একটি holdout-এ ভালো ফল ভবিষ্যতের নিশ্চয়তা নয়; অনেক origin-এর backtest, আর প্রতিটি fold আলাদা করে দেখা দরকার। দুই, lag feature (সাম্প্রতিক আসল মান) অনেক সময় বাইরের চকচকে তথ্যের চেয়ে নির্ভরযোগ্য, আর দুটি মেলালে সবচেয়ে ভালো। তিন, চালু করার পর ভুল h ধরে ধরে আর সময় ধরে নজরে রাখতে হয়, কারণ পৃথিবী বদলায়; বণ্টন বদলানো ধরার কৌশল পর্ব ৫৯ (Distribution shift — covariate, label আর concept shift ধরা ও সামলানো)-এ আসবে।

বাজারঘরের জন্য অনুবাদ: সোশ্যাল মিডিয়ার আলোচনা বা বিজ্ঞাপনের ক্লিক feature হিসেবে লোভনীয়, কিন্তু তাদের অর্থ প্ল্যাটফর্মের নিয়ম বদলালেই বদলায়। এমন feature যোগ করার আগে জিজ্ঞাসা করুন, origin-এ এটি জানা কি না, আর এর সংজ্ঞা স্থির কি না।

মূল উপলব্ধি: Forecasting model ভবিষ্যতে চলে, তাই তার মূল্যায়নও হতে হয় ভবিষ্যতের মতো করে: অনেক origin, সময়ের সঙ্গে স্থির feature, আর চালুর পরে নিয়মিত নজর।

প্র ০৩আবহাওয়ার পূর্বাভাস কয়েক দিনের বেশি নির্ভরযোগ্য হয় না কেন? Edward Lorenz-এর আবিষ্কারের সঙ্গে recursive forecasting-এর ভুল জমার সম্পর্ক কী?

১৯৬০-এর দশকের শুরুতে MIT-এর আবহবিজ্ঞানী Edward Lorenz একটি ছোট আবহাওয়া model কম্পিউটারে চালাচ্ছিলেন। বহুল প্রচলিত গল্প অনুযায়ী, একটি run আবার চালাতে তিনি মাঝপথের একটি সংখ্যা ছাপা কাগজ থেকে কম দশমিক ঘর নিয়ে বসান, আর কিছুক্ষণ পরে ফল আগের run থেকে পুরোপুরি আলাদা হয়ে যায়। ১৯৬৩ সালে তিনি একটি প্রবন্ধে দেখান, কিছু সরল অ-রৈখিক সমীকরণেও শুরুর সামান্য পার্থক্য সময়ের সঙ্গে দ্রুত বাড়ে। এটিই পরে “chaos” আর “butterfly effect” নামে পরিচিত হয়।

আবহাওয়ার সংখ্যাভিত্তিক model আসলে একটি recursive forecaster: এখনকার অবস্থা থেকে কয়েক মিনিট পরের অবস্থা, তার থেকে আবার পরের, এভাবে কয়েক দিন। শুরুর মাপে ভুল থাকেই, আর প্রতিটি ধাপ সেই ভুল বয়ে নিয়ে যায়, বিশৃঙ্খল ব্যবস্থায় বাড়িয়েও দেয়। স্টেপারের AR(১) model শান্ত: সেখানে পুরোনো ভুল প্রতি ধাপে ৮০%-এ নামে, তাই ভুলের SD একটি সীমায় (১৬.৬৭) থামে। বিশৃঙ্খল ব্যবস্থায় গুণক ১-এর বেশি হতে পারে, তখন ভুল বেড়েই চলে।

আবহাওয়া দপ্তরগুলো এর জবাব দেয় ensemble দিয়ে: শুরুর অবস্থা সামান্য বদলে একই model বহুবার চালানো, তারপর ফলের ছড়ানো থেকে অনিশ্চয়তা পড়া। দূরের দিনে ছড়ানো বেশি, তাই পূর্বাভাস “বৃষ্টির সম্ভাবনা ৬০%” রূপে আসে। ML forecasting-এর quantile বা interval-ও একই কথা বলে: h বাড়লে band চওড়া হওয়া উচিত, আর না হলে সেটি সন্দেহের কারণ।

এই পর্বের direct পদ্ধতি নিজের অনুমান ফেরত না খেয়ে model-এর ভুল জমা এড়ায়, কিন্তু ডেটার নিজের অনিশ্চয়তা এড়াতে পারে না: ব্লক ৩-এ direct-এর ভুলও ১৭.৩ থেকে ২৬.০-তে উঠেছে। কোনো পদ্ধতি দূরের দিনকে কাছের দিনের মতো নিশ্চিত করতে পারে না।

মূল উপলব্ধি: Horizon বাড়লে ভুল বাড়া একটি মৌলিক সীমা, কোডের ত্রুটি নয়; ভালো forecast সেই বাড়তি অনিশ্চয়তাকে সৎভাবে চওড়া interval-এ দেখায়।

প্র ০৪বাজারঘর ১২টি নতুন উপজেলা hub খুলছে, প্রতিটির ইতিহাস কয়েক সপ্তাহ। আপনি global model নেবেন না প্রতিটির local model? নকশা, ঝুঁকি আর ন্যায্যতার দিক থেকে যুক্তি দিন।

কয়েক সপ্তাহের ইতিহাসে local model প্রায় কিছুই শিখতে পারে না: একটি ঈদও দেখেনি, শুক্রবারের ধরন কয়েকটি মাত্র বিন্দু থেকে অনুমান করতে হয়। Global model পুরোনো জেলাগুলো থেকে এসব ধরন শিখে আনে, ঠিক যেমন অংশ ৫-এ ৯০ দিনের বরিশাল hub-এ global model-এর MAE ৭.৫, local-এর ১০.৫. তাই শুরুতে global model স্বাভাবিক পছন্দ।

নকশায় কয়েকটি জিনিস জরুরি। এক, নতুন hub-এর পরিচয় কলাম (category) শুরুতে প্রায় ফাঁকা; তাই hub-এর বৈশিষ্ট্য দিয়ে বর্ণনা করুন: জনসংখ্যার আকার, শহর না গ্রাম, ঢাকা থেকে দূরত্ব, প্রথম সপ্তাহের গড় অর্ডার। এতে model নতুন hub-কে মিলে যাওয়া পুরোনো জেলার কাছে বসাতে পারে। দুই, স্তর আলাদা হলে লক্ষ্যকে সাম্প্রতিক গড় দিয়ে ভাগ করে শেখানো যায়, তাহলে ঢাকার বড় সংখ্যা loss-এ আধিপত্য করে না। তিন, নতুন hub-এর জন্য আলাদা backtest দরকার: পুরোনো জেলার কোনো একটিকে “নতুন” ভান করে তার প্রথম কয়েক সপ্তাহ বাদে বাকি ইতিহাস লুকিয়ে মাপা।

ঝুঁকিও আছে। নতুন উপজেলার আচরণ জেলা শহরের মতো না-ও হতে পারে; global model তখন আত্মবিশ্বাসী ভুল করে। তাই নতুন hub-এর interval চওড়া রাখা আর প্রথম কয়েক সপ্তাহ মজুদে বাড়তি সতর্কতা ভালো। ইতিহাস জমলে local সংশোধন (যেমন hub-এর নিজস্ব residual-এর গড় যোগ) বা দুটির মিশ্রণ পরীক্ষা করা যায়।

ন্যায্যতার দিকটিও ভাবুন। মোট MAE কমানো লক্ষ্য হলে model বড় জেলার ভুল কমাতে বেশি মন দেয়, আর ছোট উপজেলার ক্রেতা বারবার “স্টক নেই” দেখেন। তাই series ধরে ধরে ভুল আর coverage রিপোর্ট করুন, আর প্রয়োজনে ওজন বা আলাদা লক্ষ্য দিন।

মূল উপলব্ধি: ছোট ইতিহাসে global model ধার করা জ্ঞান দেয়, কিন্তু নতুন series-কে বর্ণনা করার feature, আলাদা backtest আর series ধরে ভুলের রিপোর্ট ছাড়া সেই ধার ঝুঁকিপূর্ণ।

প্র ০৫“কত মাল রাখব” প্রশ্নের গণিত শত বছরের পুরোনো। Newsvendor-এর হিসাব থেকে M5-এর quantile অংশ পর্যন্ত: গড়ের forecast কেন মজুদের জন্য যথেষ্ট নয়?

অনিশ্চিত চাহিদায় কতটা মজুদ রাখা উচিত, এই প্রশ্নের গাণিতিক রূপ সাধারণত উনিশ শতকের শেষে Francis Edgeworth-এর ব্যাংকের নগদ জমা নিয়ে লেখার সঙ্গে যুক্ত করা হয়, আর ১৯৫০-এর দশকের শুরুতে Arrow, Harris আর Marschak-এর মজুদ-নীতির কাজে এটি আধুনিক রূপ পায়। “Newsvendor” নামটি এসেছে খবরের কাগজের হকারের উদাহরণ থেকে: সকালে কত কাগজ কিনবেন, যখন বিকেলে না-বিক্রি কাগজ মূল্যহীন।

উত্তরটি সুন্দর। এক ইউনিট বাড়তি রাখলে প্রত্যাশিত ক্ষতি co × P(চাহিদা < মজুদ), আর না রাখলে cu × P(চাহিদা > মজুদ); দুটি সমান হলে আর বাড়ানো লাভজনক নয়, তাই P(চাহিদা ≤ মজুদ) = cu/(cu + co) হওয়া চাই। অর্থাৎ সেরা মজুদ চাহিদার বণ্টনের একটি quantile-এর মান। গড় তখনই সঠিক, যখন বণ্টন প্রতিসম আর দুই দিকের দাম সমান; বাস্তবে প্রায় কখনো নয়। ঢাকার শেষ দিনের উদাহরণে median ৩০৭, আসল ৩২৮; ৮০তম percentile রাখলে কম পড়ার সম্ভাবনা কমত।

এ কারণেই pinball loss গুরুত্বপূর্ণ: τ-quantile শেখার loss ঠিক newsvendor-এর খরচের কাঠামো, τ = cu/(cu + co) ধরলে। M5-এর একটি আলাদা অংশে অংশগ্রহণকারীদের কয়েকটি quantile জমা দিতে হয়েছিল, আর pinball loss-ভিত্তিক মাপে বিচার হয়েছিল। পুরো বণ্টন অনুমানের এই ধারা এখন forecasting-এর মূলধারায়।

দুটি বাস্তব সতর্কতা। এক, quantile model-এর band প্রায়ই সরু হয় (ব্লক ৪-এ ৭৩% বনাম লক্ষ্য ৮০%), তাই coverage মাপা আর সংশোধন করা জরুরি। দুই, মজুদের আসল প্রশ্ন একাধিক দিনের: মাল আসতে সময় লাগে, তাই দরকার lead time-এর মোট চাহিদার quantile, যা দৈনিক quantile যোগ করে পাওয়া যায় না।

মূল উপলব্ধি: মজুদের সিদ্ধান্ত একটি quantile-এর প্রশ্ন, আর কোন quantile তা ঠিক করে দুই ধরনের ভুলের দামের অনুপাত; গড়ের forecast সেই প্রশ্নের উত্তর দেয় না।

অনুশীলন

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

  1. পূর্ণ numerical: একটি hub-এর ১৫ দিনের অর্ডার (দিন ১ থেকে ১৫): ৪০, ৪২, ৩৮, ৫৫, ৪৫, ৪১, ৩৯, ৪৪, ৪৩, ৪০, ৬০, ৪৮, ৪২, ৪১, ?. দিন ১৫-এর অনুমান দিন ১৪-এর রাতে। (ক) lag1, lag7, lag14 আর shift করা roll7 কত? (খ) Seasonal naive অনুমান কত? (গ) দিন ১৫-এর আসল অর্ডার ৫০; একটি quantile model-এর q০.৮ = ৫৪ হলে pinball loss কত? (ঘ) কম পড়ার দাম ৳৬০, বেশি থাকার দাম ৳২০ হলে কোন quantile মজুদ করবেন?

    (ক) lag1 = দিন ১৪ = ৪১; lag7 = দিন ৮ = ৪৪; lag14 = দিন ১ = ৪০. roll7 = দিন ৮–১৪-এর গড় = (৪৪ + ৪৩ + ৪০ + ৬০ + ৪৮ + ৪২ + ৪১)/৭ = ৩১৮/৭ = ৪৫.৪৩. (খ) Seasonal naive = গত সপ্তাহের একই দিন = lag7 = ৪৪. (গ) r = ৫০ − ৫৪ = −৪ < ০, তাই loss = (τ − ১)·r = (−০.২)(−৪) = ০.৮. বেশি বলার দাম কম, কারণ τ = ০.৮-এ এই মানের ওপরে মাত্র ২০% পড়ার কথা। (ঘ) τ = ৬০/(৬০ + ২০) = ০.৭৫, অর্থাৎ ৭৫তম percentile.

  2. সমালোচনা ও নকশা: একজন বিশ্লেষক লিখেছেন: “৫০টি পণ্যের দৈনিক বিক্রি একটি টেবিলে নিয়েছি। df["lag1"] = df["sales"].shift(1) আর df["avg7"] = df["sales"].rolling(7).mean() বানিয়ে StandardScaler পুরো টেবিলে fit করেছি, তারপর KFold(5, shuffle=True)-এ ৪% MAPE পেয়েছি। পরের মাসের প্রতিদিনের forecast এই model-কে বারবার আগামীকাল জিজ্ঞাসা করে বানাব।” কী কী সমস্যা, আর আপনি কীভাবে নকশা করবেন?

    সমস্যা: (১) groupby ছাড়া shift: প্রতিটি পণ্যের প্রথম সারিতে আগের পণ্যের শেষ বিক্রি। (২) rolling(7) shift ছাড়া: আজকের বিক্রি feature-এ, স্পষ্ট leakage. (৩) Shuffle-সহ KFold: ভবিষ্যতের দিন দিয়ে অতীত শেখা; scaler-ও পুরো ডেটায় fit (Tree model-এ scaler লাগেই না)। (৪) MAPE: কম বিক্রির বা শূন্য-বিক্রির দিনে অর্থহীন। (৫) “আগামীকাল বারবার” মানে recursive, কিন্তু মূল্যায়ন হয়েছে শুধু এক ধাপে, তাই ৩০ দিন সামনের ভুল অজানা। নকশা: groupby("product") দিয়ে lag আর shift(1)-এর পরে rolling; অনন্য তারিখের ওপর rolling-origin backtest (২৮ দিনের কয়েকটি fold); recursive অনুমানে origin-এর পরের আসল মান মুছে ১ থেকে ৩০ দিনের ভুল h ধরে, বা direct model; MAE বা MASE, seasonal naive-এর পাশে, পণ্য ধরে ধরে; মজুদের জন্য quantile model আর তার coverage.

  3. আউটপুট অনুমান করুন: চালানোর আগে টেবিলটি খাতায় লিখুন, বিশেষ করে সারি ৪-এর দুটি lag আর roll2-এর NaN কোথায়।
    import pandas as pd
    df = pd.DataFrame({"d": ["A"] * 4 + ["B"] * 3, "y": [10, 12, 14, 20, 5, 7, 9]})
    g = df.groupby("d")["y"]
    df["lag1"] = g.shift(1)
    df["bad_lag1"] = df["y"].shift(1)
    df["roll2"] = g.transform(lambda s: s.shift(1).rolling(2).mean())
    print(df)

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

       d   y  lag1  bad_lag1  roll2
    0  A  10   NaN       NaN    NaN
    1  A  12  10.0      10.0    NaN
    2  A  14  12.0      12.0   11.0
    3  A  20  14.0      14.0   13.0
    4  B   5   NaN      20.0    NaN
    5  B   7   5.0       5.0    NaN
    6  B   9   7.0       7.0    6.0

    lag1 প্রতিটি দলের ভেতরে সরে, তাই B-এর প্রথম সারিতে NaN. bad_lag1 পুরো কলাম সরায়, তাই B-এর প্রথম সারিতে A-এর শেষ মান ২০: অন্য series-এর তথ্য। roll2 আগে shift করে তারপর দুটির গড়, তাই প্রতিটি দলের প্রথম দুই সারিতে NaN; A-এর সারি ৩-এ (১২ + ১৪)/২ = ১৩, B-এর সারি ৬-এ (৫ + ৭)/২ = ৬. কোনো সারিতে নিজের y গড়ে ঢোকেনি।

  4. কোডিং: Croston-এর পদ্ধতি নিজে লিখুন: একটি function croston(y, alpha), যা প্রতিটি দিনের জন্য সেদিনের আগে জানা দৈনিক হার z/p আর শেষে পরের দিনের হার ফেরত দেয়। ১২ দিনের বিক্রি ০, ০, ৬, ০, ০, ০, ৪, ০, ৩, ০, ০, ৫ আর α = ০.৫-এ চালিয়ে সাধারণ গড়ের সঙ্গে তুলনা করুন।
    import numpy as np
    
    def croston(y, alpha=0.5):
        z = p = None
        q, out = 1, []
        for v in y:
            out.append(np.nan if z is None else z / p)   # আজকের আগে জানা হার
            if v > 0:
                if z is None:
                    z, p = v, q
                else:
                    z, p = z + alpha * (v - z), p + alpha * (q - p)
                q = 1
            else:
                q += 1
        return np.array(out), z / p
    
    y = np.array([0, 0, 6, 0, 0, 0, 4, 0, 3, 0, 0, 5])
    path, nxt = croston(y)
    print(np.round(path, 2))
    print("next-day rate:", round(nxt, 3), "| plain mean:", round(y.mean(), 3))

    ডেস্কটপে সত্যিকারের run (Python 3.14, NumPy 2.5):

    [ nan  nan  nan 2.   2.   2.   2.   1.43 1.43 1.45 1.45 1.45]
    next-day rate: 1.565 | plain mean: 1.5

    প্রথম বিক্রি (৬) আসে তৃতীয় দিনে, তাই শুরুতে z = ৬, p = ৩, হার ২. সপ্তম দিনে বিক্রি ৪, ব্যবধান ৪: z = ৬ + ০.৫(৪ − ৬) = ৫, p = ৩ + ০.৫(৪ − ৩) = ৩.৫, হার ১.৪৩. নবম দিনে ৩, ব্যবধান ২: z = ৪, p = ২.৭৫, হার ১.৪৫. শেষ দিনে ৫, ব্যবধান ৩: z = ৪.৫, p = ২.৮৭৫, হার ১.৫৬৫. বিক্রিহীন দিনে হার বদলায় না, এটিই Croston-এর বৈশিষ্ট্য। সাধারণ গড় ১.৫, কাছাকাছি, কিন্তু Croston সাম্প্রতিক বিক্রিকে বেশি ওজন দেয়।

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

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

ক্রমএকটি fold-এ rolling-origin backtest-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অনন্য তারিখের তালিকায় origin ঠিক করা: তার আগে train, পরে test
  2. শুধু origin পর্যন্ত জানা তথ্য দিয়ে lag আর rolling feature বানানো
  3. Train অংশে model শেখানো
  4. Test অংশের প্রতিটি দিনের অনুমান আর seasonal naive-এর অনুমান
  5. এই fold-এর MAE লিখে origin সামনে সরানো

আগামীকালের দৈনিক অর্ডারের অনুমানের জন্য নিচের কোন feature-এ leakage আছে? (s = একটি জেলার অর্ডারের pandas Series)

  • s.shift(7)
  • s.shift(1).rolling(7).mean()
  • s.rolling(7).mean()
  • তারিখের বার (day_of_week)

৭১৬টি তারিখে TimeSeriesSplit(n_splits=5, test_size=28, gap=7) চালালে শেষ fold-এর train অংশে কতটি তারিখ থাকে?

  • ৬৮৮
  • ৬৮১
  • ৫৭৬
  • ৩৬৫

একটি পণ্য কম পড়লে প্রতি ইউনিটে ক্ষতি ৳৮০, বেশি থাকলে ৳২০. Newsvendor-এর হিসাবে চাহিদার কোন quantile পরিমাণ মজুদ রাখা উচিত?

  • ০.৫
  • ০.২
  • ০.২৫
  • ০.৮

Model ŷt+1 = ০.৮·yt + ২০, প্রতিদিনের নিজস্ব ভুলের SD ১০, আর model নিজে নির্ভুল। Recursive পদ্ধতিতে দুই দিন সামনের (h = ২) অনুমানের ভুলের SD কত?

  • প্রায় ১২.৮
  • ১০
  • ২০
  • ১৬.৬৭

একটি quantile model-এর ১০–৯০ band আট সপ্তাহের backtest-এ ৭৩% দিন আসল মান ধরেছে; ১০% দিনে মান band-এর নিচে, ১৭% দিনে ওপরে। কোন সিদ্ধান্তটি সবচেয়ে ঠিক?

  • Band ঠিক আছে, কারণ ৭৩% আর ৮০% কাছাকাছি
  • নিচের দিকের quantile খুব উঁচু, তাই q০.১ কমাতে হবে
  • ওপরের লেজ ছোট করে ধরা হয়েছে; q০.৯ বাড়ানো বা সংশোধন দরকার
  • Quantile loss দিয়ে শেখা band-এর coverage গাণিতিকভাবে নিশ্চিত, তাই ডেটায় ভুল আছে

ছয়টি জেলার দৈনিক অর্ডার, যার একটির ইতিহাস মাত্র ৯০ দিন। সব জেলার একটি global model (জেলা একটি category feature) আর প্রতিটির আলাদা local model তুলনা করলে কোথায় global model-এর সুবিধা সবচেয়ে বেশি আশা করবেন?

  • সবচেয়ে বড় জেলায়, কারণ সেখানে অর্ডার বেশি
  • ৯০ দিনের ইতিহাসের জেলায়
  • সব জেলায় সমান
  • কোথাও নয়; global model সবসময় local-এর চেয়ে খারাপ

শব্দকোষ

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

মনে রাখুন

  1. টেবিলে রূপান্তর: প্রতিটি (তারিখ, series) একটি সারি; কলামে শুধু origin-এ জানা তথ্য: lag, shift করা rolling, ক্যালেন্ডার আর আগে জানা বাইরের তথ্য।
  2. Leakage-এর নিয়ম: groupby করে shift, আগে shift পরে rolling, h দিন সামনে হলে shift ≥ h. অবিশ্বাস্য ভালো score মানে আগে leakage খোঁজা।
  3. Backtest: অনন্য তারিখের ওপর কয়েকটি origin; expanding বা sliding (max_train_size); লক্ষ্য origin থেকে দূরে হলে gap. সব fold-এর সংখ্যা seasonal naive-এর পাশে।
  4. Multi-step: recursive একটি model, ভুল জমে; direct প্রতি h-এ একটি model. দুটিতেই h বাড়লে ভুল বাড়ে; backtest-এ origin-এর পরের আসল মান মুছে মাপুন।
  5. Global আর quantile: অনেক সম্পর্কিত series আর ছোট ইতিহাসে global model; মজুদের জন্য τ = cu/(cu + co) quantile, আর তার coverage backtest-এ মাপা।

পরের পর্বে: সময়ের ধারা থেকে এবার ক্রেতা আর পণ্যের সম্পর্কে। পর্ব ৫৩ (Recommender system — collaborative filtering আর matrix factorization) দেখাবে, কে কী কিনেছে তার ছড়ানো-ছিটানো টেবিল থেকে কীভাবে পরের পছন্দের অনুমান করা যায়, আর তা কীভাবে সৎভাবে মাপতে হয়।

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

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