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

Deep learning coding ইন্টারভিউ — NumPy আর PyTorch দিয়ে নিজে লেখা

Deep Learning Coding Interview — Backprop, BatchNorm, AdamW, im2col, Attention, KV Cache, BPE, Decoding, Retrieval and DDPM from Scratch in NumPy, plus PyTorch Debugging Rounds

Taught in Bangla, this practice page sets twelve deep learning coding problems in one format, each with clarifying questions, hints, a solution, a self-check and complexity: a 2-layer MLP with gradient check, BatchNorm backward, AdamW, im2col convolution, causal and multi-head attention, a KV cache decode loop, a BPE trainer, sampling, beam search, cosine retrieval and DDPM noising. All NumPy blocks run in the page, and six PyTorch bugs come from real desktop runs.

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

  • NumPy-তে 2-layer MLP-এর forward আর backward লিখে gradient check দিয়ে প্রমাণ করা
  • BatchNorm-এর backward, AdamW, আর convolution-এর loop ও im2col রূপ নিজে লেখা
  • Causal mask-সহ attention, multi-head-এর reshape আর KV cache-এর decode loop লেখা ও যাচাই
  • ছোট BPE trainer, temperature/top-k/top-p, beam search, cosine top-k-এর recall@k আর DDPM-এর forward noising
  • PyTorch-এর ছয়টি চেনা বাগ (zero_grad, train/eval, loss-এর input, dtype, view/permute, leak) লক্ষণ থেকে ধরা, আর কোড লিখতে লিখতে কথা বলা

এই পাতার প্রশ্ন সম্পর্কে: সমস্যাগুলো এই কোর্সের পর্ব ১ থেকে ৮৬-এর বিষয় থেকে সাজানো। কোনোটি কোনো নির্দিষ্ট প্রতিষ্ঠান, বিশ্ববিদ্যালয় বা নিয়োগ পরীক্ষা থেকে নেওয়া নয়। এ ধরনের কাজ ML engineer, deep learning আর NLP/LLM engineer-এর coding দফায় প্রায়ই আসে, তবে আপনার ইন্টারভিউ ভিন্ন হতে পারে; বিশ্ববিদ্যালয়ের ল্যাব পরীক্ষা আর থিসিসের কোডেও একই দক্ষতা লাগে। পাতার ব্লক চলে Python 3.14, NumPy 2.4.6 আর SciPy-তে; প্রথমবার package নামতে কয়েক সেকেন্ড লাগে। ডেস্কটপের run: Python 3.14, torch 2.15.0 (CPU).

নতুন হলে আগে: NumPy-র মৌলিক live coding আছে AI-এর ভিত্তি কোর্সের পর্ব ৭৮-এ, আর ML algorithm (logistic regression, স্থির softmax, k-means, AUC) নিজে লেখা মেশিন লার্নিং কোর্সের পর্ব ৭৬-এ। মৌলিক পরীক্ষার প্রশ্ন: AI-এর ভিত্তি কোর্সের numerical, MCQ ও লিখিত-ভাইভা, আর মেশিন লার্নিং কোর্সের numerical, MCQ ও লিখিত-ভাইভা। এই পাতা সেখান থেকে এক ধাপ ওপরে শুরু করে।

রাজমিস্ত্রির পরীক্ষায় ঠিকাদার শুধু দেয়ালটা দেখেন না; তিনি দেখেন ওলন দড়ি কখন ঝুলিয়েছেন, প্রতিটি সারির পরে লেভেল মেপেছেন কি না। Deep learning-এর coding ইন্টারভিউও তেমন। “NumPy দিয়ে attention লিখুন” শুনে আসল পরীক্ষা হয় তিন জায়গায়: প্রতিটি array-এর shape আপনি মুখে বলতে পারেন কি না, autograd ছাড়া নিজের gradient প্রমাণ করতে পারেন কি না, আর সময় ফুরানোর আগে একটি চলমান, যাচাই করা সমাধানে পৌঁছান কি না। PyTorch-এর দফায় যোগ হয় আরেকটি: চুপচাপ ভুল শেখা একটি training loop দেখে লক্ষণ থেকে কারণ বের করা।

১ · DL coding ইন্টারভিউ কীভাবে চলে — সময় ভাগ, shape বলা আর যাচাইয়ের সিঁড়ি

রিভিশন: পর্ব ১ · deep learning-এর মানচিত্র, পর্ব ৭ · shape আর broadcasting, পর্ব ১৮ · training ডিবাগ, মেশিন লার্নিং কোর্সের পর্ব ৭৬ · সময় ভাগের ছক

DL coding দফা সাধারণত তিন রকমের প্রশ্ন দেয়: একটি layer বা algorithm library ছাড়া লেখা (backprop, attention, BPE), একটি ছোট PyTorch কাজ (training loop, custom module), আর একটি ভাঙা কোড ঠিক করা। প্রতিষ্ঠান ভেদে নিয়ম আলাদা; কেউ NumPy দেন, কেউ PyTorch চালাতে দেন, কেউ শুধু কাগজে লেখান। নিচের ছকটি ৪৫ মিনিটের একটি সাধারণ ভাগ, কোনো নির্দিষ্ট প্রতিষ্ঠানের নিয়ম নয়।

সময়যা করবেনউদাহরণ বাক্য
০–৫ মিনিটShape আর চাহিদা মেলানো“Q, K, V কি (B, T, d)? Causal mask লাগবে? Padding আছে?”
৫–১০সূত্র এক লাইনে, ছোট উদাহরণে ফল বলা“softmax(QKᵀ/√d)V; T = 3-এ প্রথম সারির output হবে V[0].”
১০–৩০সরল সঠিক রূপ, ধাপে ধাপে চালানো“আগে head-ধরে loop, পরে reshape দিয়ে একসাথে।”
৩০–৩৮যাচাই: gradient check, ধীর রূপ, invariant“Finite difference-এর সাথে relative error 10⁻⁷-এর নিচে কি?”
৩৮–৪৫জটিলতা, memory আর বড় স্কেলে কী বদলাবে“T দ্বিগুণ হলে score matrix চার গুণ।”

Live coding-এ ইন্টারভিউয়ার আপনার চিন্তার পথ শোনেন, তাই think-aloud মূল দক্ষতা। DL-এর কোডে প্রতিটি লাইনের পরে shape বলুন: “x (B, T, D), Wq (D, D), তাই x @ Wq (B, T, D); head-এ ভাগ করলে (B, H, T, D/H).” আর time-boxing: ৩০ মিনিটে vectorised রূপ না চললে loop-এর রূপে ফিরুন, যা আপনি নিশ্চিতভাবে ঠিক লিখতে পারেন। যাচাইয়ের সিঁড়ি নিচ থেকে ওপরে: হাতে করা ছোট উদাহরণ, invariant (softmax-এর সারির যোগ ১), ধীর দ্বিতীয় রূপ, gradient check, আর library থাকলে তার সাথে মেলানো।

প্র ১“NumPy দিয়ে causal self-attention লিখুন।” কোড শুরুর আগে কী কী জিজ্ঞেস করবেন? (সহজ)

ভালো উত্তর: চারটি প্রশ্ন। ইনপুট কি আগেই Q, K, V, নাকি x আর projection weight দুটিই দেবেন? Batch আর head আছে কি, মানে shape (T, d), (B, T, d) নাকি (B, H, T, d)? Mask কেবল causal, নাকি padding-ও? আর output-এ শুধু ফল, নাকি attention weight-ও লাগবে? তারপর shape বলুন: “score (T, T), weight-এর প্রতিটি সারির যোগ ১, output (T, d).” শেষে যাচাইয়ের পরিকল্পনা: “প্রথম token শুধু নিজেকে দেখে, তাই output[0] = V[0]; আর শেষ token বদলালে আগের সারি বদলাবে না।”

দুর্বল উত্তর: সঙ্গে সঙ্গে torch.nn.MultiheadAttention লেখা, বা mask-এর দিক (ওপরের ত্রিভুজ নাকি নিচের) না ভেবে লেখা।

প্র ২Autograd নেই। নিজের লেখা backward ঠিক কি না কীভাবে প্রমাণ করবেন? (মাঝারি)

ভালো উত্তর: কেন্দ্রীয় finite difference: প্রতিটি parameter-এর একটি ঘর ±h সরিয়ে loss-এর পরিবর্তন ভাগ 2h, তারপর বিশ্লেষণী gradient-এর সাথে relative error. float64-এ h ≈ 10⁻⁵ নিলে ভালো কোডে error সাধারণত 10⁻⁷-এর নিচে; 10⁻²-এর কাছে এলে প্রায় নিশ্চিত বাগ। Check চালান ছোট network আর ছোট batch-এ (খরচ প্রতি parameter-এ দুটি forward), float64-এ, dropout বন্ধ রেখে (পর্ব ৪, পর্ব ৫)। সাথে আরও দুটি সস্তা test: শুরুর loss ≈ ln C (C class), আর একটি ছোট batch-এ loss প্রায় শূন্যে নামানো (এক batch overfit, পর্ব ১৮)।

দুর্বল উত্তর: “Loss কমছে, তাই gradient ঠিক।” ভুল চিহ্ন বা ভুল scale-এর gradient-এও loss কিছুটা কমতে পারে।

২ · Backprop নিজে — 2-layer MLP, softmax-CE আর gradient check

রিভিশন: পর্ব ৩ · NumPy-তে MLP, পর্ব ৪ · backprop হাতে, পর্ব ৫ · autograd, পর্ব ১৩ · cross-entropy, মেশিন লার্নিং কোর্সের পর্ব ১১ · matrix calculus

সমস্যা ১ · 2-layer MLP-এর forward, backward আর gradient check

প্রশ্ন: “Input (N, D), একটি ReLU hidden layer (H), তারপর C class-এর logits. গড় cross-entropy loss আর চারটি parameter-এর gradient লিখুন, NumPy-তে। তারপর প্রমাণ করুন gradient ঠিক।”

যা জিজ্ঞেস করবেন: Label কি class index? Loss গড়, নাকি যোগ? Bias আছে? Regularization লাগবে?

ইঙ্গিত: Logit-এর সাপেক্ষে CE-এর gradient (softmax − one-hot)/N; তারপর পেছনে: dW2 = aᵀ dz, dh = dz W2ᵀ ⊙ [h > 0], dW1 = Xᵀ dh. Bias-এর gradient সারি ধরে যোগ। স্থির softmax-এর জন্য সারির সর্বোচ্চ বিয়োগ (log-sum-exp; বিস্তারিত মেশিন লার্নিং কোর্সের পর্ব ৭৬-এর সমস্যা ৩-এ)।

পরিকল্পনা জোরে বলুন: “প্রতিটি gradient-এর shape তার parameter-এর সমান হবে: dW1 (D, H), db1 (H,). যাচাই: প্রতিটি ঘরে কেন্দ্রীয় finite difference.”

import numpy as np
rng = np.random.default_rng(0)
N, D, H, C = 6, 4, 5, 3                       # batch, input, hidden, class
X, y = rng.normal(size=(N, D)), rng.integers(0, C, N)
P = {"W1": rng.normal(0, .5, (D, H)), "b1": np.zeros(H),
     "W2": rng.normal(0, .5, (H, C)), "b2": np.zeros(C)}

def loss_and_grads(P):
    h = X @ P["W1"] + P["b1"]; a = np.maximum(h, 0)        # (N, H)
    z = a @ P["W2"] + P["b2"]                               # logits (N, C)
    z = z - z.max(1, keepdims=True)                         # স্থির softmax
    logp = z - np.log(np.exp(z).sum(1, keepdims=True))
    L = -logp[np.arange(N), y].mean()
    dz = np.exp(logp); dz[np.arange(N), y] -= 1; dz /= N    # softmax − one-hot
    dW2, db2 = a.T @ dz, dz.sum(0)
    dh = (dz @ P["W2"].T) * (h > 0)                         # ReLU-এর gate
    return L, {"W1": X.T @ dh, "b1": dh.sum(0), "W2": dW2, "b2": db2}

L, G = loss_and_grads(P)
print(f"loss {L:.4f} | ln(C) = {np.log(C):.4f}")
for k in P:                                  # কেন্দ্রীয় finite difference
    num = np.zeros_like(P[k])
    for i in np.ndindex(P[k].shape):
        old = P[k][i]
        P[k][i] = old + 1e-5; Lp = loss_and_grads(P)[0]
        P[k][i] = old - 1e-5; Lm = loss_and_grads(P)[0]
        P[k][i] = old; num[i] = (Lp - Lm) / 2e-5
    rel = np.abs(num - G[k]).max() / max(np.abs(num).max(), np.abs(G[k]).max())
    print(f"{k}: shape {G[k].shape}, max rel err {rel:.1e}")

সত্যিকারের আউটপুট:

loss 1.3677 | ln(C) = 1.0986
W1: shape (4, 5), max rel err 8.3e-11
b1: shape (5,), max rel err 8.5e-11
W2: shape (5, 3), max rel err 6.7e-11
b2: shape (3,), max rel err 3.6e-11

চারটি parameter-এরই relative error 10⁻¹⁰-এর আশপাশে বা নিচে: বিশ্লেষণী backpropagation আর সংখ্যায় মাপা gradient প্রায় হুবহু এক। শুরুর loss ১.৩৭, ln 3 = ১.১০-এর কাছে; এলোমেলো শুরুতে এমনই হওয়ার কথা (weight-এর মাপ ০.৫ বলে একটু বেশি)। লক্ষ করুন (h > 0): ReLU-এর gradient শুধু যেখানে hidden মান ধনাত্মক। কোনো h ঠিক ০-এর কাছে থাকলে finite difference ভাঁজ পেরিয়ে যেতে পারে, তখন একটি ঘরে বড় error দেখা যায়, যা বাগ নয়।

dz = softmax(z) − one-hot(y), N = 1
ধাপ
মান
  1. সরানো. সর্বোচ্চ ২ বিয়োগ: [0, −1, −2]. Softmax বদলায় না।
  2. exp. [1, 0.3679, 0.1353]; যোগফল 1.5032.
  3. ভাগ. p = [0.6652, 0.2447, 0.0900], যোগফল ১.
  4. Loss. সঠিক class ০, তাই −ln 0.6652 = 0.4076.
  5. Gradient. সঠিক ঘরে p − 1 = −0.3348 (logit বাড়ানোর দিকে টানে), বাকি দুটিতে p নিজেই। যোগফল ০, আর এই dz থেকেই পেছনের সব gradient.
প্র ৩ফলো-আপ: gradient check কোথায় ভুল করে ফেল বা পাস করে? জটিলতা কত? (মাঝারি)

উত্তর: ভুল ফেল: ReLU বা max-এর ভাঁজের কাছে, float32-এ (h ছোট হলে rounding error বড়, তাই float64), আর dropout বা যেকোনো এলোমেলোতা চালু থাকলে, কারণ দুই forward-এ আলাদা mask পড়ে। ভুল পাস: গড়ের বদলে যোগ লিখলে loss আর gradient দুটিই একই ভুলে চলে, check তা ধরে না; তাই loss-এর সংজ্ঞা আলাদাভাবে হাতে মেলান। খরচ: P parameter হলে 2P forward, প্রতিটি O(N·(DH + HC)), তাই শুধু ছোট network-এ। Backprop নিজে এক forward-এর প্রায় সমান খরচে সব gradient দেয়; এটিই reverse mode-এর সুবিধা (পর্ব ৫)।

প্র ৪ওয়ার্ম-আপ: X (N, D), W1 (D, H). dW1-এর shape কী, আর কেন X.T @ dh? (সহজ)

ভালো উত্তর: dW1-এর shape W1-এর সমান, (D, H). h = XW1 হলে প্রতিটি নমুনা n-এর অবদান xnᵀ dhn (একটি (D, H) বাইরের গুণফল), আর সব নমুনার যোগফলই X.T @ dh: (D, N) @ (N, H) = (D, H). Shape মেলানো থেকেই সূত্র অনুমান করা যায়, তবে শেষে gradient check দিয়ে নিশ্চিত হন।

দুর্বল উত্তর: dh @ X লিখে shape error পেয়ে এলোমেলো transpose যোগ করা।

৩ · Training-এর যন্ত্র — BatchNorm-এর backward আর AdamW নিজে

রিভিশন: পর্ব ১৪ · BatchNorm আর LayerNorm, পর্ব ১৫ · AdamW, পর্ব ১০ · train/eval mode, মেশিন লার্নিং কোর্সের পর্ব ১৪ · Adam-এর গণিত

সমস্যা ২ · BatchNorm-এর forward, backward আর running statistics

প্রশ্ন: “(N, F) input-এ training mode-এর BatchNorm লিখুন, γ আর β-সহ; backward লিখে x-এর gradient যাচাই করুন। Eval mode-এর জন্য কী রাখতে হবে?”

যা জিজ্ঞেস করবেন: ε কত? Variance biased (ভাগ N), নাকি unbiased? Running average-এর momentum কত?

ইঙ্গিত: গড় আর variance নিজেরাই x-এর ওপর নির্ভর করে, তাই dx-এ তিনটি পথ। সংক্ষিপ্ত রূপ: dx = (N·dx̂ − Σdx̂ − x̂·Σ(dx̂·x̂)) / (N·√(var + ε)), যেখানে dx̂ = dout·γ. ফলে প্রতিটি কলামে dx-এর যোগফল ০।

import numpy as np
rng = np.random.default_rng(1)
x = rng.normal(5, 3, (8, 3))                  # batch 8, feature 3
gamma, beta, eps = np.array([1., 2., .5]), np.zeros(3), 1e-5

def bn_forward(x, gamma, beta):
    mu, var = x.mean(0), x.var(0)             # batch-এর গড় আর biased variance
    xhat = (x - mu) / np.sqrt(var + eps)
    return gamma * xhat + beta, (xhat, var)

def bn_backward(dout, cache, gamma):
    xhat, var = cache; n = len(xhat)
    dxhat = dout * gamma
    dx = (n * dxhat - dxhat.sum(0) - xhat * (dxhat * xhat).sum(0)) / (n * np.sqrt(var + eps))
    return dx, (dout * xhat).sum(0), dout.sum(0)   # dx, dgamma, dbeta

out, cache = bn_forward(x, gamma, beta)
print("out mean:", out.mean(0).round(6) + 0, "| out std:", out.std(0).round(4))
w = rng.normal(size=out.shape)                # loss = sum(w * out)
dx, dg, db = bn_backward(w, cache, gamma)
f = lambda x: (w * bn_forward(x, gamma, beta)[0]).sum()
E = lambda i: np.eye(x.size)[i].reshape(x.shape) * 1e-5
num = np.array([(f(x + E(i)) - f(x - E(i))) / 2e-5 for i in range(x.size)]).reshape(x.shape)
print("dx max abs err:", f"{np.abs(num - dx).max():.1e}")
print("dx column sums:", dx.sum(0).round(10) + 0)
run_mu, run_var, m = np.zeros(3), np.ones(3), 0.1
for xb in rng.normal(5, 3, (50, 8, 3)):       # running stat: eval mode-এ এগুলোই
    run_mu = (1 - m) * run_mu + m * xb.mean(0)
    run_var = (1 - m) * run_var + m * xb.var(0, ddof=1)
print("running mean:", run_mu.round(2), "| running var:", run_var.round(2))

সত্যিকারের আউটপুট:

out mean: [0. 0. 0.] | out std: [1.  2.  0.5]
dx max abs err: 4.0e-10
dx column sums: [0. 0. 0.]
running mean: [4.93 4.76 4.95] | running var: [9.29 9.26 8.6 ]

Output-এর গড় ০ আর std ঠিক γ = [1, 2, 0.5]: normalize-এর পরে γ মাপ ফেরত দেয়। Finite difference-এর সাথে dx-এর পার্থক্য 10⁻¹⁰-এর ঘরে, আর প্রতিটি কলামে dx-এর যোগফল ০: সব input একসাথে সরালে গড়ও সরে, তাই output বদলায় না। শেষ লাইনে running statistics: ৫০টি batch পরে running গড় সত্যিকারের ৫-এর কাছে আর variance ৯-এর কাছে। PyTorch-ও normalize করে biased variance দিয়ে, কিন্তু running variance রাখে unbiased (ভাগ N − 1), তাই কোডে ddof=1; momentum ০.১ PyTorch-এর default.

প্র ৫ফলো-আপ: eval mode-এ BatchNorm কী বদলায়? batch-এ একটি নমুনা হলে? LayerNorm কোথায় আলাদা? (মাঝারি)

উত্তর: Eval-এ batch-এর গড় নয়, training-এ জমানো running গড় আর variance দিয়ে normalize; তাই একটি নমুনার ফল batch-এর অন্যদের ওপর নির্ভর করে না। Training mode-এ একটি নমুনা হলে variance ০ আর output অর্থহীন; PyTorch তখন error দেয় (অংশ ৮-এর বাগ খ, সত্যিকারের run). LayerNorm গড় নেয় feature-এর ওপর, প্রতিটি নমুনার ভেতরে; তাই batch-এর মাপ বা train/eval-এ আচরণ বদলায় না, আর running stat লাগে না। এজন্যই Transformer-এ LayerNorm (পর্ব ৩৫), CNN-এ BatchNorm বেশি দেখা যায় (পর্ব ১৪)। জটিলতা দুটিরই O(NF).

সমস্যা ৩ · AdamW, bias correction-সহ

প্রশ্ন: “একটি AdamW optimizer class লিখুন: step(w, g) নতুন weight দেয়। Bias correction কেন লাগে, সংখ্যায় দেখান।”

যা জিজ্ঞেস করবেন: Weight decay কি loss-এ যোগ হবে (L2), নাকি আলাদা করে (decoupled)? β, ε-এর মান কি default?

ইঙ্গিত: m আর v শূন্য থেকে শুরু, তাই প্রথম দিকে তারা শূন্যের দিকে ঝোঁকা; (1 − βᵗ) দিয়ে ভাগ করলে সেই ঝোঁক কাটে (bias correction). গণিত মেশিন লার্নিং কোর্সে derive করা; এখানে শুধু কোড আর যাচাই।

import numpy as np

class AdamW:
    def __init__(self, lr=0.1, b1=0.9, b2=0.999, eps=1e-8, wd=0.0, bias_correction=True):
        self.lr, self.b1, self.b2, self.eps, self.wd, self.bc = lr, b1, b2, eps, wd, bias_correction
        self.m = self.v = None; self.t = 0
    def step(self, w, g):
        if self.m is None: self.m, self.v = np.zeros_like(w), np.zeros_like(w)
        self.t += 1
        self.m = self.b1 * self.m + (1 - self.b1) * g           # প্রথম moment
        self.v = self.b2 * self.v + (1 - self.b2) * g * g       # দ্বিতীয় moment
        mh, vh = self.m, self.v
        if self.bc:                                             # শুরুর শূন্য-ঝোঁক সংশোধন
            mh, vh = mh / (1 - self.b1 ** self.t), vh / (1 - self.b2 ** self.t)
        w = w - self.lr * self.wd * w                           # decoupled weight decay
        return w - self.lr * mh / (np.sqrt(vh) + self.eps)

A = np.diag([10.0, 0.1])                     # একদিকে খাড়া, অন্যদিকে প্রায় সমতল
grad = lambda w: A @ w                       # loss = ½ wᵀAw, সর্বনিম্ন (0, 0)-তে
w0 = np.array([1.0, 1.0])
print("first step, with bias correction:", AdamW().step(w0, grad(w0)) - w0)
print("first step, without             :", AdamW(bias_correction=False).step(w0, grad(w0)) - w0)
opt, w, ws = AdamW(lr=0.05), w0.copy(), w0.copy()
for t in range(300):
    w = opt.step(w, grad(w))
    ws = ws - 0.15 * grad(ws)                # SGD: lr 0.2-এর বেশি হলে খাড়া দিকে বিস্ফোরণ
print("after 300 steps, Adam:", np.abs(w).round(4), "| SGD lr=0.15:", np.abs(ws).round(4))

সত্যিকারের আউটপুট:

first step, with bias correction: [-0.1        -0.09999999]
first step, without             : [-0.31622776 -0.31622677]
after 300 steps, Adam: [0. 0.] | SGD lr=0.15: [0.     0.0107]

প্রথম লাইনটিই যাচাই: bias correction-সহ প্রথম ধাপ প্রায় ঠিক −lr = −0.1, দুই দিকেই, যদিও gradient-এর মাপ ১০ আর ০.১, মানে ১০০ গুণ আলাদা। কারণ প্রথম ধাপে m̂/√v̂ = g/|g| = চিহ্ন। Correction ছাড়া ধাপ 0.1 × 0.1/√0.001 ≈ 0.316, তিন গুণের বেশি বড়: শুরুতে অকারণে বড় লাফ। ৩০০ ধাপ পরে Adam দুই দিকেই প্রায় ০-তে, কিন্তু সাধারণ SGD-তে সমতল দিকটি এখনো ০.০১০৭-এ; খাড়া দিকের জন্য lr ছোট রাখতে হয় বলে সমতল দিক ধীরে চলে। এই পার্থক্যই প্রতিটি parameter-এর আলাদা ধাপের সুবিধা (Adam). Weight decay এখানে ০; চালু করলে লাইনটি gradient-এর বাইরে আলাদা করে w ছোট করে, যা AdamW-এর মূল কথা।

প্র ৬ফলো-আপ: Adam-এ L2 আর AdamW-এর decoupled weight decay-এর পার্থক্য কী? Memory কত লাগে? (কঠিন)

উত্তর: L2-তে λw gradient-এ যোগ হয়, তারপর √v̂ দিয়ে ভাগ হয়; ফলে বড় gradient-এর parameter কম decay পায়, যা উদ্দেশ্য নয়। AdamW (Loshchilov আর Hutter) decay-কে আলাদা ধাপ করে: w ← w − lr·λ·w, gradient-এর মাপ যা-ই হোক (পর্ব ১৫)। সাধারণত bias আর norm-এর γ-তে decay দেওয়া হয় না। Memory: প্রতিটি parameter-এ m আর v, তাই weight-এর দ্বিগুণ বাড়তি; float32-এ ১০০ কোটি parameter-এ শুধু optimizer state প্রায় ৮ GB. জটিলতা প্রতি ধাপ O(P).

৪ · Convolution — চারটি loop থেকে im2col

রিভিশন: পর্ব ২০ · filter, stride, padding আর im2col, পর্ব ২১ · CNN-এর স্থাপত্য, পর্ব ১২ · গতি

সমস্যা ৪ · Conv2d: সরল loop আর im2col, stride ও padding-সহ

প্রশ্ন: “Input (N, Cin, H, W), weight (Cout, Cin, k, k), bias, stride s, padding p. Convolution (আসলে cross-correlation, PyTorch যা করে) দুইভাবে লিখুন: সরল loop আর matrix গুণে।”

যা জিজ্ঞেস করবেন: Kernel উল্টাতে হবে কি (গণিতের convolution), নাকি না (DL-এর রীতি)? Padding শূন্য দিয়ে? Output-এর মাপ ভগ্নাংশ হলে নিচে গোল?

ইঙ্গিত: Output-এর মাপ ⌊(H + 2p − k)/s⌋ + 1 (padding, stride). im2col: প্রতিটি জানালাকে এক সারিতে মেলে (Cin·k·k) মাপের matrix বানানো, তারপর একটিমাত্র matrix গুণ। NumPy-র sliding_window_view copy ছাড়াই জানালা দেয়।

import numpy as np
from scipy.signal import correlate
rng = np.random.default_rng(2)
x = rng.normal(size=(2, 3, 7, 7))            # (N, C_in, H, W)
w = rng.normal(size=(4, 3, 3, 3))            # (C_out, C_in, kH, kW)
b = rng.normal(size=4)
pad = lambda x, p: np.pad(x, ((0, 0), (0, 0), (p, p), (p, p)))

def conv_naive(x, w, b, s=1, p=0):
    N, C, H, W = x.shape; F, _, k, _ = w.shape
    xp, Ho, Wo = pad(x, p), (H + 2*p - k) // s + 1, (W + 2*p - k) // s + 1
    y = np.zeros((N, F, Ho, Wo))
    for n in range(N):
        for f in range(F):
            for i in range(Ho):
                for j in range(Wo):
                    y[n, f, i, j] = (xp[n, :, i*s:i*s+k, j*s:j*s+k] * w[f]).sum() + b[f]
    return y

def conv_im2col(x, w, b, s=1, p=0):
    F, C, k, _ = w.shape
    win = np.lib.stride_tricks.sliding_window_view(pad(x, p), (k, k), axis=(2, 3))[:, :, ::s, ::s]
    cols = win.transpose(0, 2, 3, 1, 4, 5)                 # (N, Ho, Wo, C, k, k)
    cols = cols.reshape(*cols.shape[:3], C * k * k)        # প্রতিটি জানালা এক সারি
    return (cols @ w.reshape(F, -1).T + b).transpose(0, 3, 1, 2)   # (N, F, Ho, Wo)

y1, y2 = conv_naive(x, w, b, s=2, p=1), conv_im2col(x, w, b, s=2, p=1)
print("output:", y1.shape, "| naive == im2col:", np.allclose(y1, y2))
ref = correlate(x[0], w[1], mode="valid")[0] + b[1]    # SciPy, stride 1, padding নেই
print("scipy check:", np.allclose(conv_im2col(x, w, b)[0, 1], ref))

সত্যিকারের আউটপুট:

output: (2, 4, 4, 4) | naive == im2col: True
scipy check: True

৭ × ৭ input, k = 3, s = 2, p = 1: (7 + 2 − 3)/2 + 1 = 4, তাই output (2, 4, 4, 4), আর দুই রূপ হুবহু এক। im2col-এর matrix এখানে (2, 4, 4, 27): ১৬টি জানালার প্রতিটি 3 × 3 × 3 = ২৭টি সংখ্যার এক সারি, আর weight (27, 4). SciPy-র correlate-এর সাথেও (stride ১, padding নেই) মিল, তাই kernel উল্টানো হয়নি, যা DL-এর রীতি। সরল রূপে চারটি loop, প্রতিটি ঘরে C·k·k গুণ; im2col একই গুণ করে একটি বড় matrix গুণে, যা BLAS-এ অনেক দ্রুত।

প্র ৭ফলো-আপ: সময় আর memory-র জটিলতা? im2col-এর দাম কোথায়? (মাঝারি)

উত্তর: গুণের সংখ্যা দুই রূপেই N·Cout·Ho·Wo·Cin·k². im2col-এর দাম memory: প্রতিটি input-এর মান প্রায় k²/s² বার copy হয় (3 × 3, stride ১-এ প্রায় ৯ গুণ)। বড় ছবি আর বড় batch-এ তাই library-গুলো টুকরো করে চালায় বা অন্য algorithm বাছে। Parameter গোনা: Cout·Cin·k² + Cout, যা ছবির মাপের ওপর নির্ভর করে না; এটিই weight sharing (পর্ব ২০)। প্রতিটি নতুন 3 × 3 layer (stride ১) receptive field-কে দুই ঘর বাড়ায়।

প্র ৮ওয়ার্ম-আপ: 32 × 32 × 3 ছবি, Conv2d(3, 16, 5), stride ১, padding ০. Output-এর shape আর parameter কত? (সহজ)

ভালো উত্তর: (32 − 5)/1 + 1 = 28, তাই output (16, 28, 28). Parameter 16 × 3 × 5 × 5 + 16 = 1,216. মাপ একই রাখতে চাইলে padding 2 (k = 5-এ (k − 1)/2).

দুর্বল উত্তর: Parameter গুনতে ছবির মাপ (32 × 32) গুণ করা, যেন fully connected layer.

৫ · Attention — causal mask, multi-head-এর reshape আর KV cache

রিভিশন: পর্ব ৩২ · query, key, value, পর্ব ৩৩ · self-attention আর multi-head, পর্ব ৩৬ · নিজে Transformer, পর্ব ৩৭ · KV cache, পর্ব ৮৩ · serving

সমস্যা ৫ · Causal mask-সহ scaled dot-product attention

প্রশ্ন: “Q, K, V (T, d). Scaled dot-product attention লিখুন, causal mask-সহ: token i শুধু 0..i দেখবে। প্রমাণ করুন ভবিষ্যৎ চুইয়ে ঢোকে না।”

ইঙ্গিত: Score = QKᵀ/√d. Mask: j > i ঘরে −∞ (ওপরের ত্রিভুজ, np.triu(..., k=1)), তাহলে exp-এ ০। Batch আর head থাকলেও একই কোড চলে যদি শেষ দুই axis ধরে লেখেন: swapaxes(-1, -2).

import numpy as np
np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(3)
T, d = 4, 8                                    # ৪টি token, প্রতিটি d = 8
Q, K, V = rng.normal(size=(3, T, d))

def softmax(z):
    z = z - z.max(-1, keepdims=True)
    e = np.exp(z)
    return e / e.sum(-1, keepdims=True)

def attention(Q, K, V, causal=True):
    S = Q @ K.swapaxes(-1, -2) / np.sqrt(Q.shape[-1])      # (..., T, T)
    if causal:
        future = np.triu(np.ones(S.shape[-2:], dtype=bool), k=1)   # j > i
        S = np.where(future, -np.inf, S)                   # exp(−inf) = 0
    A = softmax(S)
    return A @ V, A

out, A = attention(Q, K, V)
print("weights (row = query):\n", A)
print("row sums:", A.sum(-1))
print("row 0 output == V[0]:", np.allclose(out[0], V[0]))
K2, V2 = K.copy(), V.copy(); K2[3] += 5; V2[3] -= 5    # শেষ token বদলাই
out2, _ = attention(Q, K2, V2)
print("rows 0-2 unchanged:", np.allclose(out[:3], out2[:3]), "| row 3 changed:", not np.allclose(out[3], out2[3]))
q, k = rng.normal(size=(2, 10000, 64))         # d = 64: scale ছাড়া score অনেক বড়
s = (q * k).sum(-1)
print("score std d=64, unscaled vs /sqrt(d):", round(s.std(), 2), round((s / 8).std(), 2))

সত্যিকারের আউটপুট:

weights (row = query):
 [[1.    0.    0.    0.   ]
 [0.678 0.322 0.    0.   ]
 [0.459 0.226 0.315 0.   ]
 [0.471 0.081 0.239 0.209]]
row sums: [1. 1. 1. 1.]
row 0 output == V[0]: True
rows 0-2 unchanged: True | row 3 changed: True
score std d=64, unscaled vs /sqrt(d): 8.04 1.0

Weight matrix-এর ওপরের ত্রিভুজ পুরো ০, প্রতিটি সারির যোগ ১, আর প্রথম token-এর output ঠিক V[0]. মূল test: শেষ token-এর K আর V বদলালেও প্রথম তিনটি output হুবহু এক, শুধু শেষটি বদলায়; এটিই “ভবিষ্যৎ দেখা যায় না”-এর প্রমাণ। শেষ লাইনে √d কেন: d = 64-এ দুই এলোমেলো vector-এর dot product-এর std প্রায় ৮ (= √64), ভাগ করলে ১। Scale না করলে score বড় হয়, softmax প্রায় one-hot হয়ে যায় আর gradient প্রায় শূন্য (পর্ব ৩৩)।

S (scale করা) = [[1, 0.5, 2] │ [0.2, 0.8, 1] │ [0, 1, 0.5]], V = [[1, 0], [0, 1], [1, 1]]
ধাপ
মান
  1. Mask. T = 3-এ T(T − 1)/2 = ৩টি ঘর −∞: সবই ভবিষ্যতের দিকে।
  2. সারি ০. শুধু নিজের score টিকে থাকে, তাই weight [1, 0, 0] আর output = V[0] = [1, 0].
  3. সারি ১. e0.2 = 1.221, e0.8 = 2.226; ভাগ করে [0.354, 0.646].
  4. সারি ২. e⁰ = 1, e¹ = 2.718, e0.5 = 1.649; যোগ 5.367, তাই [0.186, 0.506, 0.307].
  5. Output. Weight × V: সারি ২ = 0.186·[1, 0] + 0.506·[0, 1] + 0.307·[1, 1] = [0.494, 0.814]. সারি ০-এর মান পরের কোনো token-এ বদলাবে না।
প্র ৯ফলো-আপ: কোনো সারির সব ঘর mask হলে কী হয়? Padding mask কীভাবে যোগ করবেন? (কঠিন)

উত্তর: সব ঘর −∞ হলে সর্বোচ্চও −∞, আর (−∞) − (−∞) = nan: পুরো সারি nan, যা পরের layer-এ ছড়ায়। Causal mask-এ এটি হয় না (নিজের ঘর সবসময় খোলা), কিন্তু left padding আর causal mask একসাথে দিলে padding-এর সারিতে হতে পারে। প্রতিকার: −∞-এর বদলে বড় ঋণাত্মক সসীম সংখ্যা (float16-এ সীমা মনে রেখে), বা ওই সারির output পরে ০ করে loss থেকে বাদ। Padding mask (B, 1, 1, T) shape-এ key-র দিকে বসে, broadcasting-এ সব head আর query-তে ছড়ায়; causal (T, T)-এর সাথে | দিয়ে জোড়া (পর্ব ২৭)।

সমস্যা ৬ · Multi-head attention: split, attention, merge

প্রশ্ন: “x (B, T, D), H head. Multi-head attention লিখুন একটিও loop ছাড়া, তারপর head-ধরে loop-এর সাথে মেলান।”

ইঙ্গিত: (B, T, D) → reshape (B, T, H, D/H) → transpose (B, H, T, D/H). Merge উল্টো ক্রমে। শুধু reshape করে (B, H, T, D/H) বানালে shape ঠিক, কিন্তু সংখ্যাগুলো ভুল জায়গায়।

Multi-head attention-এ shape-এর যাত্রা

Split heads with reshape then transpose; merge with transpose then reshape

Multi-head attention-এর shape x : (B, T, D) x @ Wq : (B, T, D) reshape (B, T, H, d) transpose (B, H, T, d) softmax(QKᵀ/√d) : (B, H, T, T) (A @ V) : (B, H, T, d) → (B, T, H, d) → (B, T, D) d = D / H transpose বাদ দিলে shape ঠিক, সংখ্যা ভুল মাপ T²,প্রতিটি head-এ শেষে @ Wo
ছবি ৯০.১ — Head-এ ভাগ করা আসলে D-কে দুই axis-এ ভাঙা আর head-এর axis-কে T-এর আগে আনা; parameter-এর সংখ্যা H-এর ওপর নির্ভর করে না।
import numpy as np
rng = np.random.default_rng(4)
B, T, D, H = 2, 5, 12, 3                       # batch, token, model dim, head
hd = D // H                                    # প্রতি head-এ 4
x = rng.normal(size=(B, T, D))
Wq, Wk, Wv, Wo = rng.normal(0, D ** -0.5, (4, D, D))

def softmax(z):
    e = np.exp(z - z.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True)
def split(t):                                  # (B, T, D) -> (B, H, T, hd)
    return t.reshape(B, T, H, hd).transpose(0, 2, 1, 3)
def mha(x):
    q, k, v = split(x @ Wq), split(x @ Wk), split(x @ Wv)
    A = softmax(q @ k.transpose(0, 1, 3, 2) / np.sqrt(hd))   # (B, H, T, T)
    o = (A @ v).transpose(0, 2, 1, 3).reshape(B, T, D)       # head-গুলো আবার জোড়া
    return o @ Wo

def mha_loop(x):                               # ধীর কিন্তু স্পষ্ট: প্রতিটি head আলাদা
    heads = []
    for h in range(H):
        sl = slice(h * hd, (h + 1) * hd)
        q, k, v = x @ Wq[:, sl], x @ Wk[:, sl], x @ Wv[:, sl]
        heads.append(softmax(q @ k.transpose(0, 2, 1) / np.sqrt(hd)) @ v)
    return np.concatenate(heads, -1) @ Wo

wrong = (x @ Wq).reshape(B, H, T, hd)          # বাগ: transpose ছাড়া reshape
print("split shape:", split(x @ Wq).shape, "| vectorised == loop:", np.allclose(mha(x), mha_loop(x)))
print("reshape without transpose gives same heads:", np.allclose(wrong, split(x @ Wq)))
print("params (no bias): 4 x D x D =", 4 * D * D, "for any H")

সত্যিকারের আউটপুট:

split shape: (2, 3, 5, 4) | vectorised == loop: True
reshape without transpose gives same heads: False
params (no bias): 4 x D x D = 576 for any H

Vectorised রূপ আর head-ধরে loop হুবহু এক। দ্বিতীয় লাইনটি ইন্টারভিউর প্রিয় ফাঁদ: reshape(B, H, T, hd)-ও (2, 3, 5, 4) shape দেয়, কোনো error নেই, কিন্তু head-এর সংখ্যাগুলো ভিন্ন token থেকে মিশে আসে; তাই False. Parameter ৪ × D × D = ৫৭৬, H যা-ই হোক: head বাড়ালে প্রতিটি head ছোট হয়, মোট হিসাব একই।

সমস্যা ৭ · KV cache দিয়ে decode loop

প্রশ্ন: “একটি decoder-এ token একটি একটি করে তৈরি হয়। প্রতি ধাপে পুরো prefix আবার না চালিয়ে KV cache দিয়ে নতুন token-এর attention output বের করুন, আর প্রমাণ করুন ফল একই।”

ইঙ্গিত: আগের token-এর K আর V বদলায় না (causal), তাই একবার হিসাব করে রেখে দিন; প্রতি ধাপে শুধু নতুন token-এর q, k, v. নতুন q পুরো cache-এর সাথে attention করে, mask লাগে না কারণ cache-এ ভবিষ্যৎ নেই।

import numpy as np
rng = np.random.default_rng(5)
d, steps = 8, 6
Wq, Wk, Wv = rng.normal(0, d ** -0.5, (3, d, d))
emb = rng.normal(size=(steps, d))              # ধরি প্রতিটি নতুন token-এর embedding জানা

def softmax(z):
    e = np.exp(z - z.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True)

def full_recompute(x):                         # প্রতি ধাপে পুরো prefix আবার
    q, k, v = x @ Wq, x @ Wk, x @ Wv
    S = q @ k.T / np.sqrt(d)
    S[np.triu(np.ones(S.shape, dtype=bool), 1)] = -np.inf
    return (softmax(S) @ v)[-1]                # শুধু শেষ token-এর output লাগে

K_cache, V_cache = np.empty((0, d)), np.empty((0, d))
kv_rows = full_rows = 0; ok = []
for t in range(steps):
    x_t = emb[t:t + 1]                         # (1, d): শুধু নতুন token
    K_cache = np.vstack([K_cache, x_t @ Wk])   # আগের K, V আবার হিসাব হয় না
    V_cache = np.vstack([V_cache, x_t @ Wv])
    out = softmax((x_t @ Wq) @ K_cache.T / np.sqrt(d)) @ V_cache   # (1, t+1) weight
    kv_rows += 1; full_rows += t + 1
    ok.append(np.allclose(out[0], full_recompute(emb[:t + 1])))
print("cached == full recompute at every step:", all(ok))
print("K/V projections, cached vs full:", kv_rows, "vs", full_rows)
L, layers, heads, hd = 4096, 24, 16, 64        # উদাহরণ: fp16-এ এক sequence-এর cache
print("KV cache MB:", 2 * layers * heads * hd * L * 2 / 2**20)

সত্যিকারের আউটপুট:

cached == full recompute at every step: True
K/V projections, cached vs full: 6 vs 21
KV cache MB: 384.0

ছয়টি ধাপের প্রতিটিতে cache-এর ফল পুরো হিসাবের সমান। K/V projection ৬ বনাম ২১ (= 1 + 2 + … + 6): cache ছাড়া কাজ বাড়ে T²-এর হারে, cache-এ T-এর হারে। দাম memory: শেষ লাইনের উদাহরণ-shape-এ (২৪ layer, ১৬ head, head-এর মাপ ৬৪, fp16) ৪০৯৬ token-এর একটি sequence-এর cache ৩৮৪ MB (এখানে 1 MB = 2²⁰ byte), সূত্র 2 × layer × head × d × T × byte (পর্ব ৩৭)।

প্র ১০ফলো-আপ: attention-এর জটিলতা কত? KV cache কোথায় সাহায্য করে না, আর memory কীভাবে কমে? (কঠিন)

উত্তর: Self-attention-এর সময় O(T²·d), score-এর memory O(T²) প্রতি head; T দ্বিগুণ হলে চার গুণ। KV cache decode-এর পুনরাবৃত্তি কাটে, কিন্তু প্রথম prompt-টি (prefill) পুরো O(T²)-ই থাকে, আর cache-এর memory T, batch আর layer-এর সাথে রৈখিক বাড়ে, যা অনেক ব্যবহারকারী একসাথে হলে সীমা হয়ে দাঁড়ায়। কমানোর পথ: কয়েকটি query head মিলে একটি K/V ভাগ করা (MQA, GQA), cache quantize করা, আর পাতা-ভিত্তিক বরাদ্দে অপচয় কমানো (পর্ব ৩৭, পর্ব ৮৩)। FlashAttention গণিত বদলায় না, memory-র আনা-নেওয়া কমায়।

৬ · Tokenizer আর decoding — BPE, top-k/top-p আর beam search

রিভিশন: পর্ব ৪০ · BPE আর বাংলা, পর্ব ৫০ · decoding, পর্ব ৪৬ · language model, পর্ব ৫২ · logits থেকে token

সমস্যা ৮ · ছোট BPE trainer আর encoder

প্রশ্ন: “শব্দ আর তার গণনা দেওয়া। BPE-এর প্রথম ছয়টি merge শিখুন, তারপর নতুন শব্দ encode করুন।”

যা জিজ্ঞেস করবেন: শুরু অক্ষর থেকে, নাকি byte থেকে? শব্দের শেষ চিহ্ন (</w>) লাগবে? সমান গণনায় কোন জোড়া আগে?

ইঙ্গিত: প্রতি ধাপে পাশাপাশি জোড়া গুনুন (শব্দের গণনা দিয়ে গুণ), সবচেয়ে বেশিটি জুড়ে দিন, সব শব্দে প্রয়োগ করুন। Encode-এ শেখা merge একই ক্রমে।

from collections import Counter
corpus = {"দাম": 6, "দামি": 4, "কম": 5, "কমদামি": 3, "ভালো": 4}   # শব্দ: কতবার
words = {tuple(w) + ("</w>",): c for w, c in corpus.items()}       # অক্ষরে ভাঙা

def pair_counts(words):
    pc = Counter()
    for sym, c in words.items():
        for a, b in zip(sym, sym[1:]):
            pc[a, b] += c
    return pc

def merge(sym, pair):                          # বাঁ থেকে ডানে, একটির ওপর আরেকটি নয়
    out, i = [], 0
    while i < len(sym):
        hit = i + 1 < len(sym) and (sym[i], sym[i + 1]) == pair
        out.append(sym[i] + sym[i + 1] if hit else sym[i]); i += 2 if hit else 1
    return tuple(out)

merges = []
for step in range(6):
    pc = pair_counts(words)
    best = max(pc, key=pc.get)                 # সমান হলে আগে পাওয়া জোড়া (dict-এর ক্রম)
    merges.append(best)
    words = {merge(s, best): c for s, c in words.items()}
    print(f"merge {step + 1}: {best[0]} + {best[1]} (count {pc[best]})")
def encode(word):                    # নতুন শব্দে merge একই ক্রমে
    sym = tuple(word) + ("</w>",)
    for p in merges: sym = merge(sym, p)
    return sym
print("কমদাম ->", encode("কমদাম"), "| ভালোদাম ->", encode("ভালোদাম"))

সত্যিকারের আউটপুট:

merge 1: দ + া (count 13)
merge 2: দা + ম (count 13)
merge 3: ক + ম (count 8)
merge 4: দাম + ি (count 7)
merge 5: দামি + </w> (count 7)
merge 6: দাম + </w> (count 6)
কমদাম -> ('কম', 'দাম</w>') | ভালোদাম -> ('ভ', 'া', 'ল', 'ো', 'দাম</w>')

প্রথম merge-এ “দ + া” আর “া + ম” দুটিরই গণনা ১৩ (দাম ৬, দামি ৪, কমদামি ৩); max প্রথমটি নেয়, তাই নিয়মটি আগে ঠিক করে বলুন। ছয় merge-এ “দাম” আর “কম” পুরো token হয়েছে, তাই অচেনা শব্দ “কমদাম” দুটি token-এ ভাঙে। কিন্তু “ভালো” একবারও merge পায়নি, তাই “ভালোদাম”-এ ভ, া, ল, ো আলাদা: কারচিহ্ন একা একটি token, যা বাংলায় token-এর সংখ্যা বাড়ায় (পর্ব ৪০)।

শুরুতে ৭টি চিহ্ন: অক্ষর আর কারচিহ্ন আলাদা, শেষে </w>
ধাপ
চিহ্ন
সংখ্যা
  1. শুরু. “কমদামি” ৭টি চিহ্ন: ক, ম, দ, া, ম, ি আর শেষ চিহ্ন।
  2. Merge ১. দ + া (গণনা ১৩): “দা” একটি চিহ্ন।
  3. Merge ২. দা + ম (১৩): “দাম”.
  4. Merge ৩. ক + ম (৮, কম ৫ আর কমদামি ৩): “কম”.
  5. Merge ৪. দাম + ি (৭): “দামি”.
  6. Merge ৫. দামি + শেষ চিহ্ন (৭): শব্দটি এখন দুটি token. ষষ্ঠ merge (দাম + শেষ চিহ্ন) এই শব্দে লাগে না।

সমস্যা ৯ · Temperature, top-k আর top-p sampling

প্রশ্ন: “একটি logit vector থেকে temperature, top-k আর top-p ফিল্টার লিখুন, তারপর seed দিয়ে নমুনা তুলুন।”

ইঙ্গিত: Top-p: বড় থেকে ছোট সাজিয়ে cumulative sum, প্রথম যে ঘরে যোগফল p ছোঁয় সেটি পর্যন্ত রাখা (searchsorted + 1), তারপর আবার যোগফল ১ করা।

import numpy as np
vocab = np.array(["ভালো", "দারুণ", "ঠিকঠাক", "খারাপ", "দেরি", "ফেরত"])
logits = np.array([2.0, 1.5, 0.5, 0.0, -1.0, -2.0])

def probs(z, T=1.0):                           # temperature: logit ÷ T
    z = z / T; e = np.exp(z - z.max()); return e / e.sum()

def top_k(p, k):
    keep = np.argsort(p)[::-1][:k]
    q = np.zeros_like(p); q[keep] = p[keep]; return q / q.sum()

def top_p(p, c):                               # সবচেয়ে ছোট দল যার যোগফল ≥ c
    order = np.argsort(p)[::-1]
    n = np.searchsorted(np.cumsum(p[order]), c) + 1
    q = np.zeros_like(p); q[order[:n]] = p[order[:n]]; return q / q.sum()

np.set_printoptions(precision=3, suppress=True)
for T in (0.5, 1.0, 2.0):
    print(f"T={T}:", probs(logits, T))
p = probs(logits)
print("top-k=2  :", top_k(p, 2))
print("top-p=0.8:", top_p(p, 0.8), "| kept:", int((top_p(p, 0.8) > 0).sum()))
rng = np.random.default_rng(42)
draws = rng.choice(len(p), size=2000, p=top_p(p, 0.8))   # নমুনা টানা
print("sampled share:", np.bincount(draws, minlength=len(p)) / 2000)
print("first 5 words:", " ".join(vocab[draws[:5]]))

সত্যিকারের আউটপুট:

T=0.5: [0.695 0.256 0.035 0.013 0.002 0.   ]
T=1.0: [0.492 0.298 0.11  0.067 0.024 0.009]
T=2.0: [0.336 0.262 0.159 0.124 0.075 0.045]
top-k=2  : [0.622 0.378 0.    0.    0.    0.   ]
top-p=0.8: [0.547 0.331 0.122 0.    0.    0.   ] | kept: 3
sampled share: [0.545 0.329 0.126 0.    0.    0.   ]
first 5 words: দারুণ ভালো দারুণ দারুণ ভালো

T = ০.৫-এ বণ্টন তীক্ষ্ণ (প্রথম শব্দ ০.৬৯৫), T = ২-এ চ্যাপ্টা। Top-k = ২ শুধু দুটি রাখে। Top-p = ০.৮ তিনটি রাখে, কারণ প্রথম দুটির যোগ ০.৭৯০, ০.৮-এর একটু কম। ২০০০টি নমুনার ভাগ (০.৫৪৫, ০.৩২৯, ০.১২৬) ফিল্টার-করা সম্ভাবনার কাছে, আর বাদ পড়া শব্দ একবারও আসেনি: নিজের sampler-এর এটিই সহজ যাচাই।

p (T = 1) = [0.492, 0.298, 0.110, 0.067, 0.024, 0.009]
ধাপ
মান
  1. সাজানো. বড় থেকে ছোট; এখানে আগেই সাজানো।
  2. Cumulative sum. 0.492, 0.790, 0.900, 0.967 …
  3. কাটার জায়গা. 0.790 < 0.8, তাই দ্বিতীয় ঘরে থামা চলে না; প্রথম যে ঘর 0.8 ছোঁয় তা তৃতীয়টি।
  4. রাখা. প্রথম তিনটি শব্দ, যোগফল 0.900; বাকিগুলো ০।
  5. আবার ভাগ. 0.492/0.9 = 0.547, 0.298/0.9 = 0.331, 0.110/0.9 = 0.122: ব্লকের আউটপুটের সমান।

সমস্যা ১০ · Beam search, greedy আর brute force-এর সাথে তুলনা

প্রশ্ন: “পরের শব্দের সম্ভাবনার একটি ছোট টেবিল দেওয়া। দৈর্ঘ্য L-এর সবচেয়ে সম্ভাব্য sequence খুঁজতে beam search লিখুন; দেখান কখন greedy হারে।”

ইঙ্গিত: Log-probability যোগ করুন (গুণ নয়, underflow এড়াতে)। প্রতি ধাপে প্রতিটি beam-কে প্রতিটি শব্দ দিয়ে বাড়িয়ে সেরা width-টি রাখুন। ছোট L-এ সব sequence গুনে (brute force) যাচাই।

import itertools
import numpy as np
V = ["<s>", "পণ্য", "দ্রুত", "ভালো", "এসেছে"]   # ছোট শব্দভান্ডার
P = np.array([[0.0, 0.40, 0.35, 0.10, 0.15],   # P[a, b] = আগের শব্দ a হলে পরের b-এর সম্ভাবনা
              [0.0, 0.05, 0.10, 0.25, 0.60],
              [0.0, 0.05, 0.05, 0.05, 0.85],
              [0.0, 0.30, 0.10, 0.10, 0.50],
              [0.0, 0.40, 0.30, 0.20, 0.10]])
logP = np.log(np.where(P > 0, P, 1e-12))

def score(seq):                               # শুরু <s> থেকে log-prob-এর যোগফল
    return sum(logP[a, b] for a, b in zip((0,) + seq, seq))

def beam_search(L, width):
    beams = [((), 0.0)]
    for _ in range(L):
        cand = [(s + (b,), sc + logP[s[-1] if s else 0, b]) for s, sc in beams for b in range(1, len(V))]
        beams = sorted(cand, key=lambda x: -x[1])[:width]     # সেরা width-টি রাখি
    return beams[0]

L = 2
for width in (1, 2, 3):                       # width 1 = greedy
    seq, sc = beam_search(L, width)
    print(f"width {width}: {' '.join(V[i] for i in seq):18s} log p = {sc:.3f} (p = {np.exp(sc):.3f})")
best = max(itertools.product(range(1, len(V)), repeat=L), key=score)
print("brute force:", " ".join(V[i] for i in best), f"p = {np.exp(score(best)):.3f}", f"| {4 ** L} sequences checked")

সত্যিকারের আউটপুট:

width 1: পণ্য এসেছে         log p = -1.427 (p = 0.240)
width 2: দ্রুত এসেছে        log p = -1.212 (p = 0.297)
width 3: দ্রুত এসেছে        log p = -1.212 (p = 0.297)
brute force: দ্রুত এসেছে p = 0.297 | 16 sequences checked

Greedy প্রথম ধাপে সবচেয়ে সম্ভাব্য “পণ্য” (০.৪০) নেয়, তারপর “এসেছে” (০.৬০): মোট ০.২৪০। Width ২-এ “দ্রুত” (০.৩৫) beam-এ টিকে থাকে, আর তার পরে “এসেছে” ০.৮৫, মোট ০.২৯৭৫: প্রথম ধাপে একটু কম, শেষে বেশি। Brute force-এর ১৬টি sequence-ও একই উত্তর দেয়। বড় শব্দভান্ডারে brute force VL, beam search প্রায় L·width·V.

প্র ১১ফলো-আপ: temperature আর top-p কোন ক্রমে? Beam search chat-এ কম দেখা যায় কেন? (মাঝারি)

উত্তর: সাধারণ রীতি আগে temperature দিয়ে logit ভাগ, তারপর top-k/top-p কাটা; ক্রম বদলালে কতগুলো token টিকে থাকে তা বদলায়, আর library ভেদে ক্রম আলাদা হতে পারে, তাই নিজের version-এর নথি দেখুন (পর্ব ৫০)। Beam search সবচেয়ে সম্ভাব্য পথ খোঁজে; অনুবাদের মতো একটি “সঠিক” উত্তরের কাজে ভালো, কিন্তু খোলা লেখায় প্রায়ই একঘেয়ে আর পুনরাবৃত্ত লেখা দেয় (Holtzman ও সহলেখকদের text degeneration-এর পর্যবেক্ষণ)। Beam-এ লম্বা sequence-এর log-prob বেশি ঋণাত্মক, তাই length penalty লাগে। জটিলতা: প্রতি ধাপ O(width·V) score আর sort.

প্র ১২ফলো-আপ: BPE trainer-এর জটিলতা কত? বাংলায় কী সমস্যা? (মাঝারি)

উত্তর: সরল রূপে প্রতিটি merge-এ সব শব্দ আবার গোনা: M merge আর মোট চিহ্ন N হলে O(M·N). বাস্তব trainer জোড়ার গণনা রেখে শুধু বদলানো শব্দগুলো আপডেট করে। বাংলায় কারচিহ্ন আর যুক্তাক্ষর আলাদা Unicode code point, তাই অক্ষর থেকে শুরু করলে একটি দৃশ্যমান অক্ষর কয়েকটি token-এ ভাঙতে পারে; byte-level BPE-তে একটি বাংলা অক্ষরই UTF-8-এ ৩ byte. ইংরেজি-প্রধান ডেটায় শেখা tokenizer-এ বাংলা লেখা তাই অনেক বেশি token নেয়, যা খরচ আর context-এর হিসাব বদলায় (পর্ব ৪০, পর্ব ৬৫)। আর Unicode normalization (NFC) আগে করুন, নইলে একই শব্দ দুই রকম token পায়।

৭ · Retrieval আর diffusion — cosine top-k, recall@k আর DDPM-এর forward noising

রিভিশন: পর্ব ৬৭ · semantic search, পর্ব ৬৮ · vector index, পর্ব ৭০ · recall@k আর MRR, পর্ব ৭৯ · DDPM

সমস্যা ১১ · Cosine top-k retrieval আর recall@k, MRR

প্রশ্ন: “ডকুমেন্ট আর প্রশ্নের embedding দেওয়া, প্রতিটি প্রশ্নের একটি সঠিক ডকুমেন্ট জানা। Cosine similarity-তে top-k লিখুন, পুরো sort ছাড়া; তারপর recall@k আর MRR.”

ইঙ্গিত: আগে প্রতিটি vector-কে একক দৈর্ঘ্যে আনলে cosine = dot product, তাই একটি matrix গুণ। np.argpartition O(n)-এ সেরা k-টি দেয় ক্রম ছাড়া; তারপর শুধু সেই k-টি sort.

import numpy as np
rng = np.random.default_rng(7)
n_docs, n_q, d = 2000, 50, 32
docs = rng.normal(size=(n_docs, d))                       # ধরি encoder-এর embedding
gold = rng.integers(0, n_docs, n_q)                        # প্রতিটি প্রশ্নের সঠিক ডকুমেন্ট
queries = docs[gold] + rng.normal(0, 1.1, (n_q, d))       # সঠিক ডকুমেন্টের কাছাকাছি, noise-সহ

def normalize(a):
    return a / np.linalg.norm(a, axis=1, keepdims=True)

def top_k(q, D, k):
    S = normalize(q) @ normalize(D).T                      # cosine = normalize করে dot
    idx = np.argpartition(-S, k, axis=1)[:, :k]            # O(n) বাছাই, ক্রম ছাড়া
    order = np.take_along_axis(S, idx, 1).argsort(1)[:, ::-1]
    return np.take_along_axis(idx, order, 1), S            # শুধু k-টিকে sort

idx, S = top_k(queries, docs, 10)
full = np.argsort(-S, axis=1)[:, :10]                      # যাচাই: পুরো sort
print("argpartition == full sort:", (idx == full).all())
for k in (1, 5, 10):
    print(f"recall@{k}: {(idx[:, :k] == gold[:, None]).any(1).mean():.2f}")
rank = (np.argsort(-S, axis=1) == gold[:, None]).argmax(1) + 1   # সঠিকটির অবস্থান
print("MRR:", round((1 / rank).mean(), 3), "| median rank:", int(np.median(rank)))

সত্যিকারের আউটপুট:

argpartition == full sort: True
recall@1: 0.78
recall@5: 0.90
recall@10: 0.92
MRR: 0.828 | median rank: 1

argpartition-এর ফল পুরো sort-এর প্রথম দশটির সাথে হুবহু এক। ৫০টি প্রশ্নের ৭৮%-এ সঠিক ডকুমেন্ট প্রথম স্থানে, ৯২%-এ প্রথম দশে; MRR ০.৮২৮. এই সংখ্যাগুলো বানানো ডেটার, কোনো সত্যিকারের encoder-এর নয়। RAG-এ recall@k-ই সীমা টানে: retriever যা আনেনি, LLM তা দিয়ে উত্তর দিতে পারে না (পর্ব ৭০)।

প্র ১৩ফলো-আপ: ১ কোটি ডকুমেন্টে এই brute-force-এর খরচ কত, আর বিকল্প কী? (মাঝারি)

উত্তর: প্রতি প্রশ্নে O(n·d) গুণ: n = 10⁷, d = 768-এ প্রায় ৭.৭ × 10⁹ গুণ-যোগ, আর float32-এ embedding-গুলোই প্রায় ৩১ GB. বিকল্প approximate nearest neighbor: IVF (কাছের কয়েকটি cluster-এ খোঁজা), HNSW (graph), আর PQ (vector সংকোচন); দাম কিছুটা recall হারানো, তাই exact search-এর সাথে recall@k মেপে বাছুন (পর্ব ৬৮)। Top-k বাছাইয়ে argpartition O(n), পুরো sort O(n log n).

সমস্যা ১২ · DDPM-এর forward noising, এক লাফে আর ধাপে ধাপে

প্রশ্ন: “Linear noise schedule (β ০.০০০১ থেকে ০.০২, T = ১০০০)। ᾱt বের করুন, যেকোনো t-তে xt এক লাফে তৈরি করুন, আর ধাপে ধাপে noise মেশানোর সাথে বণ্টন মিলিয়ে দেখান। Training batch-এ প্রতিটি ছবির t আলাদা হবে।”

ইঙ্গিত: xt = √ᾱt·x0 + √(1 − ᾱt)·ε, ᾱt = (1 − β)-গুলোর গুণফল (cumprod). Batch-এ আলাদা t হলে ᾱ-কে (B, 1, 1, 1) shape-এ এনে broadcast. সূত্রের প্রমাণ পর্ব ৭৯-এ।

import numpy as np
T = 1000
beta = np.linspace(1e-4, 0.02, T)              # DDPM-এর linear schedule, t = 1..T
abar = np.cumprod(1 - beta)                    # ᾱ_t, index t-1-এ
snr = abar / (1 - abar)
print("abar at t=1, 500, 1000:", abar[[0, 499, 999]].round(5))
print("t with SNR closest to 1:", int(np.argmin(np.abs(snr - 1))) + 1)

def q_sample(x0, t, rng):                      # এক লাফে: √ᾱ·x0 + √(1−ᾱ)·ε
    eps = rng.normal(size=x0.shape)
    a = abar[t - 1].reshape(-1, *[1] * (x0.ndim - 1))   # batch-এ আলাদা t
    return np.sqrt(a) * x0 + np.sqrt(1 - a) * eps, eps

rng = np.random.default_rng(0)
x0 = np.full(20000, 1.5)                       # একই "pixel" মান, অনেক নমুনা
x = x0.copy()
for t in range(1, 301):                        # ধাপে ধাপে ৩০০ বার noise
    x = np.sqrt(1 - beta[t - 1]) * x + np.sqrt(beta[t - 1]) * rng.normal(size=x.shape)
xc, _ = q_sample(x0, np.full(len(x0), 300), rng)
print(f"t=300 step-by-step: mean {x.mean():.3f}, std {x.std():.3f}")
print(f"t=300 closed form : mean {xc.mean():.3f}, std {xc.std():.3f}")
print(f"theory            : mean {np.sqrt(abar[299]) * 1.5:.3f}, std {np.sqrt(1 - abar[299]):.3f}")
imgs = rng.normal(size=(4, 1, 8, 8))           # training batch: প্রতিটি ছবিতে আলাদা t
t = rng.integers(1, T + 1, 4)
xt, eps = q_sample(imgs, t, rng)
print("batch t:", t, "| x_t shape:", xt.shape, "| target eps shape:", eps.shape)

সত্যিকারের আউটপুট:

abar at t=1, 500, 1000: [9.999e-01 7.859e-02 4.000e-05]
t with SNR closest to 1: 259
t=300 step-by-step: mean 0.942, std 0.778
t=300 closed form : mean 0.948, std 0.769
theory            : mean 0.944, std 0.777
batch t: [669 884 211 247] | x_t shape: (4, 1, 8, 8) | target eps shape: (4, 1, 8, 8)

ᾱ শুরুতে প্রায় ১, t = ৫০০-এ ০.০৭৯, শেষে ৪ × 10⁻⁵: শেষ ধাপে ছবি প্রায় পুরো noise. SNR = ᾱ/(1 − ᾱ) t = ২৫৯-এ ১-এর সবচেয়ে কাছে, পর্ব ৭৯-এর হিসাবের সাথে মিল। ৩০০ ধাপ আলাদা করে noise মিশিয়ে আর এক লাফে তৈরি করে দুই রূপের গড় আর std তত্ত্বের মানের (০.৯৪৪, ০.৭৭৭) কাছে; ২০,০০০ নমুনায় তৃতীয় দশমিকে এটুকু ফারাক এলোমেলোতার। শেষ লাইনে training batch: চারটি ছবি, চারটি আলাদা t, আর লক্ষ্য ε-এর shape xt-এর সমান (ε-prediction).

প্র ১৪ফলো-আপ: training-এ প্রতিটি ছবির জন্য এলোমেলো t কেন, ক্রমে ১ থেকে T নয় কেন? (মাঝারি)

উত্তর: একটি network সব t-এর জন্য শেখে (t ইনপুট হিসেবে যায়), আর loss সব t-এর গড়ের একটি নমুনা। প্রতি batch-এ এলোমেলো t দিলে প্রতিটি gradient সব noise স্তরের মিশ্রণ দেখে; ক্রমে চালালে network কিছুক্ষণ শুধু কম noise, পরে শুধু বেশি noise দেখত, আর আগেরটা ভুলে যেত। Closed form থাকায় t = ৭০০-এর নমুনা ৭০০ ধাপ ছাড়াই এক গুণ-যোগে আসে, এটিই DDPM training সস্তা হওয়ার কারণ। Sampling-এ কিন্তু উল্টো দিকে ধাপে ধাপেই যেতে হয়, তাই সেখানে ধাপ কমানোর পদ্ধতি (DDIM ইত্যাদি) লাগে (পর্ব ৮০)।

৮ · PyTorch ডিবাগিং রাউন্ড — ছয়টি চেনা বাগ, সত্যিকারের run

রিভিশন: পর্ব ১০ · training loop, পর্ব ৭ · view আর permute, পর্ব ১১ · ভাগ করা, পর্ব ১৮ · training ডিবাগ

ডিবাগিং দফায় সাধারণত একটি ছোট script আর একটি অভিযোগ আসে। ভালো উত্তরের ক্রম: লক্ষণ মেলানো, একটি অনুমান জোরে বলা, ছোট পরীক্ষায় প্রমাণ, ঠিক করা, আর একটি assert বা test দিয়ে আটকে রাখা। নিচের সব আউটপুট একটি script bugs.py থেকে, প্রতিটি বাগ আলাদা argument-এ। সত্যিকারের run (CPU, torch 2.15.0), Python 3.14; traceback-এর শুধু শেষ লাইন দেখানো।

বাগ ক · zero_grad নেই: “loss প্রথমে দ্রুত কমল, তারপর লাফাচ্ছে”

for step in range(1, 61):
    loss = F.mse_loss(model(X).squeeze(1), y)
    if not forget: opt.zero_grad()
    loss.backward(); opt.step()

w = torch.ones(2, requires_grad=True)
for i in range(3):
    (w * 3).sum().backward(); print("after backward", i + 1, "w.grad =", w.grad.tolist())

সত্যিকারের run (CPU, torch 2.15.0):

$ python bugs.py zero_grad
forget=False step  1 loss 2.6530
forget=False step  5 loss 1.4608
forget=False step 20 loss 0.1969
forget=False step 60 loss 0.0095
forget=True step  1 loss 2.6530
forget=True step  5 loss 0.3860
forget=True step 20 loss 1.2163
forget=True step 60 loss 0.9024
after backward 1 w.grad = [3.0, 3.0]
after backward 2 w.grad = [6.0, 6.0]
after backward 3 w.grad = [9.0, 9.0]

কারণ: backward() gradient যোগ করে, বসায় না। zero_grad না থাকলে ধাপ ৬০-এর gradient আগের সব ধাপের যোগফল: শুরুতে বাড়তি momentum-এর মতো দ্রুত নামায় (ধাপ ৫-এ ০.৩৯ বনাম ১.৪৬), তারপর ছিটকে যায় (ধাপ ৬০-এ ০.৯০ বনাম ০.০১)। নিচের তিন লাইনে প্রমাণ: একই backward তিনবারে grad ৩, ৬, ৯। ইচ্ছা করে জমানো (gradient accumulation) বৈধ কৌশল, তবে তখন loss-কে ধাপের সংখ্যা দিয়ে ভাগ করে নির্দিষ্ট ধাপ পরে step আর zero_grad.

বাগ খ · train/eval mode: “একই ছবিতে দুবার আলাদা উত্তর”, আর batch ১-এ error

model = nn.Sequential(nn.Linear(4, 8), nn.BatchNorm1d(8), nn.ReLU(), nn.Dropout(0.5), nn.Linear(8, 1))
x = torch.randn(16, 4)
model.train(); a, b = model(x), model(x)
print("train mode, same input twice equal:", torch.allclose(a, b))
model.eval(); a, b = model(x), model(x)
print("eval mode,  same input twice equal:", torch.allclose(a, b))
model.train(); err(lambda: model(torch.randn(1, 4)))
model.eval(); print("eval mode, batch of 1 ->", model(torch.randn(1, 4)).shape)

সত্যিকারের run (CPU, torch 2.15.0):

$ python bugs.py mode
train mode, same input twice equal: False
eval mode,  same input twice equal: True
ValueError: Expected more than 1 value per channel when training, got input size torch.Size([1, 8])
eval mode, batch of 1 -> torch.Size([1, 1])

কারণ: Train mode-এ dropout প্রতিবার আলাদা unit বাদ দেয় আর BatchNorm batch-এর গড় নেয়; তাই একই input-এ ভিন্ন output. একটি নমুনায় batch-এর variance মাপা যায় না, তাই error. Evaluation আর inference-এর আগে model.eval() (eval mode), আর gradient না লাগলে সাথে torch.no_grad(); eval mode নিজে gradient বন্ধ করে না (পর্ব ১০)। আবার training-এ ফেরার আগে model.train() ভোলা আরেকটি চেনা বাগ।

বাগ গ · ভুল loss input: “accuracy ভালো, কিন্তু loss ০.৭৫-এ আটকে”

out = model(X)
if bug: out = torch.softmax(out, 1)        # বাগ: CrossEntropyLoss নিজেই log-softmax করে
loss = F.cross_entropy(out, y)

সত্যিকারের run (CPU, torch 2.15.0; ৬০০ নমুনা, ৪ class, Adam ২০০ epoch):

$ python bugs.py loss
softmax_first=False epoch   1 loss 1.3610 acc 0.488
softmax_first=False epoch  50 loss 0.1376 acc 0.987
softmax_first=False epoch 200 loss 0.0171 acc 1.000
softmax_first=True epoch   1 loss 1.3768 acc 0.455
softmax_first=True epoch  50 loss 0.8260 acc 0.978
softmax_first=True epoch 200 loss 0.7526 acc 0.998
lowest possible loss with softmax inputs, 4 classes: 0.7437

কারণ: cross_entropy logit চায়, নিজেই log-softmax করে। আগে softmax দিলে input ০ থেকে ১-এর মধ্যে আটকে যায়, তাই ৪ class-এ loss কখনো ০.৭৪৩৭-এর নিচে নামতে পারে না: শেষ লাইনে সেই নিম্নসীমা, আর বাগওয়ালা run ০.৭৫-এ থেমে আছে। Accuracy প্রায় ঠিক থাকে বলে বাগটি চোখ এড়ায়, কিন্তু gradient দুর্বল আর সম্ভাবনার মান অর্থহীন। লক্ষণ: loss-এর এমন মেঝে যা ln-এর কোনো সরল মানের কাছে (পর্ব ১৩)।

বাগ ঘ · dtype আর device: “mat1 and mat2 must have the same dtype”

model = nn.Linear(3, 2)
x = torch.from_numpy(np.random.default_rng(0).normal(size=(4, 3)))
print("x dtype:", x.dtype, "| weight dtype:", model.weight.dtype)
err(lambda: model(x))
print("fixed:", model(x.float()).dtype)
logits = torch.randn(4, 3)
err(lambda: F.cross_entropy(logits, torch.tensor([0., 2., 1., 0.])))
err(lambda: F.cross_entropy(logits, torch.tensor([0, 2, 1, 0], dtype=torch.int32)))
print("int64 targets:", round(F.cross_entropy(logits, torch.tensor([0, 2, 1, 0])).item(), 4))

সত্যিকারের run (CPU, torch 2.15.0):

$ python bugs.py dtype
x dtype: torch.float64 | weight dtype: torch.float32
RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float
fixed: torch.float32
RuntimeError: expected target dtype to be Long or Byte, but got Float
RuntimeError: expected target dtype to be Long or Byte, but got Int
int64 targets: 1.6981

কারণ: NumPy-র default float64, PyTorch-এর weight float32; from_numpy dtype রেখে দেয়। Class-index target-এর জন্য cross_entropy Long (int64) চায়; float বা int32 দিলে error (এই version-এর বার্তায় Byte-ও গ্রহণযোগ্য বলা)। প্রতিকার ডেটা লোডের জায়গায় একবার: x.float(), y.long() (dtype, পর্ব ৭)। Device-এর ভুল (model GPU-তে, ডেটা CPU-তে) একই ধরনের RuntimeError দেয়; এই CPU-only run-এ তা চালানো হয়নি। প্রতিকার একই নীতি: একটি device variable, আর model ও প্রতিটি batch সেখানে .to(device).

বাগ ঙ · view বনাম permute: “shape ঠিক, ফল উল্টাপাল্টা”

x = torch.arange(24).reshape(2, 3, 4)           # (B, T, C)
want = x.permute(0, 2, 1)                       # (B, C, T)
wrong = x.view(2, 4, 3)                         # একই shape, ভুল সাজানো
print("view shape:", tuple(wrong.shape), "| equal to permute:", torch.equal(wrong, want))
print("x[0]:", x[0].tolist()); print("permute[0]:", want[0].tolist()); print("view[0]:", wrong[0].tolist())
print("permuted is_contiguous:", want.is_contiguous())
err(lambda: want.view(2, -1))

সত্যিকারের run (CPU, torch 2.15.0):

$ python bugs.py shape
view shape: (2, 4, 3) | equal to permute: False
x[0]: [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
permute[0]: [[0, 4, 8], [1, 5, 9], [2, 6, 10], [3, 7, 11]]
view[0]: [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11]]
permuted is_contiguous: False
RuntimeError: view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.
reshape works: (2, 12) | contiguous().view: (2, 12)

কারণ: view memory-র ক্রম না বদলে শুধু নতুন shape-এ পড়ে; permute axis-এর ক্রম বদলায়। (B, T, C) থেকে (B, C, T) চাইলে view কোনো error ছাড়াই ভুল সংখ্যা দেয়: permute-এ প্রথম সারি [0, 4, 8], view-এ [0, 1, 2]. আর permute-এর পরের tensor contiguous নয়, তাই তার ওপর view error দেয়; reshape বা .contiguous().view চলে। Conv1d-এ (B, T, C) টেক্সট দেওয়ার আগে এই ভুলটি সবচেয়ে বেশি দেখা যায় (পর্ব ৩১)।

বাগ চ · ভাগের leak: “এলোমেলো label-এও test accuracy ৯৮%”

Xu = torch.randn(300, 16, generator=g); yu = torch.randint(0, 2, (300,), generator=g)   # label এলোমেলো: শেখার কিছু নেই
X, y = Xu.repeat(3, 1), yu.repeat(3); grp = torch.arange(300).repeat(3)                # প্রতিটি রিভিউ ৩ বার
a, b = random_split(range(900), [720, 180], generator=torch.Generator().manual_seed(1))
run(torch.tensor(a.indices), torch.tensor(b.indices), "random row split")
perm = torch.randperm(300, generator=torch.Generator().manual_seed(1)); test_g = perm[:60]
te = torch.isin(grp, test_g)
run(torch.where(~te)[0], torch.where(te)[0], "group split      ")

সত্যিকারের run (CPU, torch 2.15.0; প্রতিটি run একটি MLP, Adam ৩০০ ধাপ):

$ python bugs.py leak
random row split: train acc 1.000 | test acc 0.983 | test rows whose duplicate is in train: 0.95
group split      : train acc 1.000 | test acc 0.533 | test rows whose duplicate is in train: 0.00

কারণ: Label পুরো এলোমেলো, তাই সৎ test accuracy প্রায় ০.৫ হওয়ার কথা। সারি ধরে এলোমেলো ভাগে test-এর ৯৫% সারির নকল train-এ আছে; network সেগুলো মুখস্থ করেছে, তাই ০.৯৮৩। একই রিভিউয়ের সব কপি একদিকে রাখলে (group split) সত্যিটা বেরিয়ে আসে: ০.৫৩৩। বাস্তবে একই রিভিউ বারবার পোস্ট, একই পণ্যের ছবি, বা একই ক্রেতার অনেক সারি এভাবে data leakage ঘটায়; আরেকটি রূপ ভাগের আগে পুরো ডেটায় গড়-std মাপা। প্রতিকার: আগে dedup বা দল ধরে ভাগ, পরে normalize-এর মান শুধু train থেকে (পর্ব ১১, পর্ব ১৮)।

প্র ১৫সহকর্মীর model-এর loss কমছে না। প্রথম পনেরো মিনিটে কী কী দেখবেন, কোন ক্রমে? (কঠিন)

উত্তর: ক্রম ধরে বলুন। (১) একটি batch চোখে দেখা: input আর label মেলে কি, normalize হয়েছে কি, label-এর dtype আর মান ঠিক কি। (২) শুরুর loss: C class-এ ln C-এর কাছে কি না; অনেক বেশি হলে init বা loss-এর input ভুল (বাগ গ)। (৩) এক batch overfit: ৫০–১০০ ধাপে loss প্রায় ০ না হলে model, loss বা optimizer-এর সংযোগে বাগ, যেমন zero_grad বা step নেই, বা optimizer-কে অন্য model-এর parameter দেওয়া। (৪) Gradient-এর norm: সব শূন্য (graph কাটা, detach, dead ReLU) নাকি বিশাল (lr বেশি)। (৫) Learning rate দশ গুণ ছোট-বড় করে দেখা (পর্ব ১৮)। প্রতিটি ধাপে একটি অনুমান আর একটি প্রমাণ।

দুর্বল উত্তর: “আরও বড় model আর বেশি epoch দিয়ে দেখি।” বাগ থাকলে বড় model শুধু বেশি সময় নষ্ট করে।

প্র ১৬ওয়ার্ম-আপ: একটি সঠিক PyTorch training ধাপের পাঁচটি লাইন বলুন, সঠিক ক্রমে। (সহজ)

ভালো উত্তর: out = model(xb), loss = loss_fn(out, yb), opt.zero_grad(), loss.backward(), opt.step(). zero_grad backward-এর আগে যেকোনো জায়গায় হতে পারে, কিন্তু backward আর step-এর মাঝে নয়। Scheduler থাকলে opt.step()-এর পরে sched.step(). আর epoch শুরুতে model.train(), validation-এর আগে model.eval() আর torch.no_grad() (পর্ব ১০)।

দুর্বল উত্তর: opt.step()-কে backward-এর আগে ডাকা; তখন আগের ধাপের পুরোনো gradient দিয়ে update হয়।

৯ · জমা দেওয়ার আগে — জটিলতার ছক আর চেকলিস্ট

রিভিশন: পর্ব ১৮, পর্ব ৩৭, পর্ব ৮৫ · GenAI system design, DSA · coding ইন্টারভিউ

প্রতিটি সমাধানের শেষে জটিলতা বলা ইন্টারভিউয়ারের প্রত্যাশা। নিচের ছকে বারোটি সমস্যার সারাংশ; N batch, T token, d বা D মাপ, H head, C class, V শব্দভান্ডার, n ডকুমেন্ট, P parameter, M merge.

সমস্যাসময়memoryযাচাই
১ · MLP backpropO(N(DH + HC))activation O(NH)finite difference
২ · BatchNormO(NF)O(NF)finite difference, কলামের যোগ ০
৩ · AdamWO(P) প্রতি ধাপবাড়তি 2Pপ্রথম ধাপ = lr·চিহ্ন
৪ · conv2dO(N·Cout·HoWo·Cink²)im2col প্রায় k²/s² গুণloop, SciPy
৫ · causal attentionO(T²d)O(T²)output[0] = V[0], ভবিষ্যৎ বদলে
৬ · multi-headO(T²D + TD²)O(H·T²)head-ধরে loop
৭ · KV cacheপ্রতি ধাপ O(T·d)O(layer·T·d)পুরো হিসাবের সাথে
৮ · BPEO(M·N) সরল রূপেO(N)হাতে গোনা
৯ · samplingO(V log V)O(V)নমুনার ভাগ
১০ · beam searchO(L·width·V)O(width·L)brute force
১১ · retrievalO(n·d) প্রতি প্রশ্নO(n·d)পুরো sort
১২ · DDPM forwardO(ছবির মাপ)O(ছবির মাপ)ধাপে ধাপে, তত্ত্ব

সমাধান জমা দেওয়ার আগে: আট লাইনের চেকলিস্ট

আরও গভীরে: BatchNorm এসেছে Ioffe আর Szegedy-র ২০১৫ সালের প্রবন্ধ থেকে, Adam Kingma আর Ba-র (২০১৪ সালে arXiv-এ, ICLR ২০১৫), আর decoupled weight decay Loshchilov আর Hutter-এর (২০১৭ সালে arXiv-এ, ICLR ২০১৯)। Scaled dot-product আর multi-head attention “Attention Is All You Need” (Vaswani ও সহলেখক, ২০১৭) থেকে; NLP-তে BPE এনেছিলেন Sennrich, Haddow আর Birch (২০১৬); DDPM Ho, Jain আর Abbeel-এর ২০২০ সালের প্রবন্ধ। Andrej Karpathy-র খোলা “micrograd” আর “nanoGPT” কোড এ ধরনের সমস্যা নিজে লেখার ভালো অনুশীলন-উৎস; তাঁর কোড পড়ে তারপর নিজে না দেখে আবার লিখুন।

AI-এর যুগে DL coding ইন্টারভিউর প্রস্তুতি

AI সহকারী ধৈর্যশীল অনুশীলনের সঙ্গী হতে পারে, তবে DL কোডে তার ভুলগুলো চেনা দরকার। সে প্রায়ই পুরোনো বা বানানো API লেখে: transformers, peft বা trl-এর সরানো argument, PyTorch-এর পুরোনো function. Tensor-এর shape, dim আর dtype গুলিয়ে ফেলে: transpose ছাড়া reshape, ভুল axis-এ softmax, float64 ডেটা। model.eval(), torch.no_grad() বা zero_grad ভুলে যায়, ভাগের আগে normalize করে test-এর তথ্য ফাঁস করে, আর একটি ভাগ্যবান run-কে ফল বলে। Derivation-এ বিশ্বাসযোগ্য দেখতে কিন্তু ভুল ধাপ দেয়, যেমন BatchNorm-এর backward-এ গড়ের পথ বাদ; আর নেই এমন প্রবন্ধ উদ্ধৃত করতে পারে। LLM কোনো কিছু “বোঝে”, এমন দাবিও যাচাই ছাড়া নেবেন না। Live ইন্টারভিউতে অনুমতি ছাড়া AI ব্যবহার করবেন না।

AI-কে সমাধান লিখতে না বলে আপনাকে চেপে ধরতে বলুন। তারপর তার প্রতিটি দাবি নিজে চালিয়ে মিলিয়ে নিন।

তুমি একজন কড়া deep learning coding ইন্টারভিউয়ার। আমার স্তর: [মাঝারি/উন্নত]। Version: Python 3.14, NumPy 2.4, torch 2.15. ১. আমাকে একটি from-scratch সমস্যা দাও (যেমন LayerNorm-এর backward, causal attention, top-p, BPE), ৩০ লাইনের মধ্যে করা যায় এমন। শুধু চাহিদা বলো, সমাধান নয়। ২. আমি লিখতে লিখতে প্রতিটি array-এর shape জিজ্ঞেস করো। আমি না বললে থামাও। ৩. আমি কোড দিলে জিজ্ঞেস করো: কীভাবে যাচাই করলে? gradient check, ধীর রূপ, নাকি invariant? সব-mask সারি, খুব বড় logit আর batch ১-এ কী হবে? জটিলতা কত? ৪. তারপর একটি বাগওয়ালা PyTorch training loop দাও (zero_grad, eval mode, loss-এর input, dtype, view/permute বা leak) আর শুধু লক্ষণ বলো। ৫. কোনো API নিয়ে নিশ্চিত না হলে "নিশ্চিত নই" লেখো আর কোন অফিসিয়াল নথির পাতা দেখব বলো। ৬. শেষে ছয়টি ক্ষেত্রে ০ থেকে ২ নম্বর দাও: চাহিদা, shape, সঠিকতা, যাচাই, জটিলতা, ব্যাখ্যা।

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

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

ক্রম“NumPy দিয়ে একটি layer লিখুন, forward আর backward” পেলে ভালো কাজের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ইনপুট, output আর প্রতিটি parameter-এর shape নিয়ে প্রশ্ন করে চাহিদা মেলানো
  2. ছোট ইনপুটে forward লিখে প্রতিটি মধ্যবর্তী shape মুখে বলা
  3. Backward লেখা, প্রতিটি gradient-এর shape parameter-এর সমান রেখে
  4. Finite difference, ধীর রূপ বা invariant দিয়ে যাচাই
  5. সময়, memory আর বড় স্কেলে কী বদলাবে তা বলা

(সহজ) Input (8, 3, 32, 32), Conv2d(3, 16, kernel_size=3, stride=2, padding=1). Output-এর shape কী?

  • (8, 16, 16, 16)
  • (8, 16, 15, 15)
  • (8, 16, 32, 32)
  • (8, 3, 16, 16)

(মাঝারি) T = 4 token-এর causal self-attention-এ 4 × 4 score matrix-এর কয়টি ঘর −∞ দিয়ে mask হয়?

  • 4
  • 6
  • 10
  • 16

(মাঝারি) x-এর shape (2, 5, 12), head H = 3. x.reshape(2, 5, 3, 4).transpose(0, 2, 1, 3)-এর shape কী?

  • (2, 3, 5, 4)
  • (2, 5, 3, 4)
  • (2, 3, 4, 5)
  • (6, 5, 4)

(মাঝারি) সাজানো সম্ভাবনা [0.5, 0.3, 0.1, 0.06, 0.04]. Top-p = 0.85 হলে কয়টি token টিকে থাকে?

  • 2, কারণ 0.5 + 0.3 যথেষ্ট
  • 5, সবগুলো
  • 3
  • 4

(কঠিন) Adam, β₁ = 0.9, β₂ = 0.999, lr = 0.01, ε খুব ছোট। শুরু m = v = 0, প্রথম gradient g = [100, 0.001]. Bias correction-সহ প্রথম update প্রায় কত?

  • [−1, −0.00001]
  • [−0.01, −0.01]
  • [−0.0316, −0.0316]
  • [−0.01, −0.00001]

(সহজ) একটি PyTorch loop প্রতি ধাপে loss.backward() আর opt.step() ডাকে, কিন্তু কখনো opt.zero_grad() ডাকে না। কী ঘটে?

  • কিছুই না, PyTorch নিজেই gradient মোছে
  • RuntimeError আসে
  • Gradient জমতে থাকে, প্রতিটি update আগের সব ধাপের যোগফল দিয়ে
  • Learning rate নিজে থেকে কমে যায়

(মাঝারি) x-এর shape (2, 3, 4). y = x.permute(0, 2, 1), তারপর y.view(2, -1). কী হবে?

  • (2, 12) shape, ঠিক ক্রমে
  • (2, 12) shape, কিন্তু x-এর মূল ক্রমে
  • (2, 4, 3) ফেরত দেয়, view উপেক্ষা হয়
  • RuntimeError, কারণ tensor contiguous নয়

(মাঝারি) রিভিউয়ের ডেটায় প্রতিটি রিভিউ তিনবার আছে, label এলোমেলো। সারি ধরে এলোমেলো ভাগে test accuracy ০.৯৮, রিভিউ ধরে ভাগে ০.৫৩। সবচেয়ে সম্ভাব্য ব্যাখ্যা কী?

  • Model চমৎকার, group split-এ ডেটা কম পড়েছে
  • একই রিভিউ train আর test দুই দিকে: নকলের leak
  • Dropout নেই বলে
  • Seed-এর ভাগ্য

শব্দকোষ

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

মনে রাখুন

  1. Shape মুখে বলুন: প্রতিটি gradient-এর shape তার parameter-এর সমান; transpose ছাড়া reshape আর permute-এর জায়গায় view কোনো error ছাড়াই ভুল দেয়।
  2. Autograd ছাড়া প্রমাণ: finite difference (float64, ছোট ইনপুট), ধীর loop-এর রূপ, আর invariant: softmax-এর যোগ ১, output[0] = V[0], ভবিষ্যৎ বদলে আগের output অপরিবর্তিত।
  3. স্থিরতা: সর্বোচ্চ বিয়োগ করে softmax, √d দিয়ে score ভাগ, log-prob যোগ, আর সব-mask সারির nan সামলানো।
  4. খরচ বলুন: attention O(T²), KV cache decode-কে T²-থেকে T-তে নামায় কিন্তু memory নেয়, im2col memory দিয়ে গতি কেনে।
  5. PyTorch-এর চেনা বাগ: zero_grad, train/eval, softmax-এর পরে cross_entropy, float64 আর int32, view/permute, আর নকল সারির leak; প্রতিটির লক্ষণ চিনুন।

পরের পর্বে: পর্ব ৯১ (Deep learning, LLM আর GenAI ধারণা ও system design ইন্টারভিউ): architecture বাছাই, fine-tune বনাম RAG বনাম prompt, LLM app-এর মূল্যায়ন, খরচ আর latency, আর পুরো GenAI system design-এর নকল ইন্টারভিউ।

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

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