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

Self-attention আর multi-head attention — matrix দিয়ে হাতে-কলমে

Self-Attention and Multi-Head Attention — Learned Q, K, V, Why √d_k, Causal and Padding Masks, Shape Arithmetic and O(T²) Cost

Taught in Bangla, this lesson derives self-attention as softmax(QKᵀ/√d_k)V with learned Q, K and V projections, works three tokens by hand, and measures in in-page NumPy why scaling by √d_k prevents softmax saturation. It builds causal and padding masks, splits and concatenates heads with full shape and parameter arithmetic, checks permutation equivariance, matches a hand-written module to nn.MultiheadAttention and scaled_dot_product_attention in a real desktop run, and measures O(T²) cost.

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

  • ৩টি token-এর self-attention পুরো হাতে হিসাব করা, আর (B, T, d) ব্যাচে matrix দিয়ে লেখা
  • √dk দিয়ে ভাগ করার যুক্তি, আর variance ও softmax saturation মাপা
  • Causal আর padding mask বানানো, মেলানো, আর NaN-এর একটি ফাঁদ চেনা
  • Multi-head-এর split–attend–concat, আর d = ৫১২, h = ৮-এর পুরো shape ও parameter হিসাব
  • নিজের module-কে PyTorch-এর nn.MultiheadAttention আর F.scaled_dot_product_attention-এর সঙ্গে মেলানো, আর O(T²) মাপা

পর্ব ৩২-এ (Attention — query, key, value আর alignment) decoder-এর একটি query encoder-এর সব token-এর দিকে তাকিয়েছিল। এবার প্রশ্নটি একটু বদলাই: একই sequence-এর token যদি একে অন্যের দিকে তাকায়? এই একটি বদল থেকেই Transformer-এর মূল অংশটি আসে। Dot product মনে না থাকলে AI-এর ভিত্তি কোর্সের পর্ব ৩৩ দেখে নিন; একবার হাতে করা attention আছে AI-এর ভিত্তি কোর্সের পর্ব ৭০-এ। Batched matmul আর einsum এসেছে পর্ব ৭-এ। এখানে আমরা গভীরে যাব।

১ · বাক্য নিজেকেই পড়ে — encoder–decoder attention থেকে self-attention

পাড়ার চায়ের দোকানে একটি আলোচনা চলছে। প্রত্যেকে একবারে সবার কথা শোনেন, আর নিজের দরকার মতো কারও কথায় বেশি, কারও কথায় কম কান দেন। কেউ পালা করে একজনের কাছ থেকে আরেকজনের কাছে খবর পাঠান না। RNN পালা করে খবর পাঠায়। Self-attention-এ প্রত্যেকে সরাসরি সবাইকে শোনে।

বাজারঘরের একটি review নিন: “ফোনটা ভালো, কিন্তু সেটি দেরিতে এসেছে”. “সেটি” বুঝতে দূরের “ফোনটা” দরকার। পর্ব ২৮-এর RNN-এ এই তথ্যকে মাঝের প্রতিটি ধাপ পার হতে হয়। পর্ব ৩০-এ দেখেছেন, পথ লম্বা হলে তথ্য হারায়।

তিন রকম attention পাশাপাশি রাখি। Attention-এ তিনটি জিনিস লাগে: query (কী খুঁজছি), key (কার কাছে কী আছে তার লেবেল) আর value (আসল যে তথ্য মেশানো হবে)।

ধরনQ আসেK, V আসে
Encoder–decoder (cross) attentionDecoder থেকেEncoder থেকে
Self-attentionএকই sequence থেকেএকই sequence থেকে
Masked self-attentionএকই sequence থেকেএকই sequence, শুধু আগের token

ইতিহাস: একই বাক্যের ভেতরে attention Transformer-এর আগেই ছিল। Cheng ও সহকর্মীরা (২০১৬) একে intra-attention বলেছিলেন, LSTM-এর পাশে। Lin ও সহকর্মীরা (২০১৭) “self-attentive” sentence embedding দেখান। Vaswani ও সহকর্মীদের ২০১৭ সালের “Attention Is All You Need” প্রবন্ধের নতুনত্ব ছিল, recurrence পুরো বাদ দিয়ে এটিকেই মূল কাঠামো করা।

২ · Q, K, V — একই input থেকে তিনটি শেখা projection

Input X-এর shape (B, T, d). B হলো batch-এ কয়টি উদাহরণ, T কয়টি token, d প্রতি token-এ কয়টি সংখ্যা। একজন বক্তার তিনটি ভূমিকা: কী জানতে চান, নিজের সম্পর্কে কী লেবেল দেখান, আর ডাক পেলে কী দেন। এই তিনের জন্য তিনটি আলাদা শেখা matrix থাকে। একে বলে learned projection.

Q = XWQ,   K = XWK,   V = XWV

Z = A V,   A = softmax(Q Kᵀ / √dk)

এখানে dk হলো key-র দৈর্ঘ্য, Kᵀ মানে শেষ দুই অক্ষ অদলবদল করা K, আর softmax চলে প্রতিটি সারিতে আলাদা। এটিই scaled dot-product attention. A-কে বলে attention weights; A[i, j] মানে token i, token j-এর value-র কত ভাগ নেবে।

Self-attention-এর পথ, shape-সহ X (B, T, d) Q = XW_QK = XW_KV = XW_V (B, T, d_k)(B, T, d_k)(B, T, d_v) S = QKᵀ/√d_k (B, T, T) Mask: নিষিদ্ধ ঘরে −∞ A = softmax(S) (B, T, T) Z = A V (B, T, d_v)
T দুইবার আসে S আর A-তে: প্রতিটি token প্রতিটি token-কে দেখে। শেষে আবার T-ই থাকে।

নিচের কোডে বাজারঘরের ২টি review, প্রতিটিতে ৪টি token. Embedding আর W এলোমেলো, কারণ এখানে শুধু হিসাবের ছাঁচ দেখছি, এখনো শেখানো হয়নি। আমরা row-vector নিয়মে লিখছি, তাই XW; nn.Linear একই কাজ করে।

import numpy as np
np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(42)
B, T, d = 2, 4, 8                 # ২টি review, প্রতিটিতে ৪ token, token-প্রতি ৮টি সংখ্যা
X = rng.normal(size=(B, T, d))
Wq, Wk, Wv = rng.normal(size=(3, d, d)) / np.sqrt(d)   # শেখার মতো ৩টি matrix

def softmax(z):                   # বড় মান বাদ দিয়ে নিরাপদ softmax (পর্ব ০৩)
    e = np.exp(z - z.max(-1, keepdims=True))
    return e / e.sum(-1, keepdims=True)

def self_attention(Q, K, V):
    S = Q @ K.transpose(0, 2, 1) / np.sqrt(K.shape[-1])
    A = softmax(S)
    return A @ V, A, S

Q, K, V = X @ Wq, X @ Wk, X @ Wv
Z, A, S = self_attention(Q, K, V)
for name, arr in zip("XQKVSAZ", (X, Q, K, V, S, A, Z)):
    print(name, arr.shape)
print("প্রতি সারির যোগফল:", A[0].sum(-1))
print("A[0]:\n", A[0])
print("A প্রতিসম?", np.allclose(A[0], A[0].T))

by_hand = sum(A[0, 1, j] * V[0, j] for j in range(T))  # token ১-এর output, লুপে
print("লুপ বনাম matrix, max|diff|:", np.abs(by_hand - Z[0, 1]).max())
same = np.einsum("bts,bsd->btd", A, V)                 # একই কাজ einsum-এ
print("einsum বনাম @, max|diff|:", np.abs(same - Z).max())

_, A0, _ = self_attention(X, X, X)                     # projection ছাড়া
d0, d1 = np.diagonal(A0, axis1=1, axis2=2), np.diagonal(A, axis1=1, axis2=2)
print(f"নিজের দিকে গড় weight: projection ছাড়া {d0.mean():.3f}, সহ {d1.mean():.3f}")

আউটপুট (পাতার Python-এ চালানো):

X (2, 4, 8)
Q (2, 4, 8)
K (2, 4, 8)
V (2, 4, 8)
S (2, 4, 4)
A (2, 4, 4)
Z (2, 4, 8)
প্রতি সারির যোগফল: [1. 1. 1. 1.]
A[0]:
 [[0.272 0.175 0.227 0.327]
 [0.187 0.313 0.28  0.22 ]
 [0.289 0.195 0.318 0.199]
 [0.235 0.27  0.34  0.155]]
A প্রতিসম? False
লুপ বনাম matrix, max|diff|: 0.0
einsum বনাম @, max|diff|: 0.0
নিজের দিকে গড় weight: projection ছাড়া 0.537, সহ 0.262

তিনটি জিনিস দেখুন। এক, S আর A-র shape (2, 4, 4), আর Z ফিরল (2, 4, 8)-এ। তাই এই স্তর একটির পর একটি বসানো যায়। দুই, A প্রতিসম নয়: i কতটা j-কে দেখে আর j কতটা i-কে দেখে, দুটি আলাদা, কারণ Q আর K আলাদা। তিন, projection ছাড়া Q = K = X হলে S[i, i] = ‖xi‖²/√d, যা সাধারণত সারির বড় মান; তাই এই এলোমেলো ডেটায় token গড়ে ০.৫৩৭ ভাগ নিজের দিকেই তাকাল। Projection থাকলে ০.২৬২. Projection-এর কাজ শুধু এটি নয়। আরও বড় কথা, “কী খুঁজি” আর “কী দিই” আলাদা করে শেখার সুযোগ এখান থেকেই আসে।

Shape-গুলো মুখে বলতে পারাই এই অংশের আসল লক্ষ্য। Q আর K-এর শেষ অক্ষ অবশ্যই সমান (dk), নইলে dot product হয় না। V-এর শেষ অক্ষ (dv) আলাদা হতে পারে, কারণ V-কে শুধু A দিয়ে মেশানো হয়; বাস্তবে সাধারণত তিনটিই সমান রাখা হয়। আর Q-এর T আর K-এর T সমান হতে হবে, এমন শর্ত নেই। Decoder-এর ১০ token encoder-এর ৩০ token-এর দিকে তাকালে A হয় (B, 10, 30); এই cross-attention পর্ব ৩৫-এ (Transformer block) আসবে। Self-attention হলো সেই বিশেষ ক্ষেত্র, যেখানে দুটি T একই।

৩ · ৩টি token হাতে হিসাব

কাগজ-কলম নিন। ৩টি token, d = ২, ধরুন “ফোন”, “ভালো”, “ছিল”. সংখ্যাগুলো হাতে হিসাবের সুবিধার জন্য বানানো। WQ = [[1,0],[1,1]], WK = [[1,1],[0,1]], WV = [[1,0],[0,2]].

সারি = token (ফোন, ভালো, ছিল); ৩ ঘর দশমিক
ধাপ
মান
  1. Input. ৩টি token, প্রতিটি ২টি সংখ্যা। Shape (3, 2).
  2. Projection. প্রতিটি সারিকে W দিয়ে গুণ। যেমন “ছিল”-এর query: [1,1]·WQ = [2, 1].
  3. Dot product. Q-এর প্রতিটি সারি K-এর প্রতিটি সারির সঙ্গে। “ফোন” আর “ছিল”: [1,0]·[1,2] = ১. ফল (3, 3).
  4. Scale. dk = ২, তাই সব ঘর ১.৪১৪২ দিয়ে ভাগ।
  5. Softmax. প্রথম সারির তিনটি মানের e-ঘাত নিয়ে যোগফল ৫.০৫৬ দিয়ে ভাগ। সারির যোগফল ১.
  6. পুরো A. “ছিল” নিজের দিকে ০.৬২০, “ভালো”-র দিকে মাত্র ০.০৭৪. A প্রতিসম নয়।
  7. মিশ্রণ. Z₁ = ০.৪০১·[1,0] + ০.১৯৮·[0,2] + ০.৪০১·[1,2] = [০.৮০২, ১.১৯৮]. Output-এর shape (3, 2).

একটি জিনিস লক্ষ করুন। ওজন ঠিক করে Q আর K, কিন্তু মিশ্রণ হয় V দিয়ে। পরীক্ষায় হাতে করার সময় এই তিনটি গুলিয়ে ফেলাই সবচেয়ে সাধারণ ভুল।

A-র একটি সারি পড়তে শিখুন। সারি ৩ বলছে, “ছিল”-এর নতুন vector তৈরি হবে “ফোন”-এর value-র ৩০.৫৭%, “ভালো”-র ৭.৪৩% আর নিজের ৬২% নিয়ে। এখানে W আমরা বানিয়েছি, তাই এই সংখ্যার কোনো ভাষাগত অর্থ নেই। Training-এ W বদলায়, আর সেই সঙ্গে A-ও। হিসাব মিলিয়ে নেওয়ার সহজ উপায়: প্রতিটি সারির যোগফল ১ কি না, আর Z-এর প্রতিটি মান V-এর সেই কলামের সর্বনিম্ন ও সর্বোচ্চ মানের ভেতরে কি না। মিশ্রণ বলে এর বাইরে যেতে পারে না।

৪ · কেন √dk দিয়ে ভাগ

ক্রিকেটের স্কোরকার্ড ভাবুন। ৫ ওভারের খেলায় দুই দলের রানের ফারাক সাধারণত অল্প, ৫০ ওভারে অনেক বেশি হতে পারে। ফারাক দিয়ে তুলনা করতে হলে খেলার দৈর্ঘ্য অনুযায়ী মানিয়ে নিতে হয়। Dot product-ও অনেকগুলো ছোট পদের যোগ, তাই dk বাড়লে এর ছড়ানোও বাড়ে।

Softmax score-এর পার্থক্য দেখে। পার্থক্য খুব বড় হলে একটি token প্রায় ১ পায়, বাকিরা প্রায় ০. একে বলে softmax saturation. তখন gradient প্রায় শূন্য, কারণ ∂pi/∂zi = pi(1 − pi). Gradient-এর এই হিসাবের ভিত্তি আছে মেশিন লার্নিং কোর্সের পর্ব ১১-এ। ধাপে ধাপে দেখুন।

ধারণা: q আর k-এর ঘরগুলো স্বাধীন, গড় 0, variance 1
ধাপ
মান
  1. একটি পদ. দুটি স্বাধীন সংখ্যার গুণফলের গড় ০. Variance E[q²]E[k²] = ১.
  2. যোগ. স্বাধীন পদের variance যোগ হয়, তাই Var(q·k) = dk. আদর্শ বিচ্যুতি (sd) √dk.
  3. বাড়ে. dk ৪ থেকে ৫১২ হলে sd ২ থেকে প্রায় ২২.৬.
  4. ভাগ ছাড়া. dk = ৬৪-এ এক sd দূরের তিনটি score: ৮, ০, −৮. প্রায় one-hot. Gradient-এর গুণক মাত্র ০.০০০৩.
  5. ভাগ করে. একই score-কে ৮ দিয়ে ভাগ: ১, ০, −১. Weight ছড়ানো, গুণক ০.২২২৭, প্রায় ৬৬০ গুণ বড়।
  6. ফল. কোনো মানকে c দিয়ে ভাগ করলে variance c² দিয়ে ভাগ হয়। তাই √dk দিয়ে ভাগে variance ঠিক ১-এ ফেরে।

এটি প্রমাণের ধারণাটুকু। এবার মাপি: প্রতিটি dk-এ ৩০০টি query আর ৮টি করে key, উপাদানগুলো standard normal.

import numpy as np, matplotlib.pyplot as plt
rng = np.random.default_rng(42)
ds = [4, 16, 64, 256, 1024]
def softmax(z):
    e = np.exp(z - z.max(-1, keepdims=True))
    return e / e.sum(-1, keepdims=True)
rows = []
for dk in ds:
    q = rng.normal(size=(300, dk))          # ৩০০টি query
    k = rng.normal(size=(300, 8, dk))       # প্রতিটির জন্য ৮টি key
    s = np.einsum("nd,ntd->nt", q, k)       # dot product, (300, 8)
    res = [dk, s.var()]
    for z in (s, s / np.sqrt(dk)):          # scaling ছাড়া, তারপর সহ
        p = softmax(z)
        g = (p * (1 - p)).max(-1)           # সবচেয়ে বড় ∂p_i/∂z_i
        res += [z.var(), p.max(-1).mean(), g.mean()]
    rows.append(res)
print("  d_k   var(raw) var(scaled) maxp(raw) maxp(scaled) grad(raw) grad(scaled)")
for r in rows:
    print(f"{r[0]:5d} {r[2]:9.1f} {r[5]:10.2f} {r[3]:9.3f} {r[6]:12.3f} {r[4]:9.4f} {r[7]:11.3f}")
r = np.array(rows)
fig, ax = plt.subplots(1, 2, figsize=(8, 3.2))
ax[0].loglog(r[:, 0], r[:, 2], "o-", label="raw q·k")
ax[0].loglog(r[:, 0], r[:, 5], "s-", label="q·k / sqrt(d_k)")
ax[0].set_xlabel("d_k"); ax[0].set_ylabel("variance of score"); ax[0].legend()
ax[1].semilogx(r[:, 0], r[:, 3], "o-", label="raw q·k")
ax[1].semilogx(r[:, 0], r[:, 6], "s-", label="q·k / sqrt(d_k)")
ax[1].axhline(1 / 8, ls=":", c="gray"); ax[1].set_ylim(0, 1.05)
ax[1].set_xlabel("d_k"); ax[1].set_ylabel("mean largest weight (8 keys)"); ax[1].legend()
fig.subplots_adjust(wspace=0.32, bottom=0.18); plt.show()

আউটপুট (পাতার Python-এ চালানো; সাথে দুই ভাগের ছবি):

  d_k   var(raw) var(scaled) maxp(raw) maxp(scaled) grad(raw) grad(scaled)
    4       3.8       0.94     0.520        0.336    0.2095       0.206
   16      16.1       1.01     0.742        0.350    0.1532       0.215
   64      67.7       1.06     0.875        0.367    0.0838       0.219
  256     261.6       1.02     0.938        0.357    0.0427       0.218
 1024     991.9       0.97     0.971        0.357    0.0214       0.215

Raw variance প্রায় dk-এর সমান; scale করলে প্রায় ১, dk যা-ই হোক। সবচেয়ে বড় weight-এর গড় raw-তে ৮টি key-র মধ্যেও ০.৯৭১-এ পৌঁছায়, আর scale করা হলে ০.৩৫-এর আশেপাশে থাকে। Gradient-এর গুণক raw-তে dk চার গুণ হলে প্রায় অর্ধেক হচ্ছে। Scale করলে প্রায় স্থির।

সীমাটি মনে রাখুন: হিসাবটি ধরে নেয়, ঘরগুলো স্বাধীন আর একক variance-এর। শেখা Q আর K-তে তা ঠিক সত্য নয়। তাই √dk একটি যুক্তিসংগত শুরুর স্কেল, কোনো নিখুঁত গ্যারান্টি নয়। পর্ব ১৪-এর LayerNorm আর initialization মানগুলোকে এই সীমার কাছাকাছি রাখতে সাহায্য করে।

শেখানোর সময় কি সত্যিই ফারাক পড়ে? — সত্যিকারের run

Variance মাপা এক কথা, training আরেক কথা। তাই PyTorch-এ (torch 2.15.0, CPU-তে) একটি ছোট খোঁজার কাজ চালিয়েছি। বাজারঘরের ৮টি পণ্য-কোড আর প্রতিটির একটি দামের স্তর দেওয়া আছে, ডেটা seed দিয়ে বানানো। একটি কোড দিলে model-কে তার দামের স্তর বলতে হবে (১০টির মধ্যে একটি)। এখানে query আসে আলাদা কোড থেকে, তাই গঠনটি cross-attention-এর মতো, কিন্তু scaling-এর হিসাব একই। মূল অংশ, যেমন চলেছে:

def forward(self, k, v, q):
    S = self.wq(self.ek(q))[:, None] @ self.wk(self.ek(k)).transpose(1, 2) / self.scale
    A = S.softmax(-1)                 # A is a middle step, not the output
    return self.out((A @ self.ev(v)).squeeze(1)), A

self.scale হয় √d, নয় ১. AdamW, learning rate 0.001, batch ১২৮, ৬০০ ধাপ, ১,০০০টি আলাদা পরীক্ষার উদাহরণ; accuracy দেখা হয়েছে প্রতি ২০ ধাপে। Embedding-এর শুরুর sd ১. সময়ের সীমায় থেমে যাওয়ায় d = ১০২৪-এর শেষ চারটি সারি একই script-এর আলাদা run থেকে নেওয়া; প্রতিটি সারি নিজের seed থেকে নতুন শুরু করে।

   d  scale seed  maxA0  gradW0  acc600  step90
  16   True    0  0.180   0.056   1.000     180
  16   True    1  0.195   0.069   1.000     160
  16   True    2  0.174   0.055   1.000     200
  16  False    0  0.379   0.278   1.000     180
  16  False    1  0.435   0.315   1.000     140
  16  False    2  0.356   0.202   1.000     200
 256   True    0  0.195   0.199   1.000      20
 256   True    1  0.193   0.251   1.000      20
 256   True    2  0.194   0.227   1.000      20
 256  False    0  0.811   2.456   0.987      80
 256  False    1  0.831   2.996   0.942     260
 256  False    2  0.807   3.173   0.952     220
1024   True    0  0.191   0.482   0.993     100
1024   True    1  0.197   0.363   0.940      20
1024   True    2  0.188   0.397   1.000      20
1024  False    0  0.922   7.469   0.524    None
1024  False    1  0.926   6.214   0.564    None
1024  False    2  0.892   7.519   0.608    None

maxA0 শুরুতে সবচেয়ে বড় weight-এর গড়, step90 কোন ধাপে accuracy প্রথম ০.৯ ছুঁল (None মানে ৬০০ ধাপে ছোঁয়নি)। d = ১৬-এ প্রায় কোনো পার্থক্য নেই। d = ২৫৬-এ scale করা model ২০ ধাপেই ০.৯, ভাগ ছাড়া model-এর লাগল ৮০ থেকে ২৬০. আর d = ১০২৪-এ ভাগ ছাড়া তিনটি run ৬০০ ধাপ পরেও ০.৫২ থেকে ০.৬১. শুরুতেই weight প্রায় ০.৯ একটি token-এ, যা প্রায় সবসময় ভুল token.

দুটি সতর্ক কথা। এক, প্রথম ধাপের gradient (gradW0) ভাগ ছাড়া model-এই বড়, কারণ score-ই বড়। অর্থাৎ এই run “gradient ছোট বলেই ধীর” কথাটি সরাসরি প্রমাণ করে না; ধীর হওয়ার কারণ আমরা আলাদা করে মাপিনি। দুই, একই ভাগ-না-করা ধারণা আরেকটি কাজে কোনো ক্ষতিই করেনি। সেখানে weight নিজেই output, আর loss সরাসরি log p-এর ওপর: “মিলে যাওয়া কোডটি কোথায়” খোঁজা। d = ১০২৪-এ ভাগ ছাড়া শুরুর loss ১৫–১৭ (ln 9 ≈ ২.২-এর জায়গায়), তবু তিনটি seed-এই প্রথম মাপে, ১০ ধাপে, accuracy ০.৯৫ পার। ফলে √dk কোনো সার্বজনীন উদ্ধারকর্তা নয়: যেখানে A মাঝের ধাপ, সেখানে এই একটি ছোট পরীক্ষায় এর ফল স্পষ্ট দেখা গেল। প্রতিটি সংখ্যা এই ছোট কাজের, তিনটি seed-এর; সাধারণ নিয়ম নয়।

৫ · Causal আর padding mask — softmax-এর আগে −∞

সবাইকে সবসময় দেখতে দেওয়া যায় না। দুটি কারণ আলাদা। এক, পরের token অনুমান করার সময় ভবিষ্যৎ দেখা নকল করার সমান। পরীক্ষার হলে উত্তরপত্র দেখে উত্তর লেখার মতো। এটি আটকায় causal mask. দুই, batch-এ ছোট review-এর শেষে pad আছে (পর্ব ১১, পর্ব ২৭), সেগুলোর কোনো অর্থ নেই। এটি আটকায় padding mask.

Causal, padding আর দুটি মিলে Causal ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ Padding ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ দুটি মিলে ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕ ✕
সারি = যে token দেখছে (query), কলাম = যাকে দেখা হচ্ছে (key). ✕ মানে −∞. T = ৫, শেষ ২টি pad. দুটি mask-এর OR নিলে ডানেরটি।

কেন softmax-এর আগে? −∞-এর e-ঘাত ঠিক ০, তাই নিষিদ্ধ ঘর weight পায় না, আর বাকিদের যোগফল আপনাআপনি ১ থাকে। Softmax-এর পরে শূন্য করলে সারির যোগফল ১-এর নিচে নেমে যেত। Causal mask বানাতে উপরের ত্রিভুজ, মানে j > i: np.triu(..., k=1). Shape মেলাতে broadcasting: causal (T, T) আর pad (B, 1, T) মিলে (B, T, T).

দুটি mask-এর আকারের পার্থক্যটি খেয়াল করুন। Causal mask সব উদাহরণের জন্য এক, কারণ এটি শুধু অবস্থানের ওপর নির্ভর করে। Padding mask প্রতিটি উদাহরণের জন্য আলাদা, কারণ দৈর্ঘ্য আলাদা। আর pad আটকানো হয় কলামে, অর্থাৎ key হিসেবে: pad-কে কেউ দেখবে না। Encoder-এ শুধু padding mask লাগে। Decoder-এর self-attention-এ দুটিই। Cross-attention-এ encoder-এর padding mask, কারণ key তখন encoder-এর token.

হাতে একবার করুন। অংশ ৩-এর S-এর দ্বিতীয় সারি ছিল [১.৪১৪২, ০.৭০৭১, ২.১২১৩]. Causal mask তৃতীয় ঘরে −∞ বসায়। e-ঘাত হয় ৪.১১৩৩, ২.০২৮১, ০; যোগফল ৬.১৪১৪. তাই সারিটি [০.৬৬৯৮, ০.৩৩০২, ০]. Mask ছাড়া ছিল [০.২৮৪০, ০.১৪০০, ০.৫৭৬০]. লক্ষ করুন, বাকি দুই ঘরের অনুপাত একই থাকে (প্রায় ২.০৩ : ১), শুধু যোগফল আবার ১ হয়। Softmax-এর আগে mask দেওয়ার সুবিধা ঠিক এটিই।

import numpy as np
np.set_printoptions(precision=2, suppress=True, linewidth=60)
rng = np.random.default_rng(7)
T, d = 5, 8
X = rng.normal(size=(2, T, d))                # batch: দৈর্ঘ্য ৫ আর ৩ (শেষে ২টি pad)
pad = np.array([[0, 0, 0, 0, 0], [0, 0, 0, 1, 1]], dtype=bool)
causal = np.triu(np.ones((T, T), dtype=bool), k=1)
block = causal[None] | pad[:, None, :]        # (1,T,T) | (2,1,T) → (2,T,T)
print("causal:\n", causal.astype(int))
print("pad-সহ, উদাহরণ ১:\n", block[1].astype(int))

def attend(X, block):
    S = X @ X.transpose(0, 2, 1) / np.sqrt(d)
    S = np.where(block, -np.inf, S)           # softmax-এর আগে −∞
    e = np.exp(S - S.max(-1, keepdims=True))
    A = e / e.sum(-1, keepdims=True)
    return A @ X, A

Z, A = attend(X, block)
print("A, উদাহরণ ১:\n", A[1])
print("সারির যোগফল:", A[1].sum(-1))
Y = X.copy(); Y[1, 3:] = 99.0                 # শুধু pad-এর ভেতরের মান বদল
Zp, _ = attend(Y, block)
print("pad বদলালে আসল token-এ পরিবর্তন:", np.abs(Zp[1, :3] - Z[1, :3]).max())
Y = X.copy(); Y[0, 4] = 99.0                  # উদাহরণ ০-এর শেষ token বদল
Zf, _ = attend(Y, causal[None] | pad[:, None, :])
print("ভবিষ্যৎ বদলালে আগের token-এ পরিবর্তন:", np.abs(Zf[0, :4] - Z[0, :4]).max())
left = np.array([[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]], dtype=bool)  # বাঁয়ে pad
_, B = attend(X, causal[None] | left[:, None, :])
print("বাঁয়ে pad + causal, A-এর সারি ০:", B[0, 0], "| NaN সারি:", np.isnan(B[0]).any(-1).astype(int))

আউটপুট (পাতার Python-এ চালানো):

causal:
 [[0 1 1 1 1]
 [0 0 1 1 1]
 [0 0 0 1 1]
 [0 0 0 0 1]
 [0 0 0 0 0]]
pad-সহ, উদাহরণ ১:
 [[0 1 1 1 1]
 [0 0 1 1 1]
 [0 0 0 1 1]
 [0 0 0 1 1]
 [0 0 0 1 1]]
A, উদাহরণ ১:
 [[1.   0.   0.   0.   0.  ]
 [0.04 0.96 0.   0.   0.  ]
 [0.09 0.27 0.64 0.   0.  ]
 [0.16 0.1  0.74 0.   0.  ]
 [0.2  0.5  0.29 0.   0.  ]]
সারির যোগফল: [1. 1. 1. 1. 1.]
pad বদলালে আসল token-এ পরিবর্তন: 0.0
ভবিষ্যৎ বদলালে আগের token-এ পরিবর্তন: 0.0
বাঁয়ে pad + causal, A-এর সারি ০: [nan nan nan nan nan] | NaN সারি: [1 1 0 0 0]

A-র প্রথম সারিতে শুধু ১টি ঘর, কারণ প্রথম token শুধু নিজেকে দেখতে পারে। কলাম ৩ আর ৪ পুরো শূন্য: pad-কে কেউ দেখে না। Pad আর ভবিষ্যৎ বদলে পরিবর্তন ঠিক ০.০: এই দুটি লাইন যেকোনো mask-এর সেরা পরীক্ষা। সারি ৩ আর ৪-ও মান পায়, যদিও এরা নিজেরাই pad; এগুলোর output loss-এ না গোনার দায়িত্ব ignore_index-এর (পর্ব ১৩)।

শেষ লাইনটিই ফাঁদ। বাঁয়ে pad থাকলে প্রথম ঘরের pad token causal mask-এ শুধু নিজেকে দেখতে পারে, আর padding mask সেটিও আটকায়। পুরো সারি −∞, ফল NaN. NaN সারি pad-এর, কিন্তু পরের স্তরে বা loss-এ গেলে সব নষ্ট করতে পারে। বাঁয়ে padding কেন লাগে, তা পর্ব ৩৭-এ (দক্ষ attention) আসবে।

৬ · Multi-head — কাটা, আলাদা attention, জোড়া

পরীক্ষার খাতা একজন না দেখে আটজন দেখছেন, কিন্তু প্রত্যেকে ছোট একটি অংশ। একজন যা খুঁজছেন, অন্যজন হয়তো তা নয়। শেষে আটজনের নম্বর জুড়ে একটি ফল। কে কী দেখবেন, তা আগে থেকে ঠিক করা নয়; model নিজেই শেখে, আর শেখা ভাগ সবসময় মানুষের বোঝার মতো হয় না।

একটি attention একটি সারিতে একটিই ওজনের ছক দেয়। Multi-head attention-এ h-টি ছক। Vector d-কে h ভাগে কাটা হয়, dk = d/h. প্রতি ভাগে আলাদা attention, তারপর জোড়া, শেষে আরেকটি projection WO.

Split, attend, concat Q, K, V: প্রতি token-এ d ঘর d/hd/hd/h Head 1Head 2Head h attentionattentionattention Concat: আবার d W_O: output (B, T, d)
Head-গুলোর জন্য আলাদা W লাগে না: একই WQ-এর output কেটে নেওয়া হয়।

২০১৭ সালের প্রবন্ধের মূল মডেলের মান ছিল d = ৫১২, h = ৮. সেই মান ধরে প্রতিটি shape গুনে দেখি।

d_k = 512 / 8 = 64
কাজ
Shape
হিসাব
  1. Input. ৩২টি review, ১০০ token, ৫১২ সংখ্যা।
  2. Projection. তিনটি Linear, bias-সহ। Shape একই, parameter ৭,৮৭,৯৬৮.
  3. কাটা. শেষ অক্ষকে ৮ × ৬৪ করা। শুধু আকার বদলায়, মান নয়।
  4. Transpose. Head-কে batch-এর মতো সামনে আনা। পর্ব ৭-এর batched matmul এবার শেষ দুই অক্ষে চলবে।
  5. Score. প্রতি head-এ (100, 100). মোট গুণের কাজ ৮ head মিলে T²·d: একটি বড় head-এর সমান।
  6. Softmax. Shape একই। ৩২ × ৮ × ১০০ × ১০০ = ২৫,৬০,০০০টি weight; float32-এ ১ কোটি ২ লাখ বাইটের বেশি।
  7. মিশ্রণ. T × T গুণ T × ৬৪, তাই আবার (100, 64), প্রতি head-এ।
  8. জোড়া. Transpose করে reshape. এখানে transpose বাদ দিলে কোনো error আসে না, কিন্তু মান গুলিয়ে যায়।
  9. শেষ projection. Head-গুলোর ফল মেশানো। মোট parameter ৪ × ২,৬২,৬৫৬ = ১০,৫০,৬২৪.

এবার একই ছাঁচ NumPy-তে, ছোট মানে (d = ৬৪, h = ৮), shape পরীক্ষা-সহ। কোডের শেষ তিন লাইন পরের অংশের প্রশ্নের জন্য।

import numpy as np
rng = np.random.default_rng(42)
B, T, d, h = 2, 6, 64, 8
dk = d // h
Wq, Wk, Wv, Wo = rng.normal(size=(4, d, d)) / np.sqrt(d)

def split(x):                                 # (B,T,d) → (B,h,T,dk)
    return x.reshape(x.shape[0], x.shape[1], h, dk).transpose(0, 2, 1, 3)

def mha(X, show=False):
    Q, K, V = split(X @ Wq), split(X @ Wk), split(X @ Wv)
    S = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(dk)
    e = np.exp(S - S.max(-1, keepdims=True))
    A = e / e.sum(-1, keepdims=True)
    H = A @ V                                 # প্রতিটি head আলাদা
    C = H.transpose(0, 2, 1, 3).reshape(X.shape)   # concat
    if show:
        for n, a in [("X", X), ("Q", Q), ("S", S), ("A", A), ("H", H), ("C", C)]:
            print(f"{n}: {a.shape}")
    return C @ Wo

X = rng.normal(size=(B, T, d))
Z = mha(X, show=True)
assert Z.shape == X.shape
Q = split(X @ Wq); manual = X[0, 3] @ Wq      # split কি শুধু কাটা? token ৩, head ২
print("Q[0,2,3] = projection-এর ১৬–২৩ ঘর?", np.allclose(Q[0, 2, 3], manual[16:24]))

print("params (bias ছাড়া) 4d² =", 4 * d * d, "| h = 8-এ head-প্রতি dk =", dk)
p = rng.permutation(T)                        # token-এর ক্রম এলোমেলো
print("perm:", p)
print("Z(PX) বনাম P·Z(X), max|diff|:", np.abs(mha(X[:, p]) - Z[:, p]).max())
pos = rng.normal(size=(T, d))                 # প্রতিটি ঘরের একটি নিজস্ব vector
base = mha(X + pos)
print("অবস্থান যোগ করলে, max|diff|:", np.abs(mha(X[:, p] + pos) - base[:, p]).max().round(3))

আউটপুট (পাতার Python-এ চালানো):

X: (2, 6, 64)
Q: (2, 8, 6, 8)
S: (2, 8, 6, 6)
A: (2, 8, 6, 6)
H: (2, 8, 6, 8)
C: (2, 6, 64)
Q[0,2,3] = projection-এর ১৬–২৩ ঘর? True
params (bias ছাড়া) 4d² = 16384 | h = 8-এ head-প্রতি dk = 8
perm: [0 2 5 4 3 1]
Z(PX) বনাম P·Z(X), max|diff|: 6.661338147750939e-16
অবস্থান যোগ করলে, max|diff|: 2.867

S-এর shape (2, 8, 6, 6): ৮টি head, প্রতিটিতে ৬ × ৬ ছক। আর “১৬–২৩ ঘর” লাইনটি বলছে, head ২ মানে আলাদা কোনো W নয়, projection-এরই একটি টুকরো। মোট parameter তাই h-এর ওপর নির্ভর করে না; h শুধু ঠিক করে একই vector কয় ভাগে কাটা হবে।

এই সূত্রের দুটি ব্যবহারিক ফল আছে। এক, h অবশ্যই d-কে নিঃশেষে ভাগ করবে। ৫১২ ঘরকে ৭ ভাগে কাটা যায় না। দুই, h বাড়লে dk কমে: d = ৫১২-এ h = ১৬ দিলে head-প্রতি মাত্র ৩২ ঘর। WO কেন লাগে? Concat-এর পর head-গুলোর ঘর পাশাপাশি বসে থাকে, মেশে না। WO প্রতিটি token-এর ভেতরে সেগুলো মেশায়, যাতে এক head-এর ফল পরের স্তরের সব head-এর কাজে আসে। Einsum-এ score লেখা যায় np.einsum("bhtd,bhsd->bhts", Q, K): অক্ষের নাম দেখেই বোঝা যায়, d মিলিয়ে যোগ হচ্ছে, আর t, s আলাদা থাকছে।

৭ · ক্রম বদলালে কী হয় — permutation equivariance

আগের আউটপুটের শেষ তিন লাইন দেখুন। Token-এর ক্রম এলোমেলো করে চালালে output-ও ঠিক একইভাবে এলোমেলো হলো; পার্থক্য শুধু floating-point-এর ত্রুটি। একে বলে permutation equivariance. “ডেলিভারি দেরি হয়নি, দাম ভালো” আর “দাম দেরি হয়নি, ডেলিভারি ভালো”-তে একই শব্দ একই vector পেলে “দেরি”-র output দুই বাক্যে হুবহু এক। অর্থ বদলাল, কিন্তু model টের পেল না।

কারণ হাতে দেখানো যায়। ধরুন P একটি permutation matrix, আর PᵀP = I. তাহলে:

X → PX ⇒ Q, K, V → PQ, PK, PV

S → P S Pᵀ ⇒ A → P A Pᵀ

Z → (P A Pᵀ)(P V) = P A V = P Z

Softmax সারি ধরে চলে, আর সারির ভেতরের ক্রম বদলালে softmax-এর মান একইভাবে সরে যায়, তাই দ্বিতীয় লাইন ঠিক আছে। শেষ লাইনে PᵀP = I কাজ করে।

হাতের হিসাবেও দেখা যায়। অংশ ৩-এর উদাহরণে “ফোন” আর “ভালো” অদলবদল করলে X = [[0,1],[1,0],[1,1]] হয়। Q, K, V-এর প্রথম দুই সারিও অদলবদল হয়, A-র প্রথম দুই সারি আর প্রথম দুই কলামও। তাই নতুন output হয় Z₂, Z₁, Z₃: [০.৮৬০০, ১.৪৩২০], [০.৮০২২, ১.১৯৭৮], [০.৯২৫৭, ১.৩৮৮৬]. মান একই, শুধু জায়গা বদল।

কোনো ঘরে অবস্থানের একটি নিজস্ব vector যোগ করলে (শেষ লাইন) এই সমতা ভেঙে যায়: পার্থক্য আর ত্রুটির মাপে থাকে না। সে কারণে পরের পর্ব ৩৪ (Positional encoding — sinusoidal, learned আর RoPE) দরকার। আর causal mask-ও অবস্থান-নির্ভর, তাই সেটি থাকলেও এই সমতা থাকে না; অনুশীলনে মাপবেন।

এটি সবসময় দোষ নয়: যে ডেটায় ক্রম নেই, যেমন একটি অর্ডারের পণ্যের তালিকা, সেখানে এই ধর্মটি আসলে সুবিধা। আসল প্রশ্ন, আপনার ডেটায় ক্রমের অর্থ আছে কি না।

৮ · সত্যিকারের run — নিজের module বনাম PyTorch

PyTorch পাতার ভেতরে চলে না, তাই এটি ডেস্কটপের সত্যিকারের run: Python 3.12.13, লেখার সময়ের torch 2.15.0, CPU build. নিচের class-টি যেমন চলেছে তেমনই।

import math, torch
import torch.nn as nn, torch.nn.functional as F

class MyMHA(nn.Module):
    def __init__(self, d, h, bias=True):
        super().__init__()
        assert d % h == 0, "d must divide evenly by h"
        self.d, self.h, self.dk = d, h, d // h
        self.wq, self.wk, self.wv, self.wo = (nn.Linear(d, d, bias=bias) for _ in range(4))

    def split(self, x):                      # (B,T,d) -> (B,h,T,dk)
        B, T, _ = x.shape
        return x.view(B, T, self.h, self.dk).transpose(1, 2)

    def forward(self, x, attn_mask=None, key_padding_mask=None, use_sdpa=False):
        B, T, _ = x.shape
        Q, K, V = self.split(self.wq(x)), self.split(self.wk(x)), self.split(self.wv(x))
        block = None                         # True = not allowed to look
        if attn_mask is not None:
            block = attn_mask[None, None]    # (1,1,T,T)
        if key_padding_mask is not None:
            pad = key_padding_mask[:, None, None, :]   # (B,1,1,T)
            block = pad if block is None else block | pad
        if use_sdpa:                         # note the ~ : True = may look
            m = None if block is None else ~block
            Z, A = F.scaled_dot_product_attention(Q, K, V, attn_mask=m), None
        else:
            S = Q @ K.transpose(-2, -1) / math.sqrt(self.dk)
            if block is not None:
                S = S.masked_fill(block, float("-inf"))
            A = S.softmax(-1)
            Z = A @ V                        # (B,h,T,dk)
        Z = Z.transpose(1, 2).reshape(B, T, self.d)
        return self.wo(Z), A

এবার nn.MultiheadAttention-এ একই weight বসিয়ে মেলাই। এই module তিনটি W একসঙ্গে একটি parameter-এ রাখে। আউটপুটের প্রথম লাইনে দেখবেন in_proj_weight-এর shape (96, 32): ৩ × ৩২ সারি, যা ভেতরে তিন ভাগে কাটা হয়। তাই আমাদের তিনটি weight torch.cat দিয়ে সারি বরাবর জোড়া হয়েছে, Q, K, V ক্রমে। average_attn_weights=False না দিলে head-গুলোর গড় ফেরত আসে, তখন shape (B, T, T). পরীক্ষার script-এর মূল অংশ সংক্ষেপ করে দেখানো হলো; পুরো script-এ আরও কয়েকটি একই ধরনের তুলনা আছে, আউটপুটে সেগুলোও এসেছে।

B, T, d, h = 2, 5, 32, 4                     # torch.manual_seed(42)
x = torch.randn(B, T, d)
pad = torch.tensor([[0,0,0,0,0],[0,0,0,1,1]], dtype=torch.bool)
causal = torch.triu(torch.ones(T, T, dtype=torch.bool), 1)
mine = MyMHA(d, h).eval()
ref = nn.MultiheadAttention(d, h, dropout=0.0, batch_first=True).eval()
with torch.no_grad():
    ref.in_proj_weight.copy_(torch.cat([mine.wq.weight, mine.wk.weight, mine.wv.weight], 0))
    ref.in_proj_bias.copy_(torch.cat([mine.wq.bias, mine.wk.bias, mine.wv.bias], 0))
    ref.out_proj.weight.copy_(mine.wo.weight)
    ref.out_proj.bias.copy_(mine.wo.bias)
    a, A = mine(x, causal, pad)
    b, _ = mine(x, causal, pad, use_sdpa=True)
    c, W = ref(x, x, x, attn_mask=causal, key_padding_mask=pad,
               need_weights=True, average_attn_weights=False)
~/bazarghar-dl$ uv run python check_match.py
[('in_proj_weight', (96, 32)), ('in_proj_bias', (96,)), ('out_proj.weight', (32, 32)), ('out_proj.bias', (32,))]
output shape (2, 5, 32) weights shape (2, 4, 5, 5)
mine vs my sdpa path      max|diff| = 8.94e-08
mine vs nn.MHA (weights)  max|diff| = 1.19e-07
mine vs nn.MHA (no wts)   max|diff| = 1.19e-07
attn weights mine vs nn   max|diff| = 5.96e-08
sdpa right vs wrong mask polarity max|diff| = 1.61, NaN in wrong: False
pad content changed: real tokens max|diff| = 0.00e+00
last token changed, causal: earlier max|diff| = 0.00e+00, last = 69.7
last token changed, no mask: earlier max|diff| = 105.1
left pad + causal, NaN per position (mine) : [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]]
left pad + causal, NaN per position (nn)   : [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]]
left pad + causal, NaN per position (sdpa) : [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]

পার্থক্য ১০−৭-এর মাপে: float32-এর গোলকরণের ত্রুটি। তিনটি পথ একই গণিত করছে। কিন্তু দুটি লাইন আলাদা করে পড়ুন।

Mask-এর অর্থ উল্টো। একই boolean mask দুই API-তে দুই রকম মানে দেয়। ভুল দিকে দিলে কোনো error নেই, NaN-ও নেই; এখানে output ১.৬১ পর্যন্ত বদলে গেছে, নীরবে। টেবিলটি মুখস্থ না করে প্রতিবার docs দেখাই নিরাপদ।

যেখানেTrue বা 1-এর মানে
nn.MultiheadAttention: key_padding_mask, attn_maskবাদ দিন
F.scaled_dot_product_attention: attn_maskঅংশ নেবে
Tokenizer-এর attention_maskআসল token

NaN সব পথে এক নয়। বাঁয়ে pad আর causal একসঙ্গে দিলে আমাদের module আর nn.MultiheadAttention-এ NaN, কিন্তু F.scaled_dot_product_attention-এ NaN নেই (পুরো আটকানো সারির output সেখানে শূন্য এসেছে)। একই nn.MultiheadAttention-এর ভেতরেও পথ ভেদে ফল আলাদা:

eval need_weights True [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]]
eval need_weights False [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]]
train need_weights True [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0]]
train need_weights False [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]
sdpa row 0 first head, all zero? 0.0 NaN False

মানে এই version-এ train-এ need_weights=False ঠিক চলল, অথচ eval-এ একই mask-এ NaN. ভেতরের কোডের পথ সম্ভবত আলাদা; অন্য version-এ এই ফল নাও মিলতে পারে। শিক্ষা: pad সারির ওপর ভরসা না করে নিজে পরীক্ষা করুন, আর সম্ভব হলে এমন mask দিন যাতে কোনো সারি পুরো আটকে না যায়।

তাহলে কোনটি ব্যবহার করবেন? শেখার জন্য নিজে লেখা ভালো, কারণ প্রতিটি shape দেখা যায়। নিজের model-এ সাধারণত F.scaled_dot_product_attention সুবিধার: হিসাবটি এক লাইনে, আর PyTorch নিজে দ্রুত পথ বেছে নিতে পারে, তবে এটি weight ফেরত দেয় না; weight দেখতে চাইলে নিজের পথ লাগে। nn.MultiheadAttention-এ সব একসঙ্গে পাওয়া যায়, কিন্তু default, mask আর ভেতরের পথগুলো মনে রাখতে হয়। যেটিই নিন, উপরের মতো ছোট একটি তুলনা আর leak test সঙ্গে রাখুন।

Parameter গোনা

~/bazarghar-dl$ uv run python count_params.py
d= 512 h= 1 dk= 512  mine=1,050,624  nn.MHA=1,050,624  formula 4d^2+4d=1,050,624
d= 512 h= 2 dk= 256  mine=1,050,624  nn.MHA=1,050,624  formula 4d^2+4d=1,050,624
d= 512 h= 8 dk=  64  mine=1,050,624  nn.MHA=1,050,624  formula 4d^2+4d=1,050,624
d= 512 h=16 dk=  32  mine=1,050,624  nn.MHA=1,050,624  formula 4d^2+4d=1,050,624
d=  64 h= 8 dk=   8  mine=   16,640  nn.MHA=   16,640  formula 4d^2+4d=   16,640
bias=False, d=512, h=8: 1,048,576 = 4d^2 = 1,048,576
  wq: 262,656
  wk: 262,656
  wv: 262,656
  wo: 262,656
nn.MHA(512, 7) -> embed_dim must be divisible by num_heads

h = ১ থেকে ১৬, parameter এক। Attention-এর parameter T-এর ওপরও নির্ভর করে না: ১০ শব্দের বাক্য আর ১,০০০ শব্দের লেখায় একই W. আর d দ্বিগুণ করলে parameter প্রায় চার গুণ। ৫১২ ঘরকে ৭ ভাগে কাটা যায় না, তাই PyTorch আগেই থামিয়ে দেয়।

৯ · খরচ — O(T²) মেপে দেখা

Parameter না বাড়লেও সময় বাড়ে। S-এ T × T ঘর, তাই গুণের কাজ T²·d, আর A-র মেমরি h·T² সংখ্যা। অন্যদিকে W দিয়ে projection T-এর সঙ্গে সমানুপাতিক। Big-O-এর ধারণা আছে DSA কোর্সের পর্ব ৪-এ। মাপা হলো CPU-তে, ১টি thread-এ, d = ২৫৬, h = ৮, B = ১; প্রতিটি T-তে ১৫ বারের দ্রুততমটি।

~/bazarghar-dl$ uv run python timing.py
torch 2.15.0+cpu, CPU, 1 thread, d=256, h=8, B=1, fastest of 15
    T        ms  ratio  proj ms    A MB
   64      0.62            0.29     0.1
  128      1.47   2.38     0.61     0.5
  256      3.53   2.40     1.13     2.0
  512     10.24   2.90     2.10     8.0
 1024     33.43   3.27     4.11    32.0
 2048    137.48   4.11     8.15   128.0
 4096    755.21   5.49    16.23   512.0
log-log slope, T=1024..4096: 2.25
log-log slope, T=64..256: 1.26

এটি একটি যন্ত্রে একটি run, তাই ধারাটি দেখুন, সংখ্যাটি নয়। ছোট T-তে T দ্বিগুণে সময় প্রায় ২.৪ গুণ, কারণ তখন projection আর অন্য খরচের ভাগ বড়, আর সেগুলো T-এর সঙ্গে সমানুপাতিক (proj ms কলামটি প্রায় দ্বিগুণ হচ্ছে)। বড় T-তে T²-এর পদ জেতে, অনুপাত ৪-এর আশেপাশে আসে। ৫.৪৯ যে ৪-এর বেশি, সেটি হয়তো cache-এ না ধরার খরচ; এটি আমরা আলাদা করে মাপিনি। শেষ কলাম: T = ৪,০৯৬-এ শুধু A-র জন্যই ৫১২ MB (২২০ বাইটে), একটি উদাহরণে, একটি স্তরে।

হাতে হিসাব করতে পারলে অবাক হতে হয় না। A-র মেমরি = B × h × T² × প্রতি সংখ্যার বাইট। B = ৩২, h = ৮, T = ৫১২, float32 ধরলে ৩২ × ৮ × ২,৬২,১৪৪ × ৪ = ২৬,৮৪,৩৫,৪৫৬ বাইট, ঠিক ২৫৬ MiB, এক স্তরে। ১২টি স্তরে ৩ GiB-এর মতো, যদি সব একসঙ্গে রাখা হয়; backward-এর জন্য autograd অনেক মধ্যবর্তী মান রাখে বলে training-এ এমন হিসাব গুরুত্বপূর্ণ (পর্ব ১২)। T অর্ধেক করলে এই মেমরি এক-চতুর্থাংশ, B অর্ধেক করলে শুধু অর্ধেক। তাই batch ছাঁটার চেয়ে লম্বা sequence ছাঁটা বা bucketing অনেক সময় বেশি কাজের।

RNN-এর সঙ্গে তুলনা: RNN-এর এক স্তরের মোট খরচ T-এর সঙ্গে সমানুপাতিক, কিন্তু ধাপগুলো পরপর চলে। Self-attention-এ খরচ T², কিন্তু কাজ একসঙ্গে চলতে পারে, আর দুই token-এর দূরত্ব এক ধাপ। কোনটি কখন বেশি লাভের, তা T, যন্ত্র আর কাজের ওপর নির্ভর করে। খরচ কমানোর উপায় আসবে পর্ব ৩৭-এ।

আরও গভীরে: Vaswani ও সহকর্মীদের প্রবন্ধের একটি footnote-এ √dk-এর যুক্তিটি আছে, ঠিক এই variance হিসাবে; একই প্রবন্ধের টেবিল ১-এ প্রতি স্তরের খরচ O(n²·d), RNN-এর O(n·d²). F.scaled_dot_product_attention হার্ডওয়্যার অনুযায়ী ভিন্ন backend বেছে নিতে পারে, পর্ব ৩৭-এ এর ধারণা আসবে। পরের গবেষণায় head-গুলোর key ও value ভাগ করে নেওয়ার ধারণাও এসেছে (Shazeer ২০১৯; Ainslie ও সহকর্মীরা ২০২৩), মূলত decoding-এর খরচ কমাতে।

পরীক্ষা ও ইন্টারভিউতে: √dk কেন? Var(q·k) = dk; ভাগ করলে ১, softmax saturate করে না। d = ৫১২, h = ৮ হলে S-এর shape? (B, 8, T, T), head-প্রতি dk = ৬৪. Parameter? ৪d² + ৪d = ১০,৫০,৬২৪, h-নিরপেক্ষ। Mask কোথায়? Softmax-এর আগে, −∞. Position ছাড়া কী হয়? Permutation equivariant, ক্রমের খবর থাকে না। T দ্বিগুণে? বড় T-তে সময় ও A-র মেমরি প্রায় ৪ গুণ।

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

AI assistant multi-head attention ঝটপট লিখে দেয়, আর বেশির ভাগ ভুলে কোনো error আসে না। ভুল shape ঠিকই মিলে যায়, মান শুধু ভুল হয়।

যে ভুল প্রায়ই দেখা যায়ফলকীভাবে ধরবেন
Reshape আছে, transpose নেইShape মেলে, head আর token মিশে যায়লুপে লেখা ধীর সংস্করণের সঙ্গে মেলানো
nn.MultiheadAttention-এ batch_first=True নেইDefault-এ (T, B, d) ধরে, B আর T অদলবদলWeight-এর shape (B, T, T) কি না ছাপানো
Mask-এর True উল্টোনীরবে ভবিষ্যৎ দেখে, বা শুধু ভবিষ্যৎ দেখেভবিষ্যৎ token বদলে আগের output মেলানো
Softmax-এর ভুল অক্ষ, বা √dk নেইRow-এর যোগফল ১ নয়, বা saturateA.sum(-1) আর সবচেয়ে বড় weight দেখা
Softmax-এর পরে maskযোগফল ১ থাকে নাএকই A.sum(-1)
পুরোনো বা বানানো API, ভুল argumentError বা ভিন্ন আচরণনিজের version-এর docs আর ছোট batch-এ চালানো
“Attention model-এর বোঝাপড়া দেখায়” ধরনের দাবিঅতিরঞ্জনপর্ব ৩২-এর সতর্কতা, আর মূল প্রবন্ধ দেখা

AI-কে কোডের সঙ্গে প্রমাণও চাইতে বলুন:

আমি PyTorch-এ শূন্য থেকে একটি multi-head self-attention module বানাচ্ছি। আমার version: torch 2.15. ১. (B, T, d) ইনপুটের module দাও, প্রতিটি লাইনের পাশে shape কমেন্টে লেখো। ২. আমার module-এর weight nn.MultiheadAttention(batch_first=True)-এ কপি করে output মেলানোর পরীক্ষা লেখো। ৩. Causal আর padding mask দাও। প্রতিটি API-তে True-র মানে কী, তা docs-এর সঙ্গে মিলিয়ে বলো। ৪. দুটি leak test লেখো: pad-এর মান বদলালে আসল token বদলায় না; ভবিষ্যৎ token বদলালে আগের output বদলায় না। ৫. একটি permutation test লেখো: mask ছাড়া Z(PX) = P·Z(X). ৬. যেসব API বা citation তুমি যাচাই করোনি, তা স্পষ্ট করে বলো।

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

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

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

প্র ০১Additive attention-এর তো √dk লাগত না। তাহলে Transformer dot product নিয়ে এই ঝামেলা কেন নিল? আর আজও কি √dk-ই একমাত্র পথ?

Bahdanau ও সহকর্মীদের ২০১৪ সালের additive attention একটি ছোট network দিয়ে score হিসাব করে, তাই score-এর স্কেলও শেখা যায়। Luong ও সহকর্মীরা ২০১৫ সালে dot product-সহ সরল রূপগুলো পরীক্ষা করেন। ২০১৭ সালের Vaswani ও সহকর্মীদের প্রবন্ধ জানায়, ছোট dk-এ দুই ধরন কাছাকাছি, কিন্তু বড় dk-এ scaling ছাড়া dot product পিছিয়ে পড়ে; এখানে তাঁরা Britz ও সহকর্মীদের কাজ উদ্ধৃত করেন। তাঁদের অনুমান ছিল, dot product বড় হয়ে softmax-কে খুব ছোট gradient-এর অঞ্চলে ঠেলে দেয়। সমাধান একটি ভাগ।

Dot product নেওয়ার বড় কারণ হলো গতি, আর প্রবন্ধটিও সে কথাই বলে। QKᵀ একটি matrix গুণ, যা BLAS আর GPU-তে অত্যন্ত দ্রুত। Additive-এ T² জোড়ার প্রতিটিতে ছোট network চালাতে হয়; তত্ত্বে একই ক্রমের হলেও বাস্তবে ধীর ও বেশি মেমরির।

√dk শেষ কথা নয়। এটি আগে ধরে নেওয়া একটি variance-এর ওপর দাঁড়ানো। Swin Transformer V2 (Liu ও সহকর্মীরা) cosine similarity-র সঙ্গে শেখা temperature ব্যবহার করে, যাতে score-এর স্কেল vector-এর দৈর্ঘ্যের ওপর কম নির্ভর করে; তাঁদের যুক্তি বড় মাপের vision model-এর ক্ষেত্রে। অনেক open language model-এর কোডে এখনো scaled dot product-ই দেখা যায়, তবে এটিকে সার্বজনীন নিয়ম ধরা ঠিক হবে না। আর আমাদের পরিমাপও সীমিত: variance-এর হিসাবটি কৃত্রিম Gaussian ডেটায়, কোনো পূর্ণ language model-এ নয়। সত্যিকারের model-এ কতটা পার্থক্য হবে, তা স্থাপত্য, initialization আর normalization-এর ওপর নির্ভর করে।

মূল উপলব্ধি: ছোট একটি স্কেলিং-এর পেছনে থাকে বিতরণের একটি অনুমান; অনুমান বদলালে স্কেলিংও বদলাতে পারে।

প্র ০২T² খরচ তো RNN-এর চেয়ে খারাপ শোনায়। তাহলে Transformer কীভাবে RNN-কে বেশির ভাগ কাজে সরিয়ে দিল?

Vaswani ও সহকর্মীদের প্রবন্ধের টেবিল ১-এ তিনটি মাপ পাশাপাশি আছে। প্রতি স্তরের জটিলতা: self-attention O(n²·d), recurrent O(n·d²), convolution O(k·n·d²). পরপর করতে হওয়া ন্যূনতম ধাপ: self-attention আর convolution O(1), recurrent O(n). দুই token-এর মধ্যে সর্বোচ্চ পথ: self-attention O(1), recurrent O(n), convolution O(logk n).

দেখুন, d-এর ঘাত আলাদা। n যদি d-এর চেয়ে ছোট হয়, n²·d আসলে n·d²-এর চেয়ে কম। আর আসল লাভ শেষ দুটি মাপে। RNN-এর ধাপ t, ধাপ t−1-এর ফল ছাড়া শুরুই হতে পারে না, তাই GPU-র সমান্তরাল শক্তি পুরো কাজে লাগে না। Self-attention-এ সব token একসঙ্গে। আর পর্ব ১৭-এ দেখেছেন, লম্বা পথে gradient ভোগে; এক ধাপের পথ এই সমস্যা অনেকটা কমায়। তবে এই লাভের দাম আছে: সব token একসঙ্গে দেখতে হলে পুরো sequence মেমরিতে রাখতে হয়।

তবে “RNN শেষ” বলা বাড়াবাড়ি। অল্প মেমরির যন্ত্রে, স্ট্রিমিং ডেটায়, প্রতিটি নতুন মানে সঙ্গে সঙ্গে ফল দিতে হলে recurrent ধাঁচের model এখনো ব্যবহৃত হয়। আর Transformer-এর মেমরি আমাদের run-এই দেখলেন: T = ৪,০৯৬-এ এক উদাহরণে এক স্তরে শুধু weight-এর জন্য ৫১২ MB, যদি সব weight রাখা হয়। এজন্যই long-context কৌশল নিয়ে এত কাজ (পর্ব ৩৭)। আবার ডেটা কম হলে Transformer সবসময় এগিয়ে থাকে, এমনও বলা যায় না; পর্ব ৩৮-এ ছবির ক্ষেত্রে এই প্রশ্ন ফিরে আসবে।

মূল উপলব্ধি: একটি O(·) দিয়ে স্থাপত্যের বিচার হয় না; সমান্তরাল চলা, পথের দৈর্ঘ্য আর বাস্তব যন্ত্র মিলেই সিদ্ধান্ত।

প্র ০৩Parameter আর গুণের কাজ যদি প্রায় একই থাকে, তাহলে একটি বড় head-এর বদলে আটটি ছোট head কেন? সত্যিই কি আটটিই কাজ করে?

মূল প্রবন্ধের যুক্তি: একটি attention-এ গড় করায় আলাদা আলাদা ধরনের সম্পর্ক মিলে যায়। একাধিক head একই সঙ্গে ভিন্ন “representation subspace”-এ তাকাতে দেয়। আমাদের হিসাবেও এটি সত্য যে একটি head প্রতিটি token-এর জন্য একটিই ওজনের সারি দেয়, আর আটটি head আটটি। অর্থাৎ একই বাজেটে বেশি রকম মিশ্রণ। কিন্তু “ভিন্ন subspace” কথাটি একটি অনুপ্রেরণা, প্রমাণ নয়; head-গুলো কী শেখে, তা training-এর আগে কেউ ঠিক করে দেয় না।

কিন্তু একটি সীমাও আছে। প্রতি head-এর vector ছোট হয়, dk = ৬৪: প্রতিটি head-এর score তুলনামূলক কম তথ্যে তৈরি হয়। আর h বাড়ালেই ভালো, এমন কোনো নিয়ম নেই।

গবেষণা এখানে সতর্ক হতে শেখায়। Michel, Levy ও Neubig-এর ২০১৯ সালের “Are Sixteen Heads Really Better than One?” দেখায়, শেখানো Transformer-এর অনেক head বাদ দিলেও তাঁদের পরীক্ষায় ফল খুব একটা কমে না। Voita ও সহকর্মীরা (২০১৯) অনুবাদের model-এ দেখান, কয়েকটি head-ই মূল কাজের বড় অংশ করে, আর অনেকগুলোকে ছাঁটা যায়। আবার এর মানে এই নয় যে শুরু থেকেই কম head দিলে একই ফল আসবে; বেশি head শেখাকে সহজ করে হয়তো, ছাঁটা হয় পরে। এটি এখনো পুরো মীমাংসিত নয়।

অন্যদিকে একটি head-কে “এটি subject খোঁজে” বলে নাম দেওয়ার আগে পর্ব ৩২-এর সতর্কতা মনে রাখুন: attention weight আপনাআপনি ব্যাখ্যা নয়। আর ব্যবহারিক কারণে head-এর খরচ কমানোর উপায়ও এসেছে, যেমন decoding-এ key-value ভাগ করা।

মূল উপলব্ধি: Multi-head একই খরচে বেশি রকম মিশ্রণ দেয়, কিন্তু প্রতিটি head-কে অর্থবহ ধরে নেওয়ার প্রমাণ দুর্বল; h একটি hyperparameter, কোনো জাদু নয়।

প্র ০৪Boolean mask-এর অর্থ এক-এক API-তে উল্টো হওয়া কি শুধুই খারাপ নকশা? এমন ভুল training-এ কীভাবে ধরা পড়ে, বা কেন পড়ে না?

এটি কোনো একজনের ভুল নয়, ইতিহাসের ফল। Tokenizer-এর attention_mask “কোন token আসল” জানায়, তাই ১ মানে রাখুন; ওই ধারণাটি স্বাভাবিক। nn.MultiheadAttention-এর ভাবনা “কোন ঘর ঢাকব”, তাই True মানে ঢাকুন। পরে আসা F.scaled_dot_product_attention আবার True মানে অংশ নেবে। প্রতিটি একা যুক্তিসংগত, পাশাপাশি বিপজ্জনক। আমাদের run-এ ভুল দিকে দেওয়া mask কোনো error বা NaN দেয়নি, শুধু output বদলাল।

কেন training এটি ধরে না? ধরুন causal mask উল্টো গেল। Model এখন নিজের ঘর ও ভবিষ্যৎ দেখছে, আর আগের token দেখতে পারছে না। অথবা mask একেবারে না থাকলে model লক্ষ্যটি সরাসরি পড়তে পারে। Decoder-এর লক্ষ্য তো এক ঘর সরানো ইনপুট (পর্ব ৩০), তাই পরের ঘর দেখলেই উত্তর পাওয়া যায়। Training loss চমৎকার নামে। Validation-ও teacher forcing-এ হলে একই রকম চমৎকার। ধরা পড়ে প্রকৃত generation-এ, যখন model হঠাৎ অর্থহীন লেখে, কারণ ভবিষ্যৎ তখন নেই।

তাই “ভালো loss” কোনো প্রমাণ নয়। সরাসরি পরীক্ষাই পথ: পরের token বদলান, আগের output ঠিক অপরিবর্তিত কি না দেখুন। আমাদের run-এ পরিবর্তন ঠিক ০, mask ছাড়া ১০৫.১. এই পরীক্ষা এক লাইনের, আর CI-তে রাখা যায়। একই ভাবনা আসে data leakage-এ, AI-এর ভিত্তি কোর্সেও যা দেখেছেন: নকল পাওয়া যত সহজ, মেট্রিক তত সুন্দর। Generation-এর একটি ছোট পরীক্ষাও রাখুন, কারণ সেখানে model-এর হাতে সত্যিই ভবিষ্যৎ থাকে না।

মূল উপলব্ধি: Mask-এর ভুল loss-এ ধরা পড়ে না, বরং loss-কে ভালো দেখায়; তাই আচরণ দিয়ে পরীক্ষা করতে হয়, নম্বর দিয়ে নয়।

প্র ০৫Self-attention যদি ক্রম না-ই জানে, তাহলে কি এটি sequence-এর চেয়ে set বা graph-এর জন্য বেশি স্বাভাবিক? Position যোগ করা কি একটি জোড়াতালি?

অনেকটা তাই। Mask আর position বাদ দিলে self-attention হলো একটি পূর্ণ সংযুক্ত graph: প্রতিটি token একটি node, প্রতিটি জোড়ায় শেখা ওজনের edge, আর A@V হলো এক ধাপ message passing. Veličković ও সহকর্মীদের Graph Attention Network (২০১৮) ঠিক এই ভাবনা সাধারণ graph-এ নেয়, যেখানে শুধু প্রতিবেশীর দিকে তাকানো হয়। Padding mask-ও একই ভাষায় বলা যায়: কয়েকটি edge কেটে দেওয়া।

যেখানে ইনপুট আসলেই set, সেখানে এই ধর্মটি সুবিধা। Zaheer ও সহকর্মীদের Deep Sets (২০১৭) দেখায়, set-এর function কেমন গঠনের হওয়া দরকার। Lee ও সহকর্মীদের Set Transformer (২০১৯) সরাসরি attention দিয়ে এমন model বানায়। বাজারঘরের ভাষায়, একটি অর্ডারের পণ্যের তালিকায় কোনটি আগে লেখা, তাতে কিছু আসে-যায় না হওয়াই স্বাভাবিক, আর তখন position না দেওয়াটাই ঠিক। তবে এই উদাহরণটি একটি কাল্পনিক দোকানের; আপনার ডেটায় তালিকার ক্রমে কোনো সংকেত আছে কি না, যেমন কোন পণ্য আগে কার্টে উঠল, সেটি আগে দেখে নিন।

ভাষায় ক্রমের অর্থ আছে, তাই position দিতে হয়। এটি জোড়াতালি, নাকি পরিষ্কার নকশা? দুই দিকেই যুক্তি আছে। পক্ষে: ক্রম ঢোকানোর উপায় স্বাধীনভাবে বদলানো যায়, আর সে কারণেই sinusoidal, শেখা ও RoPE-এর মতো নানা রূপ এসেছে (পর্ব ৩৪)। বিপক্ষে: CNN-এর কাছে অবস্থানের কাঠামো জন্মগত, Transformer-কে সেটি শিখতে হয়, আর ডেটা কম থাকলে এটি একটি বাড়তি বোঝা হতে পারে। পর্ব ৩৮-এ Vision Transformer-এ সে প্রশ্ন আবার আসবে।

মূল উপলব্ধি: প্রতিটি স্থাপত্যে ডেটা সম্পর্কে একটি অনুমান লুকানো থাকে; self-attention-এর অনুমান হলো “সবাই সবার সঙ্গে সম্পর্ক রাখতে পারে, ক্রম আলাদা খবর”.

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান।

  1. পূর্ণ numerical: (ক) ৩টি token-এ QKᵀ = [[1,0,1],[2,1,3],[3,1,4]], dk = ২. Causal mask দিয়ে A-র সারি ১ আর সারি ২ বের করুন (গণনা ১ থেকে)। (খ) d = ৭৬৮, h = ১২: dk আর মোট parameter (bias-সহ) কত? (গ) সেই model-এ B = ১, T = ২,০০০ হলে সব head-এর A float32-এ কত বাইট?

    (ক) সারি ১ শুধু নিজেকে দেখে: [১, ০, ০]. সারি ২: score [২, ১]/১.৪১৪২ = [১.৪১৪২, ০.৭০৭১]; e-ঘাত ৪.১১৩৩ আর ২.০২৮১; যোগ ৬.১৪১৪; A = [০.৬৬৯৮, ০.৩৩০২, ০]. (খ) dk = ৭৬৮/১২ = ৬৪. Parameter ৪ × ৭৬৮² + ৪ × ৭৬৮ = ২৩,৫৯,২৯৬ + ৩,০৭২ = ২৩,৬২,৩৬৮. (গ) ১২ × ২,০০০² × ৪ = ১৯,২০,০০,০০০ বাইট, প্রায় ১৮৩ MiB.

  2. সমালোচনা ও নকশা: একটি AI assistant এই কোড দিয়েছে: m = nn.MultiheadAttention(512, 8); x-এর shape (32, 100, 512); mask = torch.tril(torch.ones(100, 100)).bool(); out, _ = m(x, x, x, attn_mask=mask). কী কী ভুল? কোন পরীক্ষা দিয়ে ধরবেন?

    দুটি ভুল। এক, batch_first=True নেই। Default ধরে নেয় (T, B, d), তাই model ১০০ উদাহরণের ৩২ দৈর্ঘ্যের sequence দেখে। ১০০ × ১০০ mask তখন মেলে না, তাই torch 2.15.0-এর একটি run-এ এখানে RuntimeError এসেছে; সেটি ভাগ্যের কথা। Mask না থাকলে কোনো error-ই আসে না, আর ভুলটি নীরব থাকে। দুই, tril দিলে True নিচের ত্রিভুজে, আর এখানে True মানে বাদ। অর্থাৎ অতীত আর নিজের ঘর আটকানো, শুধু ভবিষ্যৎ খোলা: ঠিক উল্টো। শেষ সারি পুরো আটকে যাবে, NaN আসার ঝুঁকি। ঠিক রূপ: batch_first=True আর torch.triu(..., 1).

    পরীক্ষা: (১) need_weights=True দিয়ে weight-এর shape (B, T, T) কি না দেখা। (২) শেষ token বদলে আগের output ঠিক অপরিবর্তিত কি না। (৩) প্রথম সারির weight কি [১, ০, ০, …]. (৪) Output-এ NaN আছে কি না, train আর eval দুটিতেই।

  3. আউটপুট অনুমান করুন: কী ছাপা হবে? T = 4; m = np.triu(np.ones((T, T), dtype=bool), 1); pad = np.array([0, 0, 1, 1], dtype=bool); b = m | pad[None, :]; print(b.sum(1).tolist(), int((~b).sum()))

    [3, 2, 2, 2] 7. সারি ০-তে causal-ই কলাম ১, ২, ৩ আটকায়। সারি ১-এ কলাম ২, ৩. সারি ২-তে causal কলাম ৩, pad কলাম ২ ও ৩, মিলে ২টি। সারি ৩-এ causal কিছু না, pad ২টি। মোট আটকানো ৯, ১৬ ঘরের ৭টি খোলা।

  4. কোড লিখুন: mask ছাড়া আর causal mask-সহ NumPy-তে self-attention লিখুন (W ছাড়া, Q = K = V = X). T = ৫, d = ১৬, seed ৪২ দিয়ে X বানিয়ে এলোমেলো ক্রমের জন্য max|Z(PX) − P·Z(X)| মাপুন। আগে অনুমান করুন, কোন ক্ষেত্রে পার্থক্য ত্রুটির মাপে থাকবে।
    rng = np.random.default_rng(42)
    T, d = 5, 16
    X = rng.normal(size=(T, d))
    causal = np.triu(np.ones((T, T), dtype=bool), 1)
    def attend(X, block=None):
        S = X @ X.T / np.sqrt(d)
        if block is not None:
            S = np.where(block, -np.inf, S)
        e = np.exp(S - S.max(-1, keepdims=True))
        return (e / e.sum(-1, keepdims=True)) @ X
    p = rng.permutation(T)
    print(np.abs(attend(X[p]) - attend(X)[p]).max())
    print(np.abs(attend(X[p], causal) - attend(X, causal)[p]).max())

    ডেস্কটপের একটি run-এ (p = [0 1 3 4 2]) mask ছাড়া প্রায় 2.2e-16, causal-সহ প্রায় 0.328. Causal mask “আগে” আর “পরে” ঠিক করে দেয়, যা অবস্থানের ওপর নির্ভর করে; তাই ক্রম বদলালে কে কাকে দেখবে সেটিই বদলে যায়। Causal mask ক্রমের কিছুটা খবর দেয়, কিন্তু এটি positional encoding-এর বিকল্প নয়: একই সেটের token-এর দূরত্ব এটি জানায় না।

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

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

ক্রমMask-সহ scaled dot-product self-attention-এর ধাপগুলো সঠিক ক্রমে সাজান।

  1. X থেকে Q, K, V-এর projection
  2. Q আর Kᵀ গুণ করে score
  3. Score-কে √dk দিয়ে ভাগ
  4. নিষিদ্ধ ঘরে −∞ বসানো
  5. সারি ধরে softmax
  6. Weight দিয়ে V মেশানো

q আর k-এর ঘরগুলো স্বাধীন, গড় ০, variance ১, আর দৈর্ঘ্য dk হলে q·k-এর variance কত, আর সেই কারণে scaled dot-product attention কী করে?

  • Variance ১; কিছু করার দরকার নেই
  • Variance dk; score-কে √dk দিয়ে ভাগ করে
  • Variance dk; score-কে dk দিয়ে ভাগ করে
  • Variance dk²; score-কে √dk দিয়ে গুণ করে

Multi-head self-attention-এ B = ১৬, T = ৫০, d = ৭৬৮, h = ১২. Attention weight A-র shape কী?

  • (১৬, ৫০, ৭৬৮)
  • (১৬, ১২, ৫০, ৬৪)
  • (১৬, ১২, ৫০, ৫০)
  • (১৬, ১২, ৬৪, ৬৪)

একটি multi-head attention-এ d = ৫১২, bias-সহ, h = ৮. h বদলে ১৬ করলে WQ, WK, WV, WO-এর মোট parameter কত হবে?

  • ১০,৫০,৬২৪, আগের সমান
  • দ্বিগুণ, ২১,০১,২৪৮
  • অর্ধেক, ৫,২৫,৩১২
  • T-এর ওপর নির্ভর করবে

PyTorch-এ একই boolean causal mask torch.triu(torch.ones(T, T, dtype=torch.bool), 1) সরাসরি nn.MultiheadAttention-এর attn_mask আর F.scaled_dot_product_attention-এর attn_mask-এ দিলে কী হবে?

  • দুটিই ভবিষ্যৎ আটকাবে
  • দ্বিতীয়টি TypeError দেবে
  • প্রথমটি ভবিষ্যৎ খুলে দেবে, দ্বিতীয়টি আটকাবে
  • প্রথমটি ভবিষ্যৎ আটকাবে, দ্বিতীয়টি উল্টো কাজ করবে

কোনো mask আর positional তথ্য ছাড়া একটি self-attention স্তরে token-এর ক্রম একটি নির্দিষ্ট permutation P দিয়ে বদলালে output-এর কী হয়?

  • Output হুবহু একই থাকে
  • Output-এর সারিগুলো একই P দিয়ে বদলায়, মান অন্যথায় একই
  • Output সম্পূর্ণ এলোমেলো হয়ে যায়
  • Shape না মেলায় error আসে

Attention-এ নিষিদ্ধ ঘরের score softmax-এর আগে −∞ করা হয়। Softmax-এর পরে সেই ঘর ০ করে থেমে গেলে কী সমস্যা হতো?

  • Program error দিয়ে থেমে যেত
  • নিষিদ্ধ ঘরের তথ্য তবুও সরাসরি মিশে যেত
  • সারির যোগফল ১-এর কম হতো, ফলে output-এর স্কেল সারিভেদে বদলাত
  • A-র shape বদলে যেত

শব্দকোষ

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

মনে রাখুন

  1. সূত্র: Z = softmax(QKᵀ/√dk)V, যেখানে Q, K, V একই X-এর তিনটি শেখা projection. A (B, T, T), প্রতি সারির যোগফল ১.
  2. √dk: Var(q·k) = dk; ভাগে ১. না করলে softmax প্রায় one-hot, gradient ছোট। এটি একটি অনুমানের ওপর দাঁড়ানো শুরুর স্কেল।
  3. Mask: softmax-এর আগে −∞. Causal = উপরের ত্রিভুজ, padding = pad কলাম। True-র অর্থ API ভেদে উল্টো। Leak test করুন।
  4. Multi-head: (B, T, d) → (B, h, T, d/h) → attention → concat → WO. Parameter ৪d² + ৪d, h-নিরপেক্ষ।
  5. সীমা: Position ছাড়া ক্রম অজানা (Z(PX) = P·Z(X)), আর বড় T-তে সময় ও মেমরি প্রায় T²-এর হারে।

পরের পর্বে: পর্ব ৩৪-এ self-attention-কে ক্রম জানানোর উপায়: learned, sinusoidal আর RoPE, মেপে তুলনা।

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

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