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

নিজে Transformer লেখা — ছোট বাংলা language model

Writing a Transformer from Scratch — a Small GPT-like Bangla Language Model, Trained for Real

Taught in Bangla, this lesson writes a GPT-style model of about 1.8 million parameters in roughly 180 lines of PyTorch: a character tokenizer for Bangla, a shifted-target dataset, causal multi-head attention, blocks and the full model with a hand parameter count. A real desktop run on the GPU trains it with AMP, warmup and cosine decay, beats a bigram baseline on held-out perplexity across three seeds, samples text at several temperatures, and measures memorisation versus generalisation.

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

  • বাংলা লেখার জন্য character tokenizer আর এক ঘর সরানো Dataset লেখা
  • Causal multi-head attention, block আর পুরো model শূন্য থেকে লেখা, parameter হাতে গুনে মেলানো
  • AMP, warmup আর cosine schedule-সহ training চালানো, train আর val loss পড়া
  • Temperature দিয়ে লেখা তৈরি করা, আর bigram-এর সঙ্গে perplexity দিয়ে তুলনা করা
  • Model কী মুখস্থ করেছে আর কী সাধারণীকরণ করেছে, তা মেপে বলা

মডিউল ৬-এর আগের পর্বগুলোতে যন্ত্রাংশ আলাদা করে দেখা হয়েছে: পর্ব ৩২ (Attention), পর্ব ৩৩ (Self-attention আর multi-head attention), পর্ব ৩৪ (Positional encoding), পর্ব ৩৫ (Transformer block). এবার সেগুলো জুড়ে একটি চলন্ত model বানাব। এখানে বড় ধারণাগুলো আর ব্যাখ্যা করব না; শুধু ব্যবহার করব, আর দেখব আসল ডেটায় কী ঘটে। AI-এর ভিত্তি কোর্সের পর্ব ৭০-এ LLM-এর যে ছবি বাইরে থেকে দেখেছেন, আজ সেটি নিজের হাতে তৈরি হবে, খুব ছোট আকারে।

১ · কী বানাব, কোন লেখায় — ডেটা আর লাইসেন্স

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

কাজটি একটি language model বানানো: আগের অক্ষরগুলো দেখে পরের অক্ষরের probability বলা। সেই probability থেকে একটি একটি করে অক্ষর তুলে নিলেই নতুন লেখা তৈরি হয়। নকশাটি GPT-ধাঁচের, মানে শুধু-decoder: পর্ব ৩০-এর encoder নেই, cross-attention নেই। ডেটা দুই রকমের, ইচ্ছা করেই।

অংশউৎসtrainvaltest
গল্পউপেন্দ্রকিশোর রায়চৌধুরী, ৩৯টি গল্প১,৯২,০১৪ (৩১টি গল্প)২০,৩৩৪ (৪টি)২৫,৭৯০ (৪টি)
রিভিউবাজারঘর, seed দিয়ে বানানো, কাল্পনিক৬০,০৯৪৬,০০৮৬,০১২

সংখ্যাগুলো code point-এর হিসাব। Val আর test-এ পুরো গল্প আলাদা রাখা হয়েছে; এলোমেলো টুকরো নিলে একই গল্পের বাক্য দুই পাশে পড়ে যেত।

গল্পের উৎস আর লাইসেন্স: লেখাগুলো বাংলা উইকিসংকলন (bn.wikisource.org) থেকে নেওয়া, “টুনটুনির বই” আর “গুপি গাইন ও বাঘা বাইন” সংকলন। লেখক উপেন্দ্রকিশোর রায়চৌধুরী ১৯১৫ সালে মারা যান। উইকিসংকলনে বই দুটির পাতায় PD-India ট্যাগ আছে, মানে সেখানে পাবলিক ডোমেইন হিসেবে চিহ্নিত। ওখানে সংকলিত সংস্করণ দুটি ১৯৬৩–১৯৬৪ সালের, তাই সম্পাদকের সংযোজন থাকতে পারে। নিজে কোনো কাজে ব্যবহারের আগে আপনার দেশের নিয়ম আর পাতার ট্যাগ নিজে দেখে নিন; আমরা আইনি মত দিচ্ছি না। রিভিউগুলো আমাদের নিজের বানানো, কোনো আসল ক্রেতার নয়।

পরিষ্কার করার কোড আর রিভিউর ছাঁচ এই রকম (make_corpus.py থেকে ছাঁটা অংশ):

def clean(title, t):
    short = title.split("/")[-1]
    t = t.replace("", "").replace("\xa0", " ")
    t = t.split(short + "\n", 1)[-1] if short + "\n" in t else t
    t = "\n".join(l for l in t.splitlines() if "[পৃঃ" not in l and "পৃ." not in l)
    t = re.sub(r"[^ঀ-৿\s,;:!?।‘’“”\-—()]", "", t)
    t = re.sub(r"[ \t]+", " ", t)
    t = re.sub(r"\s*\n\s*", "\n", t).strip()
    return unicodedata.normalize("NFC", t) + "\n\n"

DG = "০১২৩৪৫৬৭৮৯"
def review(r):
    stars = r.randint(1, 5)
    words = GOOD if stars >= 4 else BAD
    no = "".join(r.choice(DG) for _ in range(6))
    return (f"অর্ডার {no}: {r.choice(PRODUCT)}, {r.choice(CITY)} থেকে {r.choice(CITY)}, "
            f"{DG[r.randint(1, 7)]} দিনে পৌঁছেছে। রেটিং {DG[stars]}। "
            f"{r.choice(words)}, {r.choice(words)}।\n")

লক্ষ্য করুন: রেটিং ৪ বা ৫ হলে ভালো মন্তব্য, নইলে খারাপ। এই নিয়মটি পরে পরীক্ষা করব। অর্ডারের ছয়টি অঙ্ক সম্পূর্ণ এলোমেলো, মানে সেখানে শেখার কিছুই নেই; এটিও একটি পরীক্ষা।

MiniGPT — এক নজরে, প্রতিটি ধাপের shape

B = batch, T = জানালার দৈর্ঘ্য (১২৮), d = ১৯২, V = ৮৩। শেষের layer আর token embedding একই weight ব্যবহার করে।

MiniGPT architecture with tensor shapes অক্ষরের id tokenembedding positionembedding যোগ, dropout ৪টি block LayerNorm → attention (৬ head, causal) LayerNorm → FFN দুটিতেই residual LayerNorm linear head logits → loss (B, T) (B, T, d) (B, T, d) (B, T, d) (B, T, V) দাগ: একই weight

২ · Tokenizer আর Dataset — এক ঘর সরানো লক্ষ্য

আমাদের tokenizer সবচেয়ে সরল: প্রতিটি code point একটি token. AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এ দেখেছেন, “ক্ষ” চোখে একটি হলেও আসলে তিনটি code point: ক, হসন্ত, ষ। আমাদের গল্পের প্রায় ৩১% code point এমন কার-চিহ্ন বা হসন্ত ধরনের মিলিত চিহ্ন। তাই এই model-কে যুক্তাক্ষরও এক ঘর এক ঘর করে বানাতে হয়। Train অংশে vocabulary ৮৩টি। BPE-র মতো ভালো tokenizer আসবে পর্ব ৪০ (Tokenization)-এ; আজ সরলতাই লক্ষ্য।

class CharTokenizer:
    """প্রতিটি Unicode code point একটি token."""
    def __init__(self, text):
        self.chars = sorted(set(text))
        self.stoi = {c: i for i, c in enumerate(self.chars)}
        self.vocab_size = len(self.chars)

    def encode(self, s):
        return [self.stoi[c] for c in s if c in self.stoi]   # অচেনা অক্ষর বাদ

    def decode(self, ids):
        return "".join(self.chars[i] for i in ids)

পর্ব ১১-এর Dataset এখানে খুব ছোট। পুরো লেখাটি একটি লম্বা array; যেকোনো জায়গা থেকে T + ১টি অক্ষর কেটে নিলেই একটি উদাহরণ।

import torch
from torch.utils.data import Dataset

class CharWindows(Dataset):
    """T অক্ষরের জানালা: লক্ষ্য হলো ইনপুট, এক ঘর ডানে সরানো।"""
    def __init__(self, ids, T):
        self.ids, self.T = torch.as_tensor(ids, dtype=torch.long), T

    def __len__(self):
        return len(self.ids) - self.T

    def __getitem__(self, i):
        chunk = self.ids[i : i + self.T + 1]     # T+1 অক্ষর
        return chunk[:-1], chunk[1:]             # x, y
◌ মানে মিলিত চিহ্ন, ␣ মানে ফাঁকা জায়গা
ধাপ
কী আছে
  1. লেখা. চোখে “শিয়াল পণ্ডিত”, কিন্তু model দেখে ১৩টি code point. “য়” এখানে য আর নুকতা, দুই ঘর।
  2. কাটা. T = ৮ হলে ৯টি অক্ষর কাটি, কারণ লক্ষ্যের জন্য একটি বেশি লাগে।
  3. ইনপুট. শেষ অক্ষর বাদ দিলে x, দৈর্ঘ্য ৮.
  4. লক্ষ্য. প্রথম অক্ষর বাদ দিলে y, দৈর্ঘ্যও ৮. প্রতিটি ঘরে y হলো x-এর পরের অক্ষর।
  5. একটি ঘর. t = ৩-এ model দেখতে পায় শুধু x-এর প্রথম তিনটি অক্ষর, আর অনুমান করে চতুর্থটি, নুকতা। ডানের অক্ষর দেখা বারণ; সেই কাজ করে causal mask.
  6. লাভ. একই জানালায় ৮টি ঘর, ৮টি লক্ষ্য। আমাদের আসল T = ১২৮, তাই একটি জানালায় ১২৮টি অনুমান একসঙ্গে শেখা হয়।

পর্ব ৩০-এর teacher forcing এখানেও চলছে: প্রতিটি ঘরের ইতিহাস সবসময় আসল লেখা থেকে আসে, model-এর আগের অনুমান থেকে নয়।

৩ · আগে baseline — bigram আর perplexity

Model লেখার আগে একটি সহজ প্রতিদ্বন্দ্বী দরকার, নইলে “৪.৮২” সংখ্যাটি ভালো না খারাপ, বোঝার উপায় নেই (পর্ব ১৮-এর অভ্যাস)। Bigram model শুধু আগের একটি অক্ষর দেখে: খাতায় গুনে রাখে কোন অক্ষরের পরে কোনটি কতবার এসেছে। মানুষ যেভাবে দোকানের হিসাবের খাতা থেকে বলে, “চায়ের পরে সাধারণত বিস্কুট চায়”।

তুলনার মাপ perplexity: গড় cross-entropy-র exp. AI-এর ভিত্তি কোর্সের পর্ব ৩৮-এ entropy দেখেছেন; এটি তারই সহজ রূপ।

NLL = −(১/N) ∑ ln p(yt | ইতিহাস)  ·  perplexity = eNLL

এখানে N হলো মোট অনুমানের সংখ্যা, p(yt | ইতিহাস) হলো সঠিক পরের অক্ষরকে model যে probability দিয়েছিল। Perplexity ৫ মানে: model গড়ে যেন ৫টি সমান সম্ভাব্য বিকল্পের মধ্যে দোটানায়। ৮৩টি অক্ষরে এলোমেলো অনুমানের perplexity ঠিক ৮৩, আর নিখুঁত অনুমানের ১.

নিচের কোড পাতাতেই চলে, ছোট একটি বানানো বাজারঘরের লেখার ওপর (পাতার ভেতরে রাখা, ১,২০০-এর বেশি অক্ষর)। প্রথম চালাতে কয়েক সেকেন্ড লাগতে পারে।

import numpy as np
text = open("bazarghar.txt", encoding="utf-8").read().strip()
cut = int(0.85 * len(text))
train, val = text[:cut], text[cut:]
chars = sorted(set(train)); V = len(chars)
ix = {c: i for i, c in enumerate(chars)}
ids = lambda s: np.array([ix[c] for c in s if c in ix])

a = ids(train)
C = np.zeros((V, V))
np.add.at(C, (a[:-1], a[1:]), 1)       # খাতায় জোড়া গোনা
alpha = 0.1                            # না-দেখা জোড়ার জন্য সামান্য ভাগ
P = (C + alpha) / (C + alpha).sum(axis=1, keepdims=True)

def nll(s):
    b = ids(s)
    return -np.log(P[b[:-1], b[1:]]).mean()
print("অক্ষর train/val:", len(train), len(val), " vocabulary:", V)
print(f"uniform  NLL {np.log(V):.3f}  perplexity {V}")
for name, s in [("train", train), ("val", val)]:
    n = nll(s)
    print(f"{name:7s}  NLL {n:.3f}  perplexity {np.exp(n):.2f}")

b = ids("ভালো")                         # হাতে মেলানোর ছোট উদাহরণ
p = P[b[:-1], b[1:]]
print("ভালো:", [round(float(x), 3) for x in p],
      "→ perplexity", round(float(np.exp(-np.log(p).mean())), 2))

rng = np.random.default_rng(42)
for start in ["দ", "ক"]:
    i, out = ix[start], [start]
    for _ in range(60):
        i = int(rng.choice(V, p=P[i]))
        out.append(chars[i])
    print("নমুনা:", "".join(out))

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

অক্ষর train/val: 1037 183  vocabulary: 52
uniform  NLL 3.951  perplexity 52
train    NLL 2.093  perplexity 8.11
val      NLL 2.259  perplexity 9.58
ভালো: [0.571, 0.165, 0.194] → perplexity 3.79
নমুনা: দোঁরে হার ঠলোকেছবা মরেইেরে ত, বাসবলি একটুজুপভিরংঁথজন ভারেজা, 
নমুনা: কিখারিঢমত, কষ্ডআধজাপৌদির আঘরঘখহজিখললেই এীুপতোছেছব।ন, গু রেন,

এলোমেলো অনুমানে ৫২টি বিকল্প; bigram তা কমিয়ে val-এ ৯.৫৮ করে। Train আর val-এর পার্থক্য ছোট, কারণ টেবিলে শেখার মতো জিনিস খুব কম, আর শেষের ১৫% অংশ মূল লেখার মতোই। “ভালো” শব্দে তিনটি ধাপ: ভ→া, া→ল, ল→ো. গড় −ln p ≈ ১.৩৩, তাই perplexity ≈ ৩.৭৯. নমুনাগুলো দেখুন: “একটু”-এর মতো টুকরো আসে, কিন্তু শব্দ দ্রুত ভেঙে যায়। আগের একটি অক্ষরের বাইরে এই model-এর কোনো স্মৃতিই নেই।

এবার আসল ডেটায়, একই ধরনের গোনা-গুনতি model, তবে desktop-এ। Train-এ গল্প আর রিভিউ দুটি মিলিয়ে গোনা, α = ০.১ smoothing (n-gram এর বিস্তার পর্ব ৪৬-এ)। সত্যিকারের run:

$ uv run python baseline.py
vocab 83, uniform: NLL 4.419, perplexity 83
model     split        NLL   perplexity
bigram    train_story 2.447    11.55
bigram    val_story   2.449    11.57
bigram    test_story  2.431    11.38
bigram    test_rev    2.205     9.07
trigram   train_story 1.903     6.71
trigram   val_story   2.072     7.94
trigram   test_story  2.000     7.39
trigram   test_rev    1.261     3.53
5-gram    train_story 1.478     4.38
5-gram    val_story   2.191     8.94
5-gram    test_story  2.127     8.39
5-gram    test_rev    0.612     1.84

ফলটি শিক্ষণীয়। Trigram bigram-কে হারায়। কিন্তু 5-gram গল্পের train-এ ৪.৩৮, আর test-এ ৮.৩৯: ইতিহাস লম্বা হলে খাতার বহু ঘর ফাঁকা থাকে, তাই না-দেখা গল্পে সে হোঁচট খায়। এখানেই একটি neural model-এর আসল আশা: লম্বা ইতিহাস, কিন্তু প্রতিটি ইতিহাসের জন্য আলাদা ঘর নয়।

৪ · Causal attention — ভবিষ্যৎ ঢেকে রাখা

পর্ব ৩৩-এর multi-head self-attention-ই এখানে, একটি শর্ত যোগ করে। পরীক্ষার হলে পাশের জনের খাতা দেখা যেমন বারণ, প্রতিটি ঘর তেমনি নিজের ডানের ঘর দেখতে পারবে না। এই নিয়মের নাম causal mask. না রাখলে কী হয়? লক্ষ্য yt আসলে xt+১, তাই model শুধু ডানের অক্ষরটি নকল করেই প্রায় শূন্য loss পেত, আর লেখার সময় একদম অকেজো হতো।

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

class CausalSelfAttention(nn.Module):
    def __init__(self, d, heads, T, p):
        super().__init__()
        assert d % heads == 0
        self.heads, self.dk = heads, d // heads
        self.qkv = nn.Linear(d, 3 * d, bias=False)   # Q, K, V একসঙ্গে
        self.out = nn.Linear(d, d, bias=False)
        self.drop_w, self.drop_o = nn.Dropout(p), nn.Dropout(p)
        # নিচের ত্রিভুজ: 1 = দেখা যাবে, 0 = ভবিষ্যৎ, ঢাকা
        self.register_buffer("mask", torch.tril(torch.ones(T, T, dtype=torch.bool)))

    def forward(self, x):                            # x: (B, T, d)
        B, T, d = x.shape
        q, k, v = self.qkv(x).chunk(3, dim=-1)       # প্রতিটি (B, T, d)
        split = lambda a: a.view(B, T, self.heads, self.dk).transpose(1, 2)
        q, k, v = split(q), split(k), split(v)       # (B, h, T, dk)
        s = q @ k.transpose(-2, -1) / math.sqrt(self.dk)   # (B, h, T, T)
        s = s.masked_fill(~self.mask[:T, :T], float("-inf"))
        w = self.drop_w(F.softmax(s, dim=-1))        # সারি ধরে যোগ = 1
        y = (w @ v).transpose(1, 2).reshape(B, T, d) # head জোড়া
        return self.drop_o(self.out(y))

এক লাইনে ঠিক কী ঘটে, ৪টি token-এ হাতে দেখুন (একটি head, স্কোরগুলো ছবির জন্য বসানো)।

সারি = query (যে দেখছে), কলাম = key (যাকে দেখছে)
ধাপ
Matrix
  1. স্কোর. Q আর K-এর গুণে পাওয়া কাঁচা স্কোর। এখনো প্রতিটি ঘর সবাইকে দেখছে, ডানের ঘরকেও।
  2. Mask. torch.tril নিচের ত্রিভুজ দেয়: ১ মানে দেখা যাবে, ০ মানে ভবিষ্যৎ।
  3. −inf. ০-এর ঘরে −∞ বসে। e−∞ = ০, তাই softmax-এর পরে এদের weight ঠিক শূন্য, আর বাকিদের যোগ ১ থাকে।
  4. সারি ২. দ্বিতীয় ঘর দেখে শুধু ঘর ১ আর ২. প্রথম সারিতে একটিই ঘর, তাই তার weight হয় ১.
  5. সারি ৩. তিনটি ঘর দেখা যায়, ভাগ করা হয় তাদের যোগ ৫.১০০ দিয়ে।
  6. সারি ৪. শেষ ঘর সবাইকে দেখে; mask এখানে কিছুই বদলায় না। Mask ছাড়া সারি ২-এর weight হতো ০.২৪২, ০.৪৪০, ০.১৯৮, ০.১২০: ডানের দুটি ঘরে ফাঁস।
  7. ফল. W নিচের-ত্রিভুজাকার, প্রতিটি সারির যোগ ১ (তিন ঘরে গোলের জন্য শেষ সারির যোগ ১.০০১ দেখাচ্ছে)। সারি t-এর output হলো W-এর সেই সারি দিয়ে V-এর ওজনযুক্ত যোগ।

দাবিটি পরীক্ষা করা সহজ: শেষ token বদলে দিলে আগের ঘরগুলোর output বদলানো চলবে না। ভুল mask ধরার সবচেয়ে সস্তা পরীক্ষা এটিই।

import numpy as np
np.set_printoptions(precision=3, suppress=True, linewidth=60)
rng = np.random.default_rng(42)
T, d = 4, 8
x = rng.normal(size=(T, d))                 # ৪টি token-এর embedding
Wq, Wk, Wv = (rng.normal(size=(d, d)) for _ in range(3))

def attend(x, causal):
    Q, K, V = x @ Wq, x @ Wk, x @ Wv
    S = Q @ K.T / np.sqrt(d)                # (T, T), স্কোর
    if causal:
        S = np.where(np.tril(np.ones((T, T), bool)), S, -np.inf)
    S = S - S.max(axis=1, keepdims=True)    # স্থিতিশীল softmax
    w = np.exp(S) / np.exp(S).sum(axis=1, keepdims=True)
    return w, w @ V

w, y = attend(x, causal=True)
print("W (causal):\n", w)
print("সারির যোগ:", w.sum(axis=1))

x2 = x.copy()
x2[3] += 5.0                                # শুধু শেষ token বদলাই
for causal in [True, False]:
    _, a = attend(x, causal)
    _, b = attend(x2, causal)
    print(f"causal={causal}: প্রতি ঘরের সর্বোচ্চ বদল", np.abs(a - b).max(axis=1))

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

W (causal):
 [[1.    0.    0.    0.   ]
 [0.983 0.017 0.    0.   ]
 [0.012 0.55  0.439 0.   ]
 [0.656 0.004 0.337 0.002]]
সারির যোগ: [1. 1. 1. 1.]
causal=True: প্রতি ঘরের সর্বোচ্চ বদল [0.    0.    0.    4.897]
causal=False: প্রতি ঘরের সর্বোচ্চ বদল [0.    0.004 0.142 4.897]

Mask থাকলে প্রথম তিন ঘরের পরিবর্তন ঠিক ০, শুধু শেষ ঘর বদলায়। Mask ছাড়া দ্বিতীয় আর তৃতীয় ঘরও বদলায়; প্রথম ঘরের বদল এত ছোট (প্রায় ০.০০০২) যে তিন দশমিকে ০ দেখায়, তবে শূন্য নয়। নিজের model-এও ঠিক এই পরীক্ষাটি চালাতে পারেন, যেকোনো AI-লেখা কোড পাওয়ার পর।

৫ · Block আর পুরো model — parameter হাতে গোনা

পর্ব ৩৫-এর pre-norm block হুবহু: আগে LayerNorm, তারপর attention বা FFN, চারপাশে residual পথ। FFN-এ GELU (পর্ব ৩)।

import torch
import torch.nn as nn
import torch.nn.functional as F
from attention import CausalSelfAttention

class Block(nn.Module):
    """Pre-norm: x + attn(norm(x)), তারপর x + ffn(norm(x))."""
    def __init__(self, d, heads, T, p):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.attn = CausalSelfAttention(d, heads, T, p)
        self.ln2 = nn.LayerNorm(d)
        self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(),
                                 nn.Linear(4 * d, d), nn.Dropout(p))

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        return x + self.ffn(self.ln2(x))

পুরো model-এ যা নতুন, তা হলো শেখা position embedding (পর্ব ৩৪-এর সবচেয়ে সরল বিকল্প) আর weight tying: শেষের linear layer নিজের আলাদা weight রাখে না, token embedding-এর matrix-ই ব্যবহার করে।

class MiniGPT(nn.Module):
    def __init__(self, vocab, T=128, d=192, heads=6, layers=4, p=0.2):
        super().__init__()
        self.T = T
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(T, d)                # learned, পর্ব ৩৪
        self.drop = nn.Dropout(p)
        self.blocks = nn.ModuleList(Block(d, heads, T, p) for _ in range(layers))
        self.ln = nn.LayerNorm(d)
        self.head = nn.Linear(d, vocab, bias=False)
        self.head.weight = self.tok.weight           # weight tying
        self.apply(self._init)

    @staticmethod
    def _init(m):
        if isinstance(m, (nn.Linear, nn.Embedding)):
            nn.init.normal_(m.weight, std=0.02)
        if isinstance(m, nn.Linear) and m.bias is not None:
            nn.init.zeros_(m.bias)

    def forward(self, idx, targets=None):            # idx: (B, T)
        t = torch.arange(idx.size(1), device=idx.device)
        x = self.drop(self.tok(idx) + self.pos(t))
        for b in self.blocks:
            x = b(x)
        logits = self.head(self.ln(x))               # (B, T, V)
        if targets is None:
            return logits, None
        loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
        return logits, loss

এবার পরীক্ষার খাতার মতো হাতে গুনুন। V = ৮৩, T = ১২৮, d = ১৯২, ৬ head, ৪ layer. Bias: attention-এ বন্ধ, FFN-এ আছে।

অংশহিসাবparameter
Token embedding (head-এর সঙ্গে ভাগ করা)৮৩ × ১৯২১৫,৯৩৬
Position embedding১২৮ × ১৯২২৪,৫৭৬
Attention: Q, K, V + out৩ × ১৯২² + ১৯২²১,৪৭,৪৫৬
FFN: ১৯২→৭৬৮→১৯২(১৯২ × ৭৬৮ + ৭৬৮) + (৭৬৮ × ১৯২ + ১৯২)২,৯৫,৮৭২
দুটি LayerNorm২ × (১৯২ + ১৯২)৭৬৮
এক blockওপরের তিনটির যোগ৪,৪৪,০৯৬
৪টি block৪ × ৪,৪৪,০৯৬১৭,৭৬,৩৮৪
শেষ LayerNorm১৯২ + ১৯২৩৮৪
মোট১৮,১৭,২৮০

PyTorch-এর গোনা parameters: 1817280, হুবহু মেলে। Tying না থাকলে আরও ১৫,৯৩৬টি বাড়ত। দেখুন, ৯৮%-এর বেশি parameter block-এ, আর vocabulary ছোট বলে embedding-এর ভাগ সামান্য। Multi-head-এ head সংখ্যা parameter বদলায় না, শুধু d ভাগ করে; কারণটি পর্ব ৩৩-এ।

৬ · Training — AMP, warmup, loss curve

Training loop পর্ব ১০-এর পরিচিত ছাঁচে; পর্ব ১২-এর mixed precision, পর্ব ১৫-এর AdamW, warmup আর cosine, পর্ব ১৭-এর gradient clipping. Adam-এর গণিত মেশিন লার্নিং কোর্সের পর্ব ১৪-এ। train.py-এর শুরুর argparse অংশ বাদ দিয়ে, ফাইল থেকে হুবহু:

read = lambda n: Path(f"data/{n}.txt").read_text(encoding="utf-8")
train_text = read("train_story") + read("train_rev")
tok = CharTokenizer(train_text)
ids = {n: torch.tensor(tok.encode(read(n)), dtype=torch.long) for n in
       ["train_story", "train_rev", "val_story", "val_rev"]}
train_ds = CharWindows(tok.encode(train_text), a.T)

@torch.no_grad()
def evaluate(model, x):                       # পাশাপাশি জানালা, প্রথম ৪০০টি
    model.eval()
    w = x.unfold(0, a.T + 1, a.T)[:400].to(a.device)
    total = 0.0
    for b in w.split(128):
        total += model(b[:, :-1], b[:, 1:])[1].item() * len(b)
    model.train()
    return total / len(w)
torch.manual_seed(a.seed)
sampler = RandomSampler(train_ds, replacement=True, num_samples=a.steps * a.batch)
loader = DataLoader(train_ds, a.batch, sampler=sampler, drop_last=True)
model = MiniGPT(tok.vocab_size, a.T, a.d, a.heads, a.layers, a.p).to(a.device)
print("parameters:", sum(p.numel() for p in model.parameters()), "vocab:", tok.vocab_size)
opt = torch.optim.AdamW(model.parameters(), lr=a.lr, weight_decay=0.01)
warm = 200
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min((s + 1) / warm,
        0.5 * (1 + math.cos(math.pi * min(s, a.steps) / a.steps))))
use_amp = a.device == "cuda"
scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
best, log, train_time, t0 = float("inf"), [], 0.0, time.perf_counter()
for step, (x, y) in enumerate(loader, 1):
    x, y = x.to(a.device), y.to(a.device)
    with torch.autocast("cuda", dtype=torch.float16, enabled=use_amp):
        _, loss = model(x, y)
    opt.zero_grad(set_to_none=True)
    scaler.scale(loss).backward()
    scaler.unscale_(opt)
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(opt); scaler.update(); sched.step()
    if step % a.every == 0 or step == 1 or step == a.steps:
        train_time += time.perf_counter() - t0
        r = {"step": step, "batch": round(loss.item(), 4)}
        r |= {n: round(evaluate(model, ids[n]), 4) for n in ids}
        log.append(r)
        print(f"{step:5d} train {r['train_story']:.3f}/{r['train_rev']:.3f} "
              f"val {r['val_story']:.3f}/{r['val_rev']:.3f}", flush=True)
        if r["val_story"] < best:
            best = r["val_story"]
            torch.save({"model": model.state_dict(), "step": step, "cfg": vars(a)},
                       f"{a.name}.pt")
        t0 = time.perf_counter()
print(f"সেরা val_story {best:.3f}, training সময় {train_time:.0f} s, "
      f"{a.steps / train_time:.1f} step/s")
json.dump(log, open(f"{a.name}.json", "w"))

সংস্করণ: Python 3.14.4, torch 2.15.0, GPU-তে, লেখার সময়ে। প্রথম চেষ্টায় একটি আসল ভুল ধরা পড়েছিল। Model-এ targets.view(-1) লিখেছিলাম, আর evaluate-এর unfold থেকে আসা tensor-এ সেটি ভেঙে গেল:

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. view শুধু পাশাপাশি রাখা মেমরিতে চলে (পর্ব ৭), আর unfold-এর পরের slice তেমন নয়। সত্যিকারের run, একটি seed:

$ uv run python train.py --steps 3000 --name s42 --every 250
parameters: 1817280 vocab: 83
    1 train 4.543/4.546 val 4.536/4.547
  250 train 2.258/0.965 val 2.303/0.975
  500 train 1.825/0.361 val 1.966/0.365
  750 train 1.580/0.323 val 1.798/0.325
 1000 train 1.430/0.322 val 1.724/0.325
 1250 train 1.311/0.305 val 1.693/0.305
 1500 train 1.222/0.304 val 1.660/0.308
 1750 train 1.163/0.304 val 1.662/0.307
 2000 train 1.105/0.294 val 1.641/0.299
 2250 train 1.069/0.295 val 1.645/0.299
 2500 train 1.042/0.292 val 1.636/0.297
 2750 train 1.032/0.292 val 1.639/0.297
 3000 train 1.030/0.291 val 1.640/0.297
সেরা val_story 1.636, training সময় 331 s, 9.1 step/s

প্রতি সারিতে “গল্প/রিভিউ” এই ক্রমে দুটি loss. প্রথম সারি পরীক্ষার মতো: না-শেখা model-এর loss ৪.৫৪, আর ln ৮৩ ≈ ৪.৪২, কাছাকাছি (পর্ব ১৮)। ৩০০০ step × ৬৪ × ১২৮ অক্ষর মানে প্রায় ২.৪৬ কোটি অনুমান, অর্থাৎ পুরো train লেখাটি প্রায় ৯৭ বার ঘোরা।

গল্পের train আর val loss, রিভিউর val loss, আর bigram-এর রেখা

একটি run, step ২৫০ থেকে। Train রেখা (ডট-দাগ) নামতেই থাকে; গল্পের val প্রায় step ২০০০-এ থেমে যায়।

Training and validation loss by step 0.00.51.01.52.02.50100020003000 step bigram, গল্পের val ২.৪৫ গল্প val ১.৬৪ গল্প train ১.০৩ (ডট-দাগ) রিভিউ val ০.৩০

রেখা তিনটি তিনটি গল্প বলে। রিভিউর val প্রায় ০.৩-এ ৭৫০ step-এই পৌঁছে যায়, আর train-এর সঙ্গে প্রায় সমান: ছাঁচটি সহজ। গল্পের train ১.০৩ পর্যন্ত নামে, কিন্তু val থামে ১.৬৪-এর কাছে: এই ফাঁকটিই overfitting. চেকপয়েন্ট রাখা হয় সেরা val-এ (step 2500), মানে early stopping-এর সরল রূপ।

সময়: ওপরের run GPU-তে ৩৩১ সেকেন্ড, ৯.১ step/s (GPU অন্য কাজের সঙ্গে ভাগ করা, মূল্যায়নের সময় বাদ)। একই কোডের ১০০ step CPU-তে ১১৭ সেকেন্ড, ০.৯ step/s; অর্থাৎ পুরো run CPU-তে প্রায় ৫৮ মিনিট, মানে প্রায় এক ঘণ্টা লাগার কথা, যদিও সেটি চালিয়ে দেখিনি, গুণ করে বলছি। মাপটি এই একটি যন্ত্রের, সাধারণ নিয়ম নয়।

৭ · লেখা তৈরি আর temperature

Model-এর শেষ ঘরের logit থেকে একটি অক্ষর তুলে নিই, জুড়ে দিই, আবার চালাই। আগের লেখা T-এর চেয়ে লম্বা হলে শুধু শেষ T অক্ষর। প্রতিবার পুরো জানালা নতুন করে হিসাব হয়, তাই ধীর; পর্ব ৩৭ (দক্ষ attention)-এ KV cache এই অপচয় কমায়।

import torch

@torch.no_grad()
def generate(model, tok, prompt, n, temperature=1.0, seed=42, device="cpu"):
    model.eval()
    g = torch.Generator(device=device).manual_seed(seed)
    idx = torch.tensor([tok.encode(prompt)], device=device)
    for _ in range(n):
        logits, _ = model(idx[:, -model.T:])        # শেষ T অক্ষর
        z = logits[:, -1] / temperature             # শুধু শেষ ঘর
        probs = torch.softmax(z, dim=-1)
        nxt = torch.multinomial(probs, 1, generator=g)
        idx = torch.cat([idx, nxt], dim=1)
    return tok.decode(idx[0].tolist())

এখানকার temperature (লেখায় τ) হলো softmax-এর আগে logit-কে ভাগ করার একটি সংখ্যা। τ কম মানে সাবধানি দোকানি, যিনি সবচেয়ে বেশি বিক্রি হওয়া জিনিসটিই এগিয়ে দেন। τ বেশি মানে খেয়ালি দোকানি, যিনি তাকের পেছনের জিনিসও তুলে দেন। Model বদলায় না, শুধু তোলার নিয়ম বদলায়।

চারটি অক্ষর ক, খ, গ, ঘ; z = logit, H = entropy, eH = perplexity
τ
z / τ
p
H, eH
  1. Logit. Model-এর দেওয়া চারটি স্কোর, এগুলো বদলাবে না। Softmax এখনো হয়নি।
  2. τ = ১.০. সাধারণ softmax. “ক” ৬৪%, কিন্তু “ঘ”-ও ৩% সুযোগ পায়।
  3. τ = ০.৫. Logit দ্বিগুণের মতো, পার্থক্য বড়। “ক” ৮৬.৫%. প্রায় ১.৫৮টি বিকল্পের মধ্যে বাছাই।
  4. τ = ১.৫. পার্থক্য ছোট। “ক” ৫২%, “ঘ” ৭%. প্রায় ৩.১৭টি বিকল্প। ভুল অক্ষরের সুযোগ বেড়ে গেল।
  5. τ → ০. সবচেয়ে বড় logit-এর probability ১-এর দিকে যায়। এটিই greedy decoding; τ = ০ দিয়ে সরাসরি ভাগ করলে সংখ্যার সমস্যা হবে, তাই কোডে আলাদা argmax দেওয়া হয়।
  6. সারসংক্ষেপ. ক্রম কখনো বদলায় না; “ক” সবসময় প্রথম। τ শুধু ঠিক করে, বণ্টন কতটা তীক্ষ্ণ।
import numpy as np
import matplotlib.pyplot as plt
np.set_printoptions(precision=3, suppress=True)
z = np.array([2.0, 1.0, 0.0, -1.0])         # ক, খ, গ, ঘ-এর logit
taus = [0.5, 1.0, 1.5]
rng = np.random.default_rng(42)

def softmax_t(z, tau):
    s = z / tau
    s = s - s.max()
    return np.exp(s) / np.exp(s).sum()

fig, ax = plt.subplots(figsize=(5.2, 2.6))
for j, tau in enumerate(taus):
    p = softmax_t(z, tau)
    H = -(p * np.log(p)).sum()
    n = np.bincount(rng.choice(4, 1000, p=p), minlength=4)
    print(f"τ={tau}: p={p}  H={H:.3f}  e^H={np.exp(H):.2f}")
    print(f"      ১০০০ বার তুলে গোনা: {n.tolist()}")
    ax.bar(np.arange(4) + (j - 1) * 0.27, p, width=0.25, label=f"tau = {tau}")
ax.set_xticks(range(4), ["token 0", "token 1", "token 2", "token 3"])
ax.set_ylabel("probability")
ax.set_title("Same logits, different temperature")
ax.legend()
plt.show()
print("τ→0 (argmax):", softmax_t(z, 0.01))

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

τ=0.5: p=[0.865 0.117 0.016 0.002]  H=0.455  e^H=1.58
      ১০০০ বার তুলে গোনা: [869, 111, 18, 2]
τ=1.0: p=[0.644 0.237 0.087 0.032]  H=0.948  e^H=2.58
      ১০০০ বার তুলে গোনা: [639, 233, 87, 41]
τ=1.5: p=[0.523 0.268 0.138 0.071]  H=1.153  e^H=3.17
      ১০০০ বার তুলে গোনা: [535, 260, 129, 76]
τ→0 (argmax): [1. 0. 0. 0.]

গোনা সংখ্যাগুলো হিসাবের probability-র কাছাকাছি। ছবিতে তিনটি τ-এ একই চার অক্ষরের দণ্ড দেখুন: ক্রম এক, উচ্চতার ব্যবধান আলাদা।

এবার আসল model-এর লেখা। চেকপয়েন্ট step ২৫০০, seed ৪২; নিচের সব নমুনা সত্যিকারের run থেকে হুবহু, বাছাই না করে প্রথমবারেই যা এসেছে। প্রথমে গল্পের শুরু দিয়ে:

--- temp=0.5  prompt='শিয়াল পণ্ডিত '
শিয়াল পণ্ডিত হয়ে যাবে।’
তখন শিয়াল বললে, ‘আমি তো আর একটু গর্তের দিকে বলছে, ‘একটু কথা আমার ঐ যে সাক্ষীরাজ আমার মেয়েকে বিয়ে দিয়ে দেব।’
বাঘ বললে, ‘দাসী, তবে কি করে দেখাচ্ছ?’
শুনে বাঘ বললে, ‘যদি তবে কি করে করতে পারি।’
বাঘ বললে, ‘আমি

--- temp=1.0  prompt='শিয়াল পণ্ডিত '
শিয়াল পণ্ডিত না এমনি জানি হচ্ছে যে তাহার নাম করিতে পারে। আরও সুখে সকলেই ছেলেদেকে হইল। তাহাকে চির করিতে পারিলেন, ‘ধ্যাঁঘাসুরের জন্যে, একটু ব্যাঙ খবর দিলেই যে, তাই বুঝিতে পারে সেই শুধু দুজন আছে, আর মেয়েকে জুতো ভারি ক’রে তাহার একটু বিষ

--- temp=1.5  prompt='শিয়াল পণ্ডিত '
শিয়াল পণ্ডিত নৌকা আপনিও” তখন আজ ব্যস্ত হয্নে, ইচ্ছা পাহাড়িও-চু পরীক্ষীরণ মেরেকে হইল। আবার ঐ চির সব্তি
দূর হুজুর, আমার পক্ষও খেলেও না।

দুঃখীরামের এদিস ভায় থাকেন বাল যে তবে এখে শুধু বারএ আগেয় নেমেই হল। জুতো ভূত এক-হয়ে খোঁ‘একটি বইর

τ = ০.৫-এ বাক্য চেনা লাগে: উদ্ধৃতি খোলে, বন্ধও হয়, “বাঘ বললে” ঠিক বসে। কিন্তু অর্থ মেলে না; বাঘের সঙ্গে সাক্ষীরাজ আর মেয়ের বিয়ে কোথা থেকে এল, কেউ জানে না। τ = ১.০-এ “ছেলেদেকে”-র মতো ভুল শব্দ আসে। τ = ১.৫-এ “হয্নে”, “সব্তি”: code point ধরে লেখার দুর্বলতা স্পষ্ট, যুক্তাক্ষরও ভেঙে যায়। এবার রিভিউর শুরু দিয়ে, প্রতি নমুনার প্রথম কয়েক লাইন:

--- temp=0.5  prompt='অর্ডার '
অর্ডার ৬৪৭৮৪২: জামদানি শাড়ি, রাজশাহী থেকে রংপুর, ৩ দিনে পৌঁছেছে। রেটিং ৩। প্যাকেট ছেঁড়া ছিল, মান ভালো না।
অর্ডার ৭০৭২৮৯: চায়ের কাপ, ময়মনসিংহ থেকে রাজশাহী, ৩ দিনে পৌঁছেছে। রেটিং ২। প্যাকেট ছেঁড়া ছিল, মান ভালো না।
অর্ডার ৭৮৭

--- temp=1.0  prompt='অর্ডার '
অর্ডার ৬৪৭৮৪২৮: টাঙ্গাইল শাড়ি, সিলেট থেকে সিলেট, ৩ দিনে পৌঁছেছে। রেটিং ৩। প্যাকেট ছেঁড়া ছিল, মান ভালো না।
অর্ডার ৭০৭২৮৯: চায়ের কাপ, ময়মনসিংহ থেকে রাজশাহী, ৩ দিনে পৌঁছেছে। রেটিং ২। প্যাকেট ছেঁড়া ছিল, মান ভালো না।
অর্ডার ৩৮৭

--- temp=1.5  prompt='অর্ডার '
অর্ডার ৬৪৭৮৪২৮: টাঙ্গাইল শাড়ি, সিলেট থেকে সিলেট, ৩ দিনে পৌঁছেছে। রেটিং ৩। প্যাকেট ছেঁড়া ছিল, মান ভালো না।
অর্ডার ৭০৭২৮৯৫: লুঙ্গি, ময়মনসিংহ থেকে চট্টগ্রাম, ২ দিনে পৌঁছেছে। রেটিং ১। ফেরত দিতে চাই, দাম বেশি মনে হয়েছে।
অর্ডার ৭

ছাঁচটি প্রায় নিখুঁত। তবে “৬৪৭৮৪২৮” দেখুন: সাত অঙ্ক। লাইনের নকশা দেখে যে model কেউ “বুঝেছে” মনে করতে পারেন, সে সামান্য গুনতে ভুল করছে। পরের অংশে এটিই মাপব।

৮ · সৎ মূল্যায়ন — কী মুখস্থ, কী সাধারণীকরণ

সুন্দর একটি নমুনা দেখে সিদ্ধান্ত নেওয়া ভুল পথ। সংখ্যা দেখুন। সেরা চেকপয়েন্টে, পুরো test অংশে (সত্যিকারের run):

চেকপয়েন্ট step: 2500
split        NLL   perplexity
train_story 1.087    2.96
val_story   1.636    5.13
test_story  1.572    4.82
train_rev   0.292    1.34
val_rev     0.297    1.35
test_rev    0.298    1.35
রিভিউ: order-digit NLL 2.461 (ln 10 = 2.303), বাকি অক্ষর NLL 0.172, digit ঘর 324/5888

৮৩ থেকে ৪.৮২: বড় উন্নতি। তবে এক run-এর একটি সংখ্যা দিয়ে কথা বলা ঠিক নয়, তাই একই test ভাগে তুলনা (সব সংখ্যা সত্যিকারের run, একটি যন্ত্রে; n-gram শুধু গোনা, তাই seed নেই):

Modelparameterসেরা stepগল্প: test NLLগল্প: perplexityরিভিউ: perplexity
Bigramগোনা—২.৪৩১১১.৩৮৯.০৭
Trigramগোনা—২.০০০৭.৩৯৩.৫৩
5-gramগোনা—২.১২৭৮.৩৯১.৮৪
MiniGPT, seed ৪২১৮,১৭,২৮০২৫০০১.৫৭২৪.৮২১.৩৫
MiniGPT, seed ৪৩একই২৫০০১.৫৬৭৪.৭৯১.৩৫
MiniGPT, seed ৪৪একই২০০০১.৫৮৫৪.৮৮১.৩৫
ছোট: d = ৯৬, ২ layer, ৪ head২,৪৩,৩৬০৩০০০১.৮১৮৬.১৬১.৪১
Dropout ০, সেরা val-এ থামানো১৮,১৭,২৮০৫০০১.৭৩৬৫.৬৭১.৪১

তিনটি seed খুব কাছাকাছি, ৪.৭৯–৪.৮৮, যদিও ভাগ একই থাকায় আসল অনিশ্চয়তা এর চেয়ে বেশি। গল্পে bigram-এর তুলনায় perplexity প্রায় ২.৪ গুণ কমল। ন্যায্যতার জন্য বলি, model-এর প্রতিটি নতুন জানালা শুরু হয় ইতিহাস ছাড়া, তাই এই হিসাব তার বিরুদ্ধে সামান্য ঝুঁকে। ছোট model-এর ৬.১৬-ও trigram-কে হারায়।

Dropout-এর সারিটি দেখুন। Dropout ০ দিয়ে ৩০০০ step চালিয়ে গেলে শেষে train ০.১৩৬, কিন্তু গল্পের val ৩.২৫, bigram-এর ২.৪৫-এর চেয়েও অনেক খারাপ। Val সবচেয়ে ভালো ছিল step ৫০০-এ, ১.৮১; সেই চেকপয়েন্ট টেবিলে। একই model-এ পর্ব ১৬-এর dropout ০.২ না থাকলে এই পরিণতি।

কী মুখস্থ? মুখস্থ আছে, তবে temperature-এর ওপর নির্ভর করে। ৪৮টি ধারায় ৬০০ অক্ষর করে তুলে, অর্ডার-লাইন বাদ দিয়ে, মাপা হলো train-এর গল্পের সঙ্গে সবচেয়ে লম্বা হুবহু মিল, আর train-এ না-দেখা শব্দের ভাগ:

গল্পের লাইন শুধু (অর্ডার-লাইন বাদ), ৪৮টি ধারা × ৬০০ অক্ষর:
temp শব্দ   নতুন-শব্দ%  লম্বা কপি
0.5   5396    1.3%   110  'ে কুড়ুল শানাতে; কুড়ুল দিয়ে গাছ কাটতে; গাছ দিয়ে লাঠি বানাতে; লাঠি দিয়ে ভুতোকে মারতে; সে কেন কুল খেয়ে ফেলল'
     নতুন শব্দের নমুনা: পালকে পালকা বাঁচার দেখানে খাটি শুন যেমাকে দুষ্টুকে সারি গাইবে
1.0   5064   14.0%    28  'ের কথা শুনিয়া মন্ত্রীমহাশয়'
     নতুন শব্দের নমুনা: ভাবিল পুড়িয়া ফেললল যারপর দুন দুপি পরি বেঁ চাপিব পারিলেন
1.5   4098   41.0%    21  'ে না।’\nশিয়াল বললে, ‘'
     নতুন শব্দের নমুনা: লাফিয়া পস্থিত টা স বাং করের ত কখন্ দর জম

τ = ০.৫-এ ১১০ অক্ষরের একটি টানা অংশ হুবহু train থেকে, একটি পুনরাবৃত্তিমূলক ছড়া। এমন জিনিস মুখস্থ হওয়াই সবচেয়ে সহজ। τ = ১.৫-এ শব্দের ৪১% train-এ দেখা যায়নি: নকলের বদলে ভাঙা শব্দ। আরেকটি পরীক্ষায় ৬৪ অক্ষরের শুরু দিয়ে greedy চালিয়ে দেখা হলো, পরের ৬৪ অক্ষর কতদূর হুবহু মেলে:

greedy কতদূর হুবহু চলে (64 অক্ষরের prefix-এর পর):
train_story গড়   3.0 অক্ষর, পুরো 64 মিলল  0.5%
test_story  গড়   1.6 অক্ষর, পুরো 64 মিলল  0.0%

গড়ে মাত্র ৩ অক্ষর, মানে model পুরো train লেখা মুখস্থ বলতে পারে না; কিন্তু train-এ অনুমান test-এর প্রায় দ্বিগুণ দূর চলে, আর ২০০টি শুরুর মধ্যে একটির ক্ষেত্রে পুরো ৬৪ অক্ষর উঠে এসেছে। ছোট model-এও মুখস্থ শূন্য নয়।

এবার সেই dropout ০-এর model, শেষ step ৩০০০-এর চেকপয়েন্টে একই মাপ (চেকপয়েন্ট রাখার একটি শর্ত ছাড়া কোড একই; সত্যিকারের run):

split        NLL   perplexity
train_story 0.137    1.15
val_story   3.254   25.88
test_story  3.092   22.01
train_rev   0.267    1.31
val_rev     0.303    1.35
test_rev    0.305    1.36

temp নতুন-শব্দ%  সবচেয়ে লম্বা কপি (অক্ষর)
0.5     1.1%    1236  ', ‘ভালো রে ভালো, এমন করে আমাদের আখ কোন দু …
1.0    14.3%     218  'ছিল, তারা গুপি আর বাঘাকে সেই ঢোল নিয়ে আস …
1.5    28.1%      64  'র খুঁজতে বেরিয়েছে, আর মেছুনীর ঝুড়ির ভিত …

greedy কতদূর হুবহু চলে (64 অক্ষরের prefix-এর পর):
train_story গড়  63.4 অক্ষর, পুরো 64 মিলল 98.5%
test_story  গড়   1.4 অক্ষর, পুরো 64 মিলল  0.0%

এটিই খাঁটি মুখস্থের ছবি। Train লেখার ২০০টি এলোমেলো শুরুর ৯৮.৫%-এ পরের ৬৪ অক্ষর হুবহু আসে, τ = ০.৫-এ ১,২৩৬ অক্ষরের টানা নকল আসে; অথচ না-দেখা গল্পে perplexity ২২, trigram-এর চেয়ে অনেক খারাপ। Train loss একা দেখলে এটিকেই সেরা model মনে হতো। আসল ব্যক্তিগত তথ্যে training দিলে এটিই ঝুঁকি। মনে রাখবেন, ওপরের ৪.৮২-এর model আর এটি একই স্থাপত্য; পার্থক্য শুধু dropout আর কোথায় থামা।

কী সাধারণীকরণ? রিভিউর ছাঁচে সেটি মাপা যায়, কারণ নিয়ম আমরা জানি। ৩২টি ধারায় মোট প্রায় ১৮০টি সম্পূর্ণ লাইন তৈরি করে:

temp লাইন  ছাঁচ-ঠিক  ৬-অঙ্ক  পণ্য+শহর  রেটিং↔মন্তব্য
0.5   187  100.0%   46.0%   100.0%   100.0%
1.0   185   95.7%   55.4%    98.3%    97.7%
1.5   177   76.3%   41.5%    78.5%    63.7%

Test রিভিউর order digit-এ NLL ২.৪৬, যা ln ১০ = ২.৩০-এর চেয়েও খারাপ। অঙ্কগুলো এলোমেলো, তাই সেরা সম্ভব উত্তর হলো সমান ভাগ; train-এর এলোমেলোতে নকশা খুঁজে model অকারণ আত্মবিশ্বাস পেয়েছে। বাকি অক্ষরে NLL ০.১৭. রেটিং আর দুটি মন্তব্যের মিল τ = ১.০-এ প্রায় ৯৮%: দ্বিতীয় মন্তব্য রেটিংয়ের প্রায় ১৫–২৫ অক্ষর পরে আসে, যা bigram বা trigram-এর ইতিহাসের অনেক বাইরে। অথচ সহজ মনে হওয়া “ছয়টি অঙ্ক” নিয়মটি মাত্র ৪২–৫৫% ক্ষেত্রে মেনেছে। কেন নির্ভরযোগ্য কারণ বলতে পারি না; একটি সম্ভাব্য অনুমান হলো, পরপর এলোমেলো অঙ্কের মধ্যে গোনার সূত্র কম। Model মানুষের চোখে “সহজ” নিয়ম আগে শেখে, এমন ধরে নেওয়া চলে না।

পারে (এই ডেটায়, মেপে দেখা)পারে না
পরের অক্ষর bigram-এর চেয়ে অনেক ভালো অনুমানগল্পের যুক্তি ধরে রাখা, চরিত্র মনে রাখা
উদ্ধৃতি খোলা-বন্ধ, চেনা শব্দ, লেখকের ধরনT = ১২৮ অক্ষরের আগের কিছু জানা
রিভিউর ছাঁচ, রেটিং আর মন্তব্যের সম্পর্কছয় অঙ্ক নির্ভরযোগ্যভাবে গোনা
পুনরাবৃত্তিমূলক অংশ হুবহু বলাকোনো তথ্য সত্য কি না যাচাই করা; প্রশ্নের উত্তর দেওয়া

আরও গভীরে: নকশাটি Vaswani ও সহকর্মীদের ২০১৭ সালের Transformer থেকে শুধু decoder অংশ নিয়ে, যে পথ Radford ও সহকর্মীদের ২০১৮ সালের GPT প্রতিবেদনে দেখা যায়। Pre-norm-এর ধরনটি GPT-2 (২০১৯)-এর প্রতিবেদনে উল্লেখ আছে; Weight tying-এর আলোচনা Press ও Wolf আর Inan ও সহকর্মীদের কাজে (২০১৬–২০১৭)। এমন ছোট, পড়ার মতো কোডের পরিচিত উদাহরণ Andrej Karpathy-র minGPT আর nanoGPT; আমাদের কোড তাদের কপি নয়, তবে ধাঁচে মিল আছে। Carlini ও সহকর্মীরা (২০২১) বড় language model থেকে training ডেটা বের করে দেখিয়েছেন; আমাদের মুখস্থের পরীক্ষা তারই খুব সরল রূপ।

পরীক্ষা ও ইন্টারভিউতে:

  • Causal mask কেন? লক্ষ্য xt+১; mask ছাড়া model সরাসরি উত্তর দেখে ফেলে, লেখার সময় অকেজো।
  • −∞ কেন, ০ কেন নয়? Softmax-এর আগে ০ বসালে e০ = ১, মানে weight থেকে যায়; −∞-তে weight ঠিক ০.
  • একটি block-এ কত parameter? d = ১৯২, bias ছাড়া attention, ৪ গুণ FFN: ১,৪৭,৪৫৬ + ২,৯৫,৮৭২ + ৭৬৮ = ৪,৪৪,০৯৬. Head সংখ্যায় বদলায় না।
  • Perplexity কী? eNLL; NLL ১.৬০৯ হলে ৫. Vocabulary আলাদা হলে দুই model-এর সরাসরি তুলনা চলে না।
  • Temperature কী বদলায়? শুধু তোলার বণ্টন, parameter বা অক্ষরের ক্রম নয়। τ → ০ মানে greedy.

৯ · AI-এর যুগে — Transformer লেখায় AI-এর সাহায্য

AI assistant এমন একটি model কয়েক সেকেন্ডে লিখে দেবে। কোড চলবেও, আর সেখানেই বিপদ। সাধারণ ভুল: mask ভুল দিকে বসানো, লক্ষ্য না সরানো, model.eval() ভুলে যাওয়া, val-এ একই গল্পের এলোমেলো টুকরো নেওয়া, একটি সুন্দর নমুনা দেখিয়ে “model বুঝেছে” বলা, আর পুরোনো বা বানানো API. চালানোর আগে যাচাই চাইতে হবে।

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

আমি PyTorch 2.x-এ বাংলা লেখার জন্য character-level GPT-ধাঁচের ছোট model লিখছি (T = 128, d = 192, ৪ layer, ৬ head, pre-norm, weight tying). ১. Tokenizer, Dataset, attention, block, model, training loop আর generate আলাদা ছোট টুকরোয় দাও; F.scaled_dot_product_attention ছাড়া mask নিজে হাতে লেখো। ২. এক ঘর সরানো x আর y একটি উদাহরণে ছাপানোর কোড দাও। ৩. Causal mask পরীক্ষার কোড দাও: শেষ token বদলালে আগের ঘরের logits ঠিক একই থাকে কি না। ৪. Parameter সংখ্যা হাতে গুনে দেখাও আর কোডের সংখ্যার সঙ্গে মেলাও। ৫. Train, val আর test পুরো গল্প ধরে ভাগ করো, এলোমেলো টুকরো ধরে নয়; bigram baseline-এর perplexity একই test-এ দাও। ৬. আগে এক batch overfit করো; তারপর ৩টি seed-এ test NLL-এর গড় আর sd. ৭. যেসব API তুমি PyTorch-এর এই সংস্করণের docs-এ যাচাই করোনি, তা স্পষ্ট করে বলো।

AI-এর লেখা ছোট language model হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Shannon ১৯৪৮ সালেই অক্ষরের n-gram দিয়ে লেখা তৈরি করে দেখিয়েছিলেন। তাহলে Transformer আসলে নতুন কী এনেছে?

Claude Shannon-এর “A Mathematical Theory of Communication” (১৯৪৮) প্রবন্ধে ইংরেজি অক্ষর আর শব্দের ধাপে ধাপে ভালো approximation দেখানো হয়: শূন্য-order-এর এলোমেলো অক্ষর, তারপর একক অক্ষরের ঘনত্ব, তারপর পরপর অক্ষরের সম্পর্ক। ১৯৫১ সালের “Prediction and Entropy of Printed English” প্রবন্ধে তিনি মানুষকে পরের অক্ষর আন্দাজ করতে দিয়ে ভাষার entropy-র একটি অনুমান দেন। মানে “ভাষা হলো পরের অক্ষরের অনুমান”, এই ধারণা আজকের নয়। আমাদের পরীক্ষার ক্রমও সেই পথই: uniform ৮৩, bigram ১১.৩৮, trigram ৭.৩৯.

তাহলে নতুন কী? প্রথমত, সমস্যাটি ঘরের সংখ্যায়। n-gram-এ প্রতিটি ইতিহাসের জন্য আলাদা ঘর লাগে; ৮৩টি অক্ষরে ৪টি অক্ষরের ইতিহাস মানে সম্ভাব্য ৪.৭ কোটির বেশি ঘর, আর আমাদের লেখায় অক্ষর আছে মাত্র আড়াই লাখ। তাই 5-gram-এর test perplexity trigram-এর চেয়ে খারাপ, ৮.৩৯. Smoothing বা backoff কিছুটা সামলায়, মৌলিক সমস্যা থাকে।

দ্বিতীয়ত, Bengio ও সহকর্মীদের ২০০৩ সালের neural probabilistic language model ইতিমধ্যে দেখায়, embedding-এর ভাগাভাগিতে এক ইতিহাসের শেখা আরেকটিতে কাজে লাগে। RNN সেই ইতিহাস লম্বা করে, কিন্তু একটি state-এর সরু পথে (পর্ব ৩০)। Attention যেকোনো আগের ঘর সরাসরি দেখতে পারে। আমাদের রিভিউতে দ্বিতীয় মন্তব্য রেটিংয়ের প্রায় ১৫–২৫ অক্ষর পরে আসে, অথচ রেটিংয়ের সঙ্গে মিল প্রায় ৯৮%: শুধু দুই অক্ষরের ইতিহাস দেখে trigram এই দূরের সম্পর্ক সরাসরি ধরতে পারে না।

তৃতীয়ত, দামও আছে। Trigram কয়েক সেকেন্ডে গোনা যায়, ব্যাখ্যা করা যায়; আমাদের model GPU-তে ৫ মিনিটের বেশি নেয়, আর ব্যাখ্যা করা কঠিন। ভাষা বিষয়ে কোনো নতুন “তত্ত্ব” এখানে আসেনি; এসেছে অনেক ভালো একটি function approximator. Model ভাষা “বোঝে” কি না, সে প্রশ্ন এই সংখ্যাগুলো দিয়ে মীমাংসা হয় না।

মূল উপলব্ধি: লক্ষ্য একই পুরোনো পরের-অক্ষরের অনুমান; নতুন হলো লম্বা ইতিহাসকে ঘর ছাড়া, ভাগ করা weight দিয়ে সামলানোর উপায়।

প্র ০২Train NLL ১.০৯ আর test NLL ১.৫৭. এটি কি খারাপ overfitting? Training আগে থামানো উচিত ছিল?

Loss curve-এ ফাঁকটি বড়, কিন্তু উত্তর অত সরল নয়। প্রথমে একটি সতর্কতা: test-এর গল্পগুলোতে নতুন নাম, নতুন শব্দ, ভিন্ন ধরনের বাক্য থাকে। সেরা সম্ভব model-ও সেখানে train-এর সমান নম্বর পাবে, এমন প্রত্যাশা নেই। তাই ফাঁকের একটি অংশ overfitting নয়, ডেটার পার্থক্য। আবার আমাদের val গল্পের loss test-এর চেয়ে বেশি (১.৬৪ বনাম ১.৫৭), অথচ দুটিই না-দেখা। মানে গল্প বাছাইও ফল নড়ায়। দুই-তিনটি গল্প সরিয়ে দিলে সংখ্যা কিছুটা বদলাবে।

দ্বিতীয় প্রশ্ন আগে থামানো। Curve-এ val প্রায় step ২০০০-এ থামে, কিন্তু তারপর বাড়ে না, সামান্য ঘোরে। Cosine schedule-এ শেষের দিকে learning rate কমে আসে, তাই বড় ক্ষতি হয় না। চেকপয়েন্ট সেরা val-এ রাখা হয়েছে, তাই ফলে ঝুঁকি নেই। Early stopping বহুদিনের পরিচিত কৌশল, তবে overparameterized model-এ val curve সবসময় একঘেয়ে নয়; Nakkiran ও সহকর্মীদের deep double descent (২০১৯) তার একটি পরিচিত উদাহরণ। এখানে তেমন কিছু দেখিনি, দাবিও করছি না।

তৃতীয়ত, পরীক্ষা করা যায়। Dropout তুলে দিলে কী হয়, আকার ছোট করলে কী হয়, তা ওপরের টেবিলে আছে। আরও ভালো উপায় সাধারণত আরও ডেটা: আড়াই লাখ অক্ষর খুবই কম, আর এই লেখা একজন লেখকের। Parameter ১৮ লাখ, অক্ষর প্রায় আড়াই লাখ। Dropout ০-তে model প্রায় পুরোটা মুখস্থ করল (৯৮.৫%), আর dropout ০.২-এ একই আকারের model করল না (০.৫%). মানে parameter গুনে সাধারণীকরণের কথা বলা যায় না; training-এর পদ্ধতিও সমান গুরুত্বপূর্ণ।

বাস্তবের সিদ্ধান্ত হলো: লক্ষ্য কী? সর্বোচ্চ test মাপ চাইলে আরও ডেটা বা regularization (পর্ব ১৬)। চমৎকার demo চাইলে হয়তো বেশি training-ও চলে, যদিও তখন মুখস্থের ঝুঁকি বাড়ে।

মূল উপলব্ধি: Train আর val-এর ফাঁক একটি লক্ষণ, রোগনির্ণয় নয়; কারণ খুঁজতে ছোট পরীক্ষা, ভাগের ধরন আর baseline, সব একসঙ্গে দেখতে হয়।

প্র ০৩Code point-এ বাংলা শেখানো কেন দুর্বল? কিন্তু পুরো যুক্তাক্ষর বা শব্দকে token করলেই কি সব ঠিক হয়?

Unicode বাংলাকে ধ্বনি আর চিহ্নের ক্রমে সাজায়, চোখে দেখা আকারে নয়। তাই “ণ্ড” তিন ঘর। τ = ১.৫-এর নমুনায় “হয্নে” আর “সব্তি” সেই ফল: হসন্তের পরে model এমন অক্ষর বসিয়েছে যা ভাষায় প্রায় হয় না। আমাদের vocabulary ৮৩ হওয়ার সুবিধা আছে: অজানা token প্রায় নেই, যেকোনো নতুন শব্দ অন্তত লেখা যায়। অসুবিধা হলো, ১২৮ অক্ষরের জানালা আসলে প্রায় ২৩টি শব্দের মতো ছোট (আমাদের গল্পে শব্দপ্রতি ৫.৬ code point), আর মাঝে মাঝে ভাঙা অক্ষর আসে।

বিকল্প দুটি। Grapheme cluster ধরে token করলে একটি দৃশ্যমান একক এক ঘরে আসে, তবে vocabulary বড় হয়, আর লাইব্রেরি আর লিপির নিয়মের ওপর নির্ভর করতে হয়। পুরো শব্দ token করলে আরেক সমস্যা: বাংলায় বিভক্তির ভিন্নতা অনেক (“বাঘ”, “বাঘের”, “বাঘকে”), তাই অসংখ্য বিরল শব্দ, আর অজানা শব্দের জটিলতা। Sennrich, Haddow আর Birch ২০১৬ সালে subword unit নিয়ে যে কাজ দেখান, তা থেকে BPE-র ব্যবহার ছড়ায়: ঘন ঘন জোড়া মিশিয়ে মাঝের একটি পথ। Kudo আর Richardson-এর SentencePiece (২০১৮) আরেকটি পরিচিত বাস্তবায়ন। বিস্তার পর্ব ৪০-এ।

তবে সাবধান। BPE-তেও perplexity সরাসরি তুলনা করা চলে না, কারণ এক token এক অক্ষরের সমান নয়; তুলনা করতে হলে প্রতি অক্ষর বা প্রতি শব্দে bit-এ আনতে হবে। আরেকটি বাস্তব বিষয়: ইংরেজি-কেন্দ্রিক ডেটায় শেখা tokenizer বাংলাকে অনেক সময় ছোট টুকরোয় ভাঙে; নিজের ভাষায় পরীক্ষা করা দরকার। এখানে সাধারণ দাবি না করে নিজে মাপা ভালো।

Normalization-ও ভোলা চলবে না। আমরা NFC করেছি; “য়” এখানে দুই code point হিসেবে থাকে। না করলে একই দেখতে দুটি লেখা ভিন্ন token পায়, যা আপনি চোখে ধরতে পারবেন না, শুধু loss-এ টের পাবেন।

মূল উপলব্ধি: Token-এর আকার একটি নকশার সিদ্ধান্ত; ছোট token-এ লেখা সবসময় যায় কিন্তু শেখা কঠিন, বড় token-এ শেখা সহজ কিন্তু অজানার সমস্যা। বাংলায় সিদ্ধান্তটি মেপে নিতে হয়।

প্র ০৪Temperature বাড়ালে লেখা “সৃজনশীল” হয়, এই কথা কতটা ঠিক? আর একটি ভালো নমুনা দেখিয়ে model-এর গুণ বলা কেন বিপজ্জনক?

Temperature শুধু একটি বণ্টনকে তীক্ষ্ণ বা সমতল করে; পরিসংখ্যানের ভাষায় এটি নতুন কিছু জানায় না। “সৃজনশীল” একটি বিচারের শব্দ, মাপের নয়। আমাদের মাপে τ = ১.৫-এ নতুন শব্দ ৪১%, কিন্তু উদাহরণে দেখছেন “টা”, “স”, “দর”, “ত”-এর মতো ভাঙা টুকরো: নতুনত্ব আর ভুল এখানে আলাদা করা যায়নি। মানুষের চোখে ভুলের মধ্যে কখনো মজাদার কিছু ধরা পড়ে, আর সেটিকে আমরা “সৃজন” বলে ফেলি।

Low temperature-ও নিরাপদ নয়। Holtzman ও সহকর্মীদের “The Curious Case of Neural Text Degeneration” (২০১৯) দেখায়, বড় GPT-2-তে শুধু সবচেয়ে সম্ভাব্য পথ ধরলে লেখা পুনরাবৃত্তিতে আটকে যায়; তাঁরা nucleus sampling প্রস্তাব করেন। আমাদের ছোট model-এ τ = ০.৫-এর নমুনায় “বাঘ বললে” বারবার আসা একই ঝোঁকের ছোট রূপ, তবে এত ছোট পরীক্ষায় একে প্রমাণ বলা যায় না। Top-k, top-p-এর বিস্তার পর্ব ৫০-এ।

নমুনা বাছাই সবচেয়ে বড় ফাঁদ। ২০১৯ সালে GPT-2-র ঘোষণায় যে একটি নমুনা বহুল আলোচিত হয়েছিল, তার সঙ্গে নির্মাতারা নিজেরাই উল্লেখ করেছিলেন, ভালো ফল পেতে কয়েকবার চেষ্টা লেগেছে। আপনি ১০০টি নমুনা তুলে সবচেয়ে সুন্দরটি দেখালে পাঠক model-এর ক্ষমতা নিয়ে ভুল ধারণা পাবেন, যদিও প্রতিটি অক্ষর “সত্যিকারের”। এই কারণেই এই পর্বে নমুনা প্রথম seed থেকে বাছাই ছাড়া দেওয়া, আর পাশে NLL, নিয়ম-মানার হার ইত্যাদি মাপ।

Likelihood-ও পুরো সত্য নয়। ভালো NLL মানে ভালো অক্ষর-অনুমান; সত্যতা, সংগতি বা কাজের উপযোগিতা আলাদা প্রশ্ন। তাই একটি কাজের সিস্টেমে কাজ-ভিত্তিক মূল্যায়নও চাই, যেমন আমাদের রেটিং আর মন্তব্যের নিয়ম।

মূল উপলব্ধি: Temperature ঝুঁকি আর বৈচিত্র্যের একটি নব, সৃজনশীলতার সুইচ নয়; বাছাই করা নমুনা নয়, মাপ আর বাছাইহীন নমুনা দিয়ে model বিচার করতে হয়।

প্র ০৫আমাদের model-কে কেবল বড় করলে আর বেশি বাংলা লেখা দিলেই কি একটি কাজের বাংলা LLM হবে? আর কখন নিজে শূন্য থেকে training-ই ভুল সিদ্ধান্ত?

স্থাপত্যের দিক থেকে অনেকটাই একই। GPT-2 সবচেয়ে বড় সংস্করণে প্রায় ১৫০ কোটি parameter-এর ছিল বলে প্রকাশিত প্রতিবেদন জানায়; আমাদের চেয়ে প্রায় ৮০০ গুণ। Kaplan ও সহকর্মীরা (২০২০) দেখান, একটি পরিসরে ডেটা, parameter আর compute বাড়লে loss একটি power-law-এর মতো কমে; এটি পর্যবেক্ষণ, প্রকৃতির নিয়ম নয়। বিস্তার পর্ব ৫১-এ। কিন্তু “কেবল বড় করা”-র আড়ালে অনেক কিছু লুকানো: কোটি কোটি শব্দের পরিষ্কার ডেটা, দীর্ঘ training, বহু GPU, tokenizer-এর সিদ্ধান্ত, আর সেই খরচ।

ডেটা নিজেই একটি বড় প্রশ্ন, বিশেষত বাংলায়। আমরা উপেন্দ্রকিশোরের লেখা নিয়েছি, কারণ সেটির ব্যবহারের অবস্থান পরিষ্কার। ইন্টারনেটের বাংলা লেখা সংগ্রহ করলে কপিরাইট, ব্যক্তিগত তথ্য আর মানের প্রশ্ন আসে, আর কিছু উপভাষা বা আঞ্চলিক লেখা কম থাকতে পারে। এ বিষয়ে কোনো সংখ্যা আমরা দাবি করছি না; নিজের প্রকল্পে আগে ডেটার উৎস আর অনুমতি দেখতে হবে।

পরের ধাপও আলাদা। বড় pretraining-এর পর একটি model শুধু লেখা চালিয়ে যায়; প্রশ্নের উত্তর দেওয়ার মতো আচরণ আসে পরের নির্দেশনামূলক training-এ, যা কোর্সের পরের দিকে দেখবেন (পর্ব ৪৯ GPT আর decoder-only model). তাই “বড় model” আর “কাজের সহকারী” একই জিনিস নয়।

বাজারঘরের বাস্তব কাজে সিদ্ধান্ত কী? রিভিউ শ্রেণিবিভাগ, সারাংশ বা প্রশ্নোত্তর চাইলে প্রথমে ছোট open model বা সরল পদ্ধতি দিয়ে শুরু করা সাধারণত যৌক্তিক: fine-tuning (পর্ব ৫৪) বা AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এর TF-IDF. শূন্য থেকে training তখনই, যখন উদ্দেশ্য শেখা, গবেষণা, খুব নির্দিষ্ট ছোট ডেটা, বা বাইরের model ব্যবহার করা যায় না। আজকের পর্বের মূল লাভ কোড নয়, বরং এই বোধ: কোথায় কী ভাঙে, কীভাবে মাপতে হয়।

মূল উপলব্ধি: স্থাপত্যের কঙ্কাল একই থাকলেও একটি কাজের LLM-এর বড় অংশ ডেটা, compute আর পরের ধাপ; কঙ্কাল নিজে লিখে দেখা শেখার জন্য, সবসময় ব্যবহারের জন্য নয়।

অনুশীলন

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

  1. পূর্ণ numerical: (ক) একই নকশায় V = ১০০, T = ৬৪, d = ১২৮, ৪ head, ২ layer, weight tying, attention-এ bias নেই, FFN-এ আছে: মোট parameter কত? (খ) একটি model চারটি পরের অক্ষরকে probability দিয়েছে ০.৫, ০.২৫, ০.১, ০.৪. NLL আর perplexity কত? (গ) Logit [১, ০]; τ = ০.৫-এ প্রথম অক্ষরের probability কত?

    (ক) Token ১০০ × ১২৮ = ১২,৮০০; position ৬৪ × ১২৮ = ৮,১৯২. প্রতি block: attention ৪ × ১২৮² = ৬৫,৫৩৬; FFN (১২৮ × ৫১২ + ৫১২) + (৫১২ × ১২৮ + ১২৮) = ১,৩১,৭১২; দুই LayerNorm ৫১২; মোট ১,৯৭,৭৬০. দুটি block ৩,৯৫,৫২০. শেষ LayerNorm ২৫৬. সব মিলিয়ে ৪,১৬,৭৬৮; আমাদের কোডে চালিয়ে এই সংখ্যাই এসেছে। (খ) NLL = (০.৬৯৩ + ১.৩৮৬ + ২.৩০৩ + ০.৯১৬) / ৪ = ১.৩২৫; perplexity = e১.৩২৫ ≈ ৩.৭৬. (গ) z/τ = [২, ০]; p = e২ / (e২ + ১) = ৭.৩৮৯ / ৮.৩৮৯ ≈ ০.৮৮১.

  2. সমালোচনা ও নকশা: একজন লিখেছেন: loss = F.cross_entropy(logits.view(-1, V), x.view(-1)), attention-এ mask নেই। ডেটা ভাগ: সব গল্প জুড়ে এলোমেলো ৯০% জানালা train, ১০% val. ফল: val loss ০.০২, আর “দারুণ নমুনা” একটি দেখিয়েছেন। কী কী সমস্যা? কোন পরীক্ষায় ধরবেন?

    চারটি। এক, লক্ষ্য x নিজেই, y নয়: model শুধু ইনপুট নকল করে। দুই, mask নেই: ভবিষ্যৎ দেখা যায়; লক্ষ্য ঠিক করলেও ডানের অক্ষর দেখে উত্তর পাবে। তিন, এলোমেলো জানালায় val আর train-এ একই বাক্যের প্রায় একই টুকরো পড়ে, তাই leakage: পুরো গল্প আলাদা করা দরকার। চার, একটি বাছাই করা নমুনা কোনো প্রমাণ নয়। পরীক্ষা: x আর y ছাপানো; শেষ token বদলে leak test; শুরুর loss ln V-এর সঙ্গে মেলানো; bigram-এর perplexity-র পাশে ফল রাখা। ০.০২ দেখলেই সন্দেহ করা উচিত: খালি এলোমেলো order digit-ও এত কম হওয়া অসম্ভব।

  3. আউটপুট অনুমান করুন: কী ছাপা হবে? ids = [5, 2, 7, 1, 3]; T = 3; i = 1; c = ids[i:i+T+1]; print(c[:-1], c[1:]); import numpy as np; print(np.tril(np.ones((3, 3), dtype=int)).sum(axis=1).tolist())

    [2, 7, 1] [7, 1, 3] এবং [1, 2, 3]. জানালা শুরু ঘর ১ থেকে, T + ১ = ৪টি মান: 2, 7, 1, 3. x শেষটি বাদ, y প্রথমটি বাদ। Tril-এর প্রতি সারিতে যতগুলো ১, সেই ঘর ততগুলো token দেখতে পায়: প্রথম ঘর ১টি (নিজেকে), তৃতীয় ঘর ৩টি।

  4. নকশা: বাজারঘর চায়, model ছয় অঙ্কের অর্ডার নম্বরের নিয়ম আরও ভালো মানুক, আর অর্ডার নম্বরের ওপর অকারণ আত্মবিশ্বাস না রাখুক। একটি ছোট পরীক্ষার পরিকল্পনা লিখুন; অন্তত দুটি পরিবর্তন আর কোন মাপে বিচার করবেন, তা বলুন।

    পরিবর্তন: (১) অঙ্কগুলোকে একটি বিশেষ token দিয়ে বদলে দেওয়া, যেমন <ORDER>, আর লেখার পরে নম্বরটি নিয়ম দিয়ে বসানো। ছয় অঙ্কের নিয়মটি তখন model-এর দায়িত্বই থাকে না; এটিই প্রায়ই সেরা সমাধান। (২) Label smoothing (পর্ব ১৩) বা বেশি dropout, যাতে এলোমেলো অঙ্কে আত্মবিশ্বাস কমে। (৩) আরও রিভিউ দিয়ে training. মাপ: digit ঘরের NLL, ln ১০ ≈ ২.৩০-এর সঙ্গে তুলনা করে; রিভিউ ছাঁচে ঠিক ছয় অঙ্কের হার; আর বাকি অক্ষরের NLL, যাতে এক লাভে আরেক ক্ষতি ধরা পড়ে। পদ্ধতি: ৩টি seed, একই test, একবারে একটি পরিবর্তন। ফল ভালো হলেও আসল অর্ডার নম্বর model দিয়ে লেখা ঠিক হবে না; সেটি ডেটাবেস থেকে আসা উচিত।

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

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

ক্রমশূন্য থেকে একটি ছোট character language model বানানোর ধাপগুলো সঠিক ক্রমে সাজান।

  1. লেখা পরিষ্কার করা, আর পুরো নথি ধরে train, val, test ভাগ করা
  2. Tokenizer আর এক ঘর সরানো Dataset বানানো
  3. Bigram baseline-এর perplexity মাপা
  4. Causal attention, block আর model লিখে parameter মেলানো
  5. Loss curve আর সেরা val চেকপয়েন্ট রেখে training
  6. Test-এ মূল্যায়ন, temperature-সহ নমুনা আর মুখস্থের পরীক্ষা

একটি model-এ “বাজার” লেখাটি তিনটি token: বা, জা, র. জানালার দৈর্ঘ্য T = ২. এক ঘর সরানো ইনপুট x আর লক্ষ্য y কী?

  • x = (বা, জা), y = (বা, জা)
  • x = (বা, জা), y = (জা, র)
  • x = (বা, জা), y = (র)
  • x = (জা, র), y = (বা, জা)

৪টি token-এর causal self-attention-এ তৃতীয় token-এর weight সারিতে কোন কোন ঘর শূন্যের বেশি হতে পারে?

  • শুধু ঘর ৩
  • ঘর ১ আর ২
  • ঘর ১, ২ আর ৩
  • ঘর ১, ২, ৩ আর ৪

একটি model-এর test NLL প্রতি অক্ষরে ১.৬০৯ (স্বাভাবিক log). Perplexity কত, আর ৮৩ অক্ষরের vocabulary-তে এলোমেলো অনুমানের perplexity কত?

  • প্রায় ৫; ৮৩
  • ১.৬০৯; ৮৩
  • প্রায় ৫; ৪.৪২
  • ১০; ৪.৪২

একটি sampling ধাপে logit [২, ১, ০, −১]. Temperature ০.৫ থেকে ১.৫ করলে কী ঘটে?

  • অক্ষরের ক্রম উল্টে যায়
  • Model-এর parameter বদলে যায়
  • প্রথম অক্ষরের probability বেড়ে যায়
  • বণ্টন সমতল হয়, ছোট logit-এর অক্ষর বেশি সুযোগ পায়

V = ৫০, d = ৬৪ এমন একটি model-এ token embedding আর শেষের bias-ছাড়া linear layer-এর weight tying করা। এই দুটি মিলিয়ে কতটি আলাদা parameter?

  • ৬,৪০০
  • ৩,২০০
  • ১১৪
  • ০

একটি রিভিউ ডেটায় ৬ ঘরের অর্ডার নম্বরের অঙ্ক সম্পূর্ণ এলোমেলো। Test-এ একটি model-এর এই ঘরগুলোর NLL ২.৪৬, আর ln ১০ ≈ ২.৩০. সবচেয়ে যুক্তিসঙ্গত ব্যাখ্যা কী?

  • Model অঙ্কের গোপন নকশা শিখেছে
  • Loss-এর হিসাবে নিশ্চয়ই ভুল আছে
  • Train-এর noise-এ model অকারণ আত্মবিশ্বাসী হয়েছে
  • অঙ্কগুলো vocabulary-তে নেই

শব্দকোষ

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

মনে রাখুন

  1. কঙ্কাল: token + position embedding, pre-norm block, LayerNorm, tied head. d = ১৯২-এ এক block ৪,৪৪,০৯৬, মোট ১৮,১৭,২৮০ parameter, হাতে গুনে মেলে।
  2. লক্ষ্য আর mask: chunk[:-1] ইনপুট, chunk[1:] লক্ষ্য; tril mask −∞ বসিয়ে ভবিষ্যৎ ঢাকে। শেষ token বদলে leak test করুন।
  3. Baseline: perplexity = eNLL. আগে bigram, পরে model; একই test, পুরো নথি আলাদা।
  4. Temperature: logit / τ. ক্রম একই, বণ্টন বদলায়। কম τ-তে মুখস্থ বাড়ে, বেশি τ-তে ভাঙা শব্দ।
  5. সততা: সুন্দর নমুনা প্রমাণ নয়। Model ছাঁচ আর ধরন শেখে; অর্থ নয়। কী পারে আর কী পারে না, মেপে বলুন।

পরের পর্বে: পর্ব ৩৭ (দক্ষ attention)-এ দেখবেন, লেখা তৈরির সময় প্রতিবার পুরো জানালা নতুন করে হিসাব করার অপচয় KV cache কীভাবে কমায়, আর লম্বা context-এ খরচ কোথায় যায়।

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

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