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

Tokenization — BPE, WordPiece, SentencePiece আর বাংলা

Tokenization — BPE from Scratch, Byte versus Code Point, WordPiece, SentencePiece and Real Runs on Bangla Text

Taught in Bangla, this lesson trains byte pair encoding by hand and in in-page Python, with pair counts, a merge table, encoding, decoding and round-trip checks. It compares byte-level and code point BPE on Bangla vowel signs, explains WordPiece longest match and SentencePiece Unigram, shows what NFC and NFKC change, trains a tokenizer with Hugging Face tokenizers in a real run, and counts how many tokens pretrained multilingual tokenizers spend on Bangla versus English.

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

  • হাতে আর Python-এ BPE training: জোড়া গোনা, merge table, encode, decode, round trip
  • Byte আর code point থেকে শুরু করার পার্থক্য, আর merge কীভাবে কার-চিহ্ন বা অক্ষর কেটে দেয়
  • WordPiece-এর longest match আর SentencePiece-এর Unigram পদ্ধতির ধারণা
  • NFC আর NFKC বাংলা লেখায় ঠিক কী বদলায়
  • Hugging Face tokenizers দিয়ে আসল training, আর ভান্ডার, token ও OOV-এর টেবিল পড়া
  • Special token, round-trip পরীক্ষা, আর pretrained tokenizer-এ বাংলার token গোনা

AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এ Unicode, NFC আর একটি খুদে BPE দেখেছেন, আর পর্ব ৭০-এ চারটি tokenizer-এ একই বাক্য গুনেছেন, যেখানে “রাজশাহী” একটিতে ৯ টুকরো, আরেকটিতে ৩ টুকরো। কেন এমন হলো, তার উত্তর সেখানে দেওয়া হয়নি। আজ সেই যন্ত্র খুলব। পর্ব ৩৬-এ আমাদের MiniGPT প্রতিটি code point-কে একটি token ধরেছিল, আর পর্ব ১১-এ রিভিউর ভান্ডার ছিল শব্দের। এবার দেখব এই পছন্দগুলোর বিকল্প কী, আর প্রতিটির দাম কত।

১ · Tokenization একটি model-এর সিদ্ধান্ত

দোকানের হিসাব খাতার কথা ভাবুন। প্রতিটি পণ্যের পুরো নাম আলাদা ঘরে লিখলে খাতা হবে বিশাল, আর নতুন পণ্য এলে ঘরই থাকবে না। শুধু অক্ষর ধরে লিখলে সব নাম লেখা যাবে, কিন্তু প্রতিটি লাইন হবে লম্বা। অভিজ্ঞ খাতাদার মাঝের পথ নেন: “শাড়ি”, “চাল”, “কেজি” তাঁর চেনা সংক্ষেপ, আর নতুন নাম লেখেন চেনা টুকরো জুড়ে। Subword tokenizer এই খাতাদার, যিনি সংক্ষেপগুলো ডেটা থেকে নিজেই শিখে নেন।

Tokenizer লেখাকে token-এর ক্রমে ভাঙে, আর প্রতিটি token পায় একটি নম্বর। Model শুধু সেই নম্বরই দেখে, তাই tokenizer যা আলাদা করে দেখায় না, model সেখান থেকেই শুরু করতে বাধ্য। পছন্দটি প্রভাব ফেলে তিন জায়গায়: vocabulary-র আকার (তাই embedding টেবিলের parameter), প্রতি বাক্যে token-এর সংখ্যা (তাই ক্রমের দৈর্ঘ্য, গতি আর খরচ), আর অচেনা শব্দের হার।

স্তর“বাজারঘরের”সুবিধাদাম
শব্দ১ token, ভান্ডারে না থাকলে অচেনাক্রম ছোটভান্ডার বড়; বিভক্তি-যুক্ত প্রতিটি রূপ আলাদা
Code point৯ tokenভান্ডার খুব ছোট, প্রায় কিছুই অচেনা নয়ক্রম লম্বা; অর্থের একক গড়তে হয় model-কে
Subwordবাজার + ঘর + েরচেনা টুকরোয় নতুন শব্দTraining ডেটার ওপর নির্ভরতা; টুকরোর সীমা অক্ষরের মাঝে পড়তে পারে

বাংলায় এই প্রশ্নটি একটু বেশি ধারালো। “বাজার”, “বাজারে”, “বাজারের”, “বাজারঘরের”: একই মূলে বিভক্তি আর জোড়া শব্দ। শব্দ-স্তরের ভান্ডারে এরা চারটি সম্পর্কহীন ঘর। পর্ব ৩৯ (NLP-এর পথ)-এ বাংলার এই চ্যালেঞ্জগুলোর মানচিত্র আছে; এখানে আমরা যন্ত্রটি নিয়ে কাজ করব।

একটি আধুনিক tokenizer-এর পাঁচ ধাপ

The tokenizer pipeline, with one real BPE result on a Bangla word

Tokenizer pipeline: normalizer, pre-tokenizer, model, post-processor, decoder Normalizer · NFC Pre-tokenizer · সীমা Model · BPE Post-processor Decoder শাড়ি → একই বানানের এক রূপ ▁শাড়ি ▁শ · া · ড়ি [CLS] ▁শ · া · ড়ি [SEP] id → শাড়ি
ছবি ৪০.১ — প্রতিটি ধাপ আলাদা করে বদলানো যায়, ভুলও হয় আলাদা করে। ▁ চিহ্ন মানে শব্দের শুরু। Model ধাপের টুকরোগুলো অংশ ৬-এর সত্যিকারের BPE run থেকে; post-processor ধাপটি উদাহরণ, ওই run-এ তা চালু ছিল না।

২ · BPE — হাতে তিনটি merge, তারপর Python

BPE শেখে একটি merge table: কোন দুই টুকরো কোন ক্রমে জুড়বে। শুরুতে প্রতিটি code point একটি টুকরো। প্রতি ধাপে সব শব্দে পাশাপাশি জোড়া গুনে, শব্দের সংখ্যা দিয়ে গুণ করে, সবচেয়ে বড়টি জোড়া দেওয়া হয়। নিচের উদাহরণে শব্দ আটটি: কার ×৪, জার ×২, বাকি ×৩, বাজার ×৫, বাজারে ×২, বাজারের ×৩, ঘর ×৪, ঘরের ×৩.

1গোনা: সব জোড়া2merge 1: া + র3merge 2: ব + া4merge 3: জ + ার5encode: বাজারঘর
মান (Watch)
সেরা জোড়া
বাজার
বাকি
ঘরের
মোট token
ভান্ডার
  1. গোনা। “া র” আসে কার (৪), জার (২), বাজার (৫), বাজারে (২), বাজারের (৩)-এ: মোট ১৬. সবচেয়ে বড় এটিই। মোট token ১০৮, ভান্ডারে ৮টি একক।
  2. প্রথম merge. “া” আর “র” জুড়ে “ার”, যেখানেই পাশাপাশি। “ঘরের”-এ “া” নেই, তাই বদলায় না। মোট token ১৬ কমে ৯২; ভান্ডারে একটি যোগ হলো।
  3. আবার গোনা। জোড়া দেওয়ার পরে গোনা বদলে যায়, তাই প্রতি ধাপে নতুন করে গুনতে হয়। এবার সেরা “ব া”, ১৩ বার। “বাকি”-র ৩ বারও এতে আছে। মোট ৭৯.
  4. তৃতীয় merge. “জ” + “ার”, ১২ বার। নতুন টুকরো “জার” গড়া হলো আগের একটি টুকরো দিয়ে, যা BPE-এর মূল কৌশল। মোট ৬৭; পরের merge হবে “বা” + “জার”.
  5. নতুন শব্দ encode. “বাজারঘর” শিখিনি। Code point-এ ভেঙে merge-গুলো শেখার ক্রমে চালানো হয়: ার, তারপর বা, তারপর জার. ফল ৪ token, কোনো অচেনা নেই। Decode মানে শুধু টুকরোগুলো পরপর জোড়া দেওয়া।

এবার একই কাজ কোডে, একটি নিজের লেখা ছোট কাল্পনিক লেখায়, ৪৫৯ code point. লেখাটি পাতার ভেতরেই আছে, তাই কিছু নামাতে হয় না। Space-কে শব্দের গায়ে রাখা হয়েছে (▁ দেখায়), যাতে decode-এ লেখা হুবহু ফেরে।

import re; from collections import Counter
text = open("bazarghar.txt", encoding="utf-8").read().strip()
chunks = Counter(re.findall(r"\s*\S+", text))     # space শব্দের গায়ে থাকে
show = lambda t: t.replace(" ", "▁")
def pair_counts(seqs):
    c = Counter()
    for seq, f in seqs.items():
        for p in zip(seq, seq[1:]): c[p] += f
    return c
def apply_merge(seq, a, b):
    out = []
    for t in seq:
        if out and (out[-1], t) == (a, b): out[-1] = a + b
        else: out.append(t)
    return tuple(out)
def train(seqs, k):
    merges = []
    while len(merges) < k and (c := pair_counts(seqs)):
        (a, b), f = c.most_common(1)[0]           # সমান হলে আগে পাওয়াটি
        merges.append((a, b, f))
        seqs = {apply_merge(s, a, b): n for s, n in seqs.items()}
    return merges
def encode(s, merges, split=tuple):
    toks = []
    for ch in re.findall(r"\s*\S+", s):
        seq = split(ch)
        for a, b, _ in merges: seq = apply_merge(seq, a, b)
        toks += seq
    return toks
seqs = {tuple(w): f for w, f in chunks.items()}
print("প্রথম জোড়া:", [(show(a + "+" + b), n) for (a, b), n in pair_counts(seqs).most_common(3)])
merges = train(seqs, 100)
for i, (a, b, f) in enumerate(merges[:6], 1): print(f"merge {i}: {show(a)} + {show(b)} → {show(a + b)} ({f})")
print("code point:", len(text), "| token:", len(ids := encode(text, merges)), "| round trip:", "".join(ids) == text)
print([show(x) for x in encode(" বাজারঘরের দোকান", merges)])

আসল আউটপুট:

প্রথম জোড়া: [('া+র', 11), ('র+ে', 11), ('ে+র', 11)]
merge 1: া + র → ার (11)
merge 2: ে + র → ের (11)
merge 3: ▁ + ক → ▁ক (11)
merge 4: ▁ + দ → ▁দ (8)
merge 5: া + জ → াজ (6)
merge 6: ত + া → তা (6)
code point: 459 | token: 152 | round trip: True
['▁বাজারঘরের', '▁দ', 'ো', 'ক', 'ান']

প্রথম merge-গুলো অর্থের একক নয়, শুধু ঘন ঘন আসা পড়শি: “ের” বিভক্তির মতো দেখালেও “ার” একটি বানানের টুকরো মাত্র। ১০০ merge-এ ৪৫৯ token নেমেছে ১৫২-এ। “বাজারঘরের” চারবার এসেছে, তাই এক token, আর “দোকান” চেনা টুকরোয় ভেঙেছে। খেয়াল করুন, “ো” আর “ক” আলাদা: কার-চিহ্ন আগের অক্ষর থেকে কাটা পড়েছে। লেখা এত ছোট যে এই merge table শুধু ভেতরের কাজ দেখায়, বাস্তবের tokenizer নয়।

BPE প্রথমে ছিল data compression-এর কৌশল: Philip Gage ১৯৯৪ সালে এটি বর্ণনা করেন। Sennrich, Haddow আর Birch ২০১৬ সালের একটি প্রবন্ধে একে অনুবাদের অচেনা শব্দ সামলাতে ব্যবহার করেন। GPT-2-এর ২০১৯ সালের কাজে এটি byte-স্তরে নামে। WordPiece-এর নাম পাওয়া যায় Schuster আর Nakajima-র ২০১২ সালের কণ্ঠ-অনুসন্ধানের কাজে, আর SentencePiece আসে Kudo আর Richardson-এর ২০১৮ সালের প্রবন্ধে।

৩ · Byte না code point — বাংলার দাম

Byte-level BPE শুরু করে UTF-8-এর ২৫৬টি byte দিয়ে। তাই ভান্ডারে কিছু না থাকলেও যেকোনো লেখা encode করা যায়। কিন্তু বাংলার প্রতিটি code point ৩ byte, ইংরেজির ১ byte. প্রথম কয়েকটি merge বাংলায় চলে যায় শুধু code point জোড়া লাগাতে। নিচের ব্লক একই নিয়মে দুই ভাষায়, দুই এককে train করে, আর train-এ না থাকা একটি নতুন বাক্যে token গোনে।

en = open("bazarghar_en.txt", encoding="utf-8").read().strip()
as_bytes = lambda s: tuple(bytes([b]) for b in s.encode("utf-8"))
TEST = {"বাংলা": (text, "বাজারঘরের নতুন শাড়ি কাল ঢাকা থেকে পৌঁছাবে, দাম কম।"),
        "English": (en, "The new Bazarghar sari will reach Dhaka tomorrow, and the price is low.")}
def fit(txt, split, k):
    seqs = Counter()
    for ch in re.findall(r"\s*\S+", txt):
        seqs[split(ch)] += 1
    return train(seqs, k)
print("নতুন বাক্য: অক্ষর, byte, byte/অক্ষর")
for name, (_, s) in TEST.items():
    print(f"{name:8s}{len(s):4d}{len(s.encode()):5d}{len(s.encode()) / len(s):6.2f}")
print("\nলেখা     একক        k=0  k=50 k=150 k=300")
for name, (tr, s) in TEST.items():
    for lvl, sp in (("code point", tuple), ("byte", as_bytes)):
        row = [len(encode(s, fit(tr, sp, k), sp)) for k in (0, 50, 150, 300)]
        print(f"{name:8s}{lvl:11s}", *[f"{x:4d}" for x in row])

আসল আউটপুট:

নতুন বাক্য: অক্ষর, byte, byte/অক্ষর
বাংলা     51  135  2.65
English   71   71  1.00

লেখা     একক        k=0  k=50 k=150 k=300
বাংলা   code point    51   32   18   15
বাংলা   byte         135   42   23   15
English code point    71   40   24   18
English byte          71   40   24   18

ইংরেজিতে দুই সারি হুবহু এক, কারণ এখানে প্রতিটি অক্ষর ১ byte. বাংলায় শুরুতে ১৩৫ বনাম ৫১. ৫০টি merge-এর পরেও byte পিছিয়ে (৪২ বনাম ৩২), কারণ merge খরচ হয়েছে অক্ষর গড়তে। ৩০০ merge-এ ব্যবধান মুছে গেছে, তবে লেখা খুব ছোট বলে তখন সে প্রায় মুখস্থ; বড় লেখায় এই সংখ্যা ধরে নেবেন না। “অক্ষর” এখানে code point, চোখে দেখা অক্ষর নয়।

এবার একটি byte-level tokenizer কীভাবে বাংলা অক্ষরের মাঝখানে কাটে, তা “রাজ” শব্দে দেখুন। তিনটি merge বানানো নয়: অংশ ৬-এর সত্যিকারের byte-level training-এর ১ম, ৪র্থ আর ৬ষ্ঠ merge এগুলোই।

1s.encode("utf-8")2merge E0 + A63merge E0A6 + BE4merge া + E0A65একটি একটি করে decode6সব একসঙ্গে decode
মান (Watch)
token
সংখ্যা
পর্দায়
  1. Byte-এ ভাঙা। র = E0 A6 B0, া = E0 A6 BE, জ = E0 A6 9C. তিন code point, ৯ byte. বাংলার প্রায় সব code point E0 A6 বা E0 A7 দিয়ে শুরু, তাই এই জোড়া সবচেয়ে ঘন ঘন আসে।
  2. প্রথম merge. E0 + A6 জুড়ে একটি token. ৯ থেকে ৬. এই token একা কোনো অক্ষর নয়, শুধু একটি অসম্পূর্ণ শুরু।
  3. আকার-চিহ্ন গড়া। E0A6 + BE, মানে “া”. এটি বৈধ একটি code point. ৫ token.
  4. সীমা পেরোনো। “া” এত ঘন ঘন আসে যে তার পরের E0A6-এর সঙ্গেও জোড়া লাগে। BPE byte গোনে, অক্ষরের সীমা চেনে না। এখন একটি token-এ একটি পুরো “া” আর “জ”-এর শুরু। “জ”-এর শেষ byte 9C আলাদা পড়ে আছে।
  5. একটি একটি করে দেখানো। কোনো টুকরো একা বৈধ UTF-8 না হলে পর্দায় � আসে। Streaming-এ token ধরে ধরে ছাপালে ঠিক এটাই দেখা যায়, যদি কোড অসম্পূর্ণ byte জমিয়ে না রাখে।
  6. একসঙ্গে সব ঠিক। Byte জুড়লে হুবহু “রাজ”. তাই সমস্যা তথ্য হারানোর নয়; সমস্যা হলো একটি token-এর অর্থ আর অক্ষরের সীমার অমিল, আর streaming-এর সাবধানতা।

Code point-এ শুরু করলেও একটি সমস্যা থাকে: চোখে দেখা একটি অক্ষর, মানে একটি grapheme cluster, কাটা পড়তে পারে। “কি” দুটি code point; merge না হলে “ি” একা একটি token. নিচের ব্লক সরল একটি নিয়মে গোনে কোন সীমা grapheme-এর ভেতরে পড়ছে: পরের চিহ্নটি কার বা হসন্ত হলে, অথবা আগেরটি হসন্ত হলে। Unicode-এর পূর্ণ নিয়ম (UAX #29) এর চেয়ে জটিল। বাক্যটি train-এ নেই।

import unicodedata as ud
def inside(s):              # কোন সীমা একটি grapheme-এর ভেতরে পড়ে (সরল নিয়ম)
    return {i for i in range(1, len(s))
            if ud.category(s[i]) in ("Mn", "Mc") or s[i - 1] == "্"}
def cuts(toks, s):
    n, pos, ins = 0, 0, inside(s)
    for t in toks[:-1]:
        pos += len(t)
        n += pos in ins
    return n, len(toks) - 1
seqs = {tuple(w): f for w, f in chunks.items()}
s = " ক্রেতার ছেঁড়া প্যাকেট ফেরত গেল, দোকানদার নতুন শাড়ি পাঠালেন"
for k in (0, 30, 100, 300):
    t = encode(s, train(seqs, k))
    n, tot = cuts(t, s)
    print(f"k={k:3d}: token {len(t):2d}, ভেতরের কাট {n:2d}/{tot}")
print([show(x) for x in encode(" প্যাকেট দোকানদার", train(seqs, 30))])
b = Counter(as_bytes(ch) for ch in re.findall(r"\s*\S+", text))
m = train(b, 30)
w = " ক্রেতা"
tb = encode(w, m, as_bytes)
print("byte k=30:", [show(x.decode("utf-8", "replace")) for x in tb])
print("একসঙ্গে decode:", b"".join(tb).decode("utf-8") == w)
print("প্রথম ৩ byte merge:", [(x.hex(), y.hex(), f) for x, y, f in m[:3]])

আসল আউটপুট:

k=  0: token 61, ভেতরের কাট 24/60
k= 30: token 46, ভেতরের কাট 18/45
k=100: token 30, ভেতরের কাট 11/29
k=300: token 15, ভেতরের কাট  3/14
['▁প', '্', 'য', 'া', 'কে', 'ট', '▁দ', 'ো', 'ক', 'া', 'ন', 'দ', 'ার']
byte k=30: ['▁ক', '্', 'রে�', '�', 'া']
একসঙ্গে decode: True
প্রথম ৩ byte merge: [('e0', 'a6', 302), ('20', 'e0a6', 74), ('e0', 'a7', 64)]

কোনো merge ছাড়া ৬০টি সীমার ২৪টিই অক্ষরের ভেতরে। Merge বাড়লে এই কাট কমে, শূন্য হয় না। “▁প”, “্”, “য”, “া”: “প্যা” একটি চোখে-দেখা অক্ষর হয়ে গেছে চার টুকরো। Byte-স্তরে “রে�” আর “�”-এর মাঝখানে “ত” ভেঙে গেছে। সব জুড়লে লেখা ঠিকই ফেরে। প্রথম byte merge e0 + a6, ৩০২ বার, যা স্টেপারের কথাই মেলায়। Model এই ভাঙা টুকরো থেকেও শিখতে পারে, তাই কাট মানেই ভুল নয়; কিন্তু “অক্ষর গোনা” বা “শব্দের মাঝে থামা”-র মতো কাজে এটি সরাসরি বাধা হতে পারে।

৪ · WordPiece আর SentencePiece — একই লক্ষ্য, অন্য পথ

WordPiece দুটি জায়গায় BPE থেকে আলাদা। প্রথমত, শব্দের ভেতরের টুকরোয় ## চিহ্ন থাকে। দ্বিতীয়ত, encode-এ merge table চালানো হয় না; ভান্ডারে সবচেয়ে লম্বা মিল খোঁজা হয়, বাঁ দিক থেকে।

1পুরো শব্দ থেকে শুরু2পাওয়া গেল: বাজার3বাকি অংশে ## সহ খোঁজ4পাওয়া গেল: ##ঘর5পাওয়া গেল: ##ের6শেষ
মান (Watch)
বাকি
চেষ্টা
ফল
  1. লম্বা থেকে ছোট। ভান্ডার ধরুন: বা, বাজার, ##ঘ, ##ঘর, ##র, ##ে, ##ের. ৯ code point-এর পুরো শব্দ নেই; একটি করে code point ছাঁটা হচ্ছে।
  2. প্রথম মিল। ৫ code point-এ “বাজার” ভান্ডারে আছে। “বা”-ও আছে, কিন্তু লম্বাটিই নেওয়া হয়। একবার নিলে আর ফেরা হয় না: এটি greedy.
  3. এবার ## সহ। শব্দের ভেতরের অংশ বলে “##” জুড়ে খোঁজা হয়। ##ঘরের নেই, ##ঘরে নেই।
  4. দ্বিতীয় মিল। ##ঘর আছে। ##ঘ-ও আছে, কিন্তু লম্বাটি আগে পাওয়া গেছে।
  5. তৃতীয় মিল। বাকি “ের”, আর ##ের আছে।
  6. ফল ৩ token. অংশ ৬-এ আমাদের train করা WordPiece-ও এই শব্দে হুবহু এই তিন টুকরো দিয়েছে। মাঝের কোনো অংশে মিল না পেলে কিন্তু পুরো শব্দটি একটি [UNK] হয়ে যায়, এটিই WordPiece-এর বড় ফাঁদ।

WordPiece-এর প্রচলিত ব্যাখ্যায় জোড়া বাছা হয় score দিয়ে: score = f(ab) / (f(a) × f(b)), যেখানে f মানে গোনা। এতে বিরল টুকরোর জোড়া সুযোগ পায়। তবে Hugging Face tokenizers-এর v0.21.0 source-এ WordPieceTrainer ভেতরে BpeTrainer-ই চালায়, শুধু ## সহ। তাই আমাদের টেবিলের WordPiece-এর ভান্ডার গোনা দিয়ে শেখা; BERT-এর ভান্ডার ঠিক কীভাবে বানানো, তা এখানে দাবি করছি না।

SentencePiece একটি library, যা BPE আর Unigram দুই পদ্ধতিই train করতে পারে। Unigram ধরে নেয়, প্রতিটি টুকরোর একটি নিজস্ব সম্ভাবনা p আছে, আর একটি ভাঙনের সম্ভাবনা হলো টুকরোগুলোর p-এর গুণফল। Encode মানে সবচেয়ে সম্ভাব্য ভাঙন খোঁজা, যা Viterbi-ধরনের dynamic programming-এ করা যায় (AI-এর ভিত্তি কোর্সের পর্ব ৫৩). Training-এ বড় একটি টুকরোর ভান্ডার থেকে শুরু করে, EM-ধরনের হিসাবে (মেশিন লার্নিং কোর্সের পর্ব ৩৯) কম কাজের টুকরো ছেঁটে ফেলা হয়। লেখার দিকটিও আলাদা: SentencePiece সাধারণত whitespace-কে ▁ দিয়ে রাখে, আগে থেকে শব্দ ভাগ করার দরকার পড়ে না।

BPEWordPieceUnigram
Trainingনিচ থেকে জোড়ানিচ থেকে জোড়া, score বা গোনাবড় ভান্ডার থেকে ছাঁটা
Encodemerge শেখার ক্রমেসবচেয়ে লম্বা মিল, greedyসবচেয়ে সম্ভাব্য ভাঙন
Spaceচিহ্ন বা byte-এ রাখাসাধারণত হারায়▁ দিয়ে রাখা
পরিচিত ব্যবহারGPT-2BERTXLM-RoBERTa, T5

৫ · Normalization — NFC, NFKC আর একটি বড় ফাঁদ

চোখে এক, code point-এ ভিন্ন হলে tokenizer দুটিকে আলাদা দেখে। Unicode normalization এই সমস্যা কমায়, যার মূল ধারণা AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এ আছে। এখানে দেখব tokenizer-এর দিক থেকে কী বদলায়। NFC শুধু একই অক্ষরের ভিন্ন লেখাকে এক করে। NFKC আরও এগিয়ে “কাছাকাছি অর্থের” চিহ্নকেও সাধারণ রূপে আনে।

import unicodedata as ud
hexs = lambda s: " ".join(f"{ord(c):04X}" for c in s)
nf = lambda f, s: ud.normalize(f, s)
for name, s in (("য় এক চিহ্নে", "য়"), ("কো দুই চিহ্নে", "কো")):
    print(f"{name}: {hexs(s)} → NFC {hexs(nf('NFC', s))}")
a, b = "শাড়ি", "শাড়ি"
print("শাড়ি দুই ভাবে:", a == b, "→ NFC-র পরে", nf("NFC", a) == nf("NFC", b))
z1, z2 = "র‍্য", "র্য"
print("র্য, ZWJ-সহ আর ছাড়া, NFKC-র পরে:", nf("NFKC", z1) == nf("NFKC", z2))
print("ৎ আর ত্+ZWJ, NFKC-র পরে:", nf("NFKC", "ৎ") == nf("NFKC", "ত্‍"))
for s in ["দাম ½ কেজি", "৫ m²", "① শাড়ি", "ABC১২", "কিনব আজ"]:
    print(repr(s), "NFC:", repr(nf("NFC", s)), "NFKC:", repr(nf("NFKC", s)))

আসল আউটপুট:

য় এক চিহ্নে: 09DF → NFC 09AF 09BC
কো দুই চিহ্নে: 0995 09C7 09BE → NFC 0995 09CB
শাড়ি দুই ভাবে: False → NFC-র পরে True
র্য, ZWJ-সহ আর ছাড়া, NFKC-র পরে: False
ৎ আর ত্+ZWJ, NFKC-র পরে: False
'দাম ½ কেজি' NFC: 'দাম ½ কেজি' NFKC: 'দাম 1⁄2 কেজি'
'৫ m²' NFC: '৫ m²' NFKC: '৫ m2'
'① শাড়ি' NFC: '① শাড়ি' NFKC: '1 শাড়ি'
'ABC১২' NFC: 'ABC১২' NFKC: 'ABC১২'
'কিনব\xa0আজ' NFC: 'কিনব\xa0আজ' NFKC: 'কিনব আজ'

তিনটি শিক্ষা। এক, NFC “য়”-কে দুই code point-এ ভাঙে, আর “ে + া”-কে এক “ো” করে; এটিই মান্য রূপ, তাই “জোড়া লাগায়” কথাটি পুরো সত্য নয়। দুই, NFC বা NFKC কোনোটিই ZWJ বা “ৎ”-এর দুই লেখাকে এক করে না; এগুলোর জন্য নিজের নিয়ম লাগে। তিন, NFKC অর্থ বদলাতে পারে: “৫ m²” হয়ে যায় “৫ m2”, “①” হয়ে যায় “1”, আর “½” হয়ে যায় তিন চিহ্নের “1⁄2”. বাংলা অঙ্ক কিন্তু ইংরেজি অঙ্ক হয় না। খোঁজার মিলে NFKC কাজের হতে পারে; ফেরানোর দরকার থাকলে সাবধান।

এবার আসল ফাঁদ। পুরোনো একটি বিখ্যাত tokenizer-এর normalizer ছোট হাতের অক্ষর করার সঙ্গে সঙ্গে “accent” মুছে দেয়। ইউরোপীয় ভাষায় “é” থেকে “e” হলে ক্ষতি কম। কিন্তু Unicode-এ “accent” মানে Mn শ্রেণির চিহ্ন, আর বাংলার হসন্ত, উ-কার, ঊ-কার, ঋ-কার, নুকতা, চন্দ্রবিন্দু সবই সেই শ্রেণিতে। সত্যিকারের run (tokenizers 0.23.3, দুটিই apache-2.0 লাইসেন্স, model card দেখা):

~/bazarghar-dl$ uv run python pretrained.py
…
BERT normalizer settings:
distilbert/distilbert-base-multilingual-cased {'type': 'BertNormalizer', 'clean_text': True, 'handle_chinese_chars': True, 'strip_accents': None, 'lowercase': False}
google-bert/bert-base-multilingual-uncased {'type': 'BertNormalizer', 'clean_text': True, 'handle_chinese_chars': True, 'strip_accents': None, 'lowercase': True}
in : ব্যাগ খুশি পৌঁছাবে শাড়ি
uncased tokens: ['[CLS]', 'ব', '##যা', '##গ', 'খ', '##শি', 'প', '##ে', '##ৗ', '##ছ', '##াব', '##ে', 'শ', '##াড', '##ি', '[SEP]']
uncased decode: বযাগ খশি পৌছাবে শাডি
cased decode  : ব্যাগ খুশি পৌঁছাবে শাড়ি

“ব্যাগ” হয়েছে “বযাগ”, “খুশি” হয়েছে “খশি”, “শাড়ি” হয়েছে “শাডি”. কোনো error নেই, কোনো warning নেই। strip_accents: None দেখে নিরাপদ মনে হয়, কিন্তু এখানে None মানে lowercase-এর মান অনুসরণ করা। বাংলার কাজে uncased checkpoint নেওয়ার আগে tokenizer-এর decode চোখে দেখুন।

৬ · সত্যিকারের training — Hugging Face tokenizers

ডেটা আর লাইসেন্স: পর্ব ৩৬-এর সেই ৩৯টি গল্প, উপেন্দ্রকিশোর রায়চৌধুরীর “টুনটুনির বই” আর “গুপি গাইন ও বাঘা বাইন”, বাংলা উইকিসংকলন (bn.wikisource.org) থেকে। পাতাগুলোতে PD-India ট্যাগ আছে, মানে সেখানে পাবলিক ডোমেইন হিসেবে চিহ্নিত; নিজের কাজে ব্যবহারের আগে পাতার ট্যাগ আর নিজের দেশের নিয়ম দেখে নেবেন। পুরো গল্প ধরে ভাগ: ৩১টি গল্পে train (১,৯১,৯৫২ code point), ৮টি গল্প held-out (৪৬,১০৮ code point), যা থেকে ৮৫২টি বাক্য। Python 3.14.4, tokenizers 0.23.3, লেখার সময়ে। নিচের কোড আসল স্ক্রিপ্ট থেকে ছাঁটা।

from tokenizers import Tokenizer, Regex, models, trainers, pre_tokenizers, decoders, normalizers
SPECIAL = ["[UNK]", "[PAD]", "[CLS]", "[SEP]"]

t = Tokenizer(models.BPE(unk_token="[UNK]"))       # code-point BPE
t.normalizer = normalizers.NFC()
t.pre_tokenizer = pre_tokenizers.Metaspace()
t.decoder = decoders.Metaspace()
tr = trainers.BpeTrainer(vocab_size=2000, special_tokens=SPECIAL, min_frequency=2)
t.train(["data/train.txt"], tr)

# byte-gpt2: ByteLevel(add_prefix_space=False)
# byte-plain: Split(Regex(r"\s*\S+|\s+"), "isolated") + ByteLevel(use_regex=False)
# wordpiece: models.WordPiece + BertPreTokenizer + decoders.WordPiece + WordPieceTrainer
# unigram:   models.Unigram + Metaspace + UnigramTrainer(unk_token="[UNK]")
~/bazarghar-dl$ uv run python train_tok.py
tokenizers 0.23.3 | held-out sentences: 852
tokenizer     vocab tok/sent   UNK%    RT%   cut%  bad%
word(f>0)      5232     11.3  11.83      -      -     -
word(f>1)      2700     11.3  15.17      -      -     -
code point       84     52.0   0.00      -      -     -
bpe             500     21.9   0.00  100.0   20.5   0.0
bpe            2000     15.2   0.00  100.0   10.4   0.0
bpe            5463     13.0   0.00  100.0    6.6   0.0
byte-gpt2       500     37.3   0.00  100.0   43.9   0.3
byte-gpt2      1282     35.7   0.00  100.0   45.9   0.0
byte-gpt2      1282     35.7   0.00  100.0   45.9   0.0
byte-plain      500     27.7   0.00  100.0   39.0  18.5
byte-plain     2000     16.6   0.00  100.0   28.4   4.1
byte-plain     6020     13.6   0.00  100.0   19.7   1.8
wordpiece       500     21.7   0.00   60.4   22.6   0.0
wordpiece      2000     15.2   0.00   60.4    8.9   0.0
wordpiece      4375     13.6   0.00   60.4    5.8   0.0
unigram         500     22.2   0.00  100.0   20.7   0.0
unigram        2000     15.9   0.00  100.0   11.9   0.0
unigram        4265     14.4   0.00  100.0    8.9   0.0

প্রতিটির জন্য ভান্ডারের লক্ষ্য ছিল ৫০০, ২,০০০ আর ৮,০০০; vocab কলামে পাওয়া আকার। ছোট লেখায় min_frequency = ২ শর্তে সব লক্ষ্য পূরণ হয়নি। UNK% = অচেনা token-এর হার; RT% = decode করে হুবহু একই বাক্য ফেরার হার; cut% = token-সীমার কত ভাগ অক্ষরের ভেতরে, অংশ ৩-এর সরল নিয়মে; bad% = কত ভাগ token একা বৈধ UTF-8 নয়। শব্দ-স্তরে শব্দ আর যতিচিহ্ন আলাদা ধরা, f>১ মানে train-এ অন্তত ২ বার। এক run-এর মাপ, পুরোনো গল্পের ভাষায়।

টেবিলটি কীভাবে পড়বেন

  • মূল বিনিময়: শব্দ-স্তরে ৫,২৩২ ঘর লেগেও প্রায় ১২% token অচেনা। Code point-এ ৮৪ ঘরে ০% অচেনা, কিন্তু প্রতি বাক্যে ৫২ token. BPE ২,০০০ ঘরে ১৫.২ token আর ০% অচেনা: শব্দ-স্তরের কাছাকাছি দৈর্ঘ্য, অক্ষর-স্তরের মতো নমনীয়তা।
  • UNK ০% দেখে খুশি হবেন না: গল্প আর test একই লিপির, তাই সব অক্ষর train-এ ছিল। Latin আর emoji দিলে নিচের দ্বিতীয় আউটপুটে দেখুন কী হয়।
  • byte-gpt2-এর অদ্ভুত সারি: ৮,০০০ চেয়ে পেল ১,২৮২, আর ৩৫.৭ token. কারণ GPT-2-এর regex: এটি আগে লেখা ভাঙে \p{L}+ দিয়ে, মানে Unicode-এর “letter”. বাংলার কার-চিহ্ন “letter” নয়, “mark” (M). তাই “বাজার” regex-এই ব, া, জ, া, র হয়ে যায়, আর এই সীমা পেরিয়ে merge শেখা যায় না। Regex সরালে (byte-plain) ৮,০০০-এর লক্ষ্যে ১৩.৬ token.
  • WordPiece-এর RT ৬০.৪%: পূর্ণ শব্দ ফিরলেও space ফেরে না নিয়ম মেনে; নিচে দেখুন।
বাজারঘরের
  bpe        3 ['▁বাজার', 'ঘ', 'রের']
  byte-gpt2  8 ['ব', 'া', 'জ', 'া', 'র', 'ঘর', 'ে', 'র']
  byte-plain 4 ['বাজ', 'ার', 'ঘ', 'রের']
  wordpiece  3 ['বাজার', '##ঘর', '##ের']
  unigram    3 ['▁বাজার', 'ঘর', 'ের']
…
byte-plain, vocab 500, রাজশাহী: 6 ['র', 'াজ', 'শ', 'া�', '�', 'ী']

round trip with Latin + emoji (vocab 8000), শুধু decode দেখানো:
  bpe        '[UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK] [UNK] শাড়ি'
  byte-gpt2  'Rajshahi 😀 শাড়ি'
  byte-plain 'Rajshahi 😀 শাড়ি'
  wordpiece  '[UNK] [UNK] শাড়ি'
  unigram    '[UNK] [UNK] শাড়ি'

WordPiece round-trip failure:
 in : পিঁপড়ী বললে, ‘পিঁপড়ে আমি বাপের বাড়ি যাব, নৌকো নিয়ে এস
 out: পিঁপড়ী বললে, ‘ পিঁপড়ে আমি বাপের বাড়ি যাব, নৌকো নিয়ে এস
 দাড়ি: 'দাম কম । ভালো ।'

modern shop sentence (domain shift, vocab 8000):
  bpe        22 ['▁বাজার', 'ঘ', 'রের', '▁অ', 'র', '্ড', 'ার', '▁আগ', 'ামী', 'কাল', '▁বি', 'কে', 'লে', '▁রাজ', 'শা', 'হ', 'ী', '▁প', 'ৌ', 'ঁ', 'ছা', 'বে।']
  unigram    19 ['▁বাজার', 'ঘর', 'ের', '▁অ', 'র্', 'ড', 'ার', '▁আগ', 'ামী', 'কাল', '▁বি', 'কে', 'লে', '▁রাজ', 'শা', 'হী', '▁পৌঁছ', 'া', 'বে।']

NFC normalizer on/off (bpe, vocab 8000), input has U+09DC:
 with NFC   : ['▁শ', 'া', 'ড়ি']
 without    : ['▁শ', 'া', '[UNK]', 'ি']
 decode == input? False

আউটপুট ছাঁটা: কিছু শব্দ আর সারি বাদ, আর round-trip অংশে শুধু decode-এর কলাম রাখা।

একই আউটপুট থেকে আরও চারটি কথা। Code-point BPE Latin চেনে না: শুধু বাংলা গল্পে শেখা BPE-তে “Rajshahi” আট [UNK], আর byte-level হুবহু ফেরায়। এটিই byte-level-এর আসল যুক্তি। WordPiece-এর decode অনুমাননির্ভর: “‘”-এর পরে আর “।”-এর আগে space বসে গেছে; decoder-এর পরিষ্কার করার নিয়ম এই চিহ্নগুলো চেনে না। Domain shift: গল্পের ভান্ডারে “অর্ডার” নেই, তাই আধুনিক দোকানের বাক্যটি byte-gpt2 ছাড়া বাকিগুলোয় ১৯ থেকে ২২ টুকরো; ভান্ডার আপনার নিজের ধরনের লেখা থেকে শেখা উচিত। Normalizer না থাকলে: U+09DC সরাসরি এলে [UNK]; থাকলে ঠিক, কিন্তু decode আর ইনপুট byte-এ মেলে না। Round trip মাপতে হয় normalize করা লেখার সঙ্গে।

৭ · Pretrained tokenizer — বাংলা কত দামি

নিজে train না করে সাধারণত model-এর সঙ্গের tokenizer নিতেই হয়, কারণ model সেই id-তেই train হয়েছে। দুটি pretrained tokenizer নিয়েছি, শুধু tokenizer ফাইল, কোনো অ্যাকাউন্ট ছাড়া: distilbert/distilbert-base-multilingual-cased (WordPiece, apache-2.0) আর FacebookAI/xlm-roberta-base (Unigram, MIT); লাইসেন্স model card-এ দেখা। ৬টি বাক্য এই পর্বের জন্য লেখা, দুই ভাষায় একই অর্থে। Token-এ এখানে special token ধরা হয়নি; শব্দ মানে space-এ ভাগ করা।

distilbert/distilbert-base-multilingual-cased | vocab: 119547
model: WordPiece | BertNormalizer
English: 84 tokens / 62 words = 1.35
Bangla : 136 tokens / 46 words = 2.96
28 ['[CLS]', 'বা', '##জার', '##ঘ', '##রের', 'অ', '##র্ড', '##ার', 'আগ', '##াম', '##ী', '##কা', '##ল', 'ব', '##িক', '##েলে', 'র', '##াজ', '##শা', '##হী', 'প', '##ৌ', '##ঁ', '##ছ', '##াব', '##ে', '।', '[SEP]']
  decode: বাজারঘরের অর্ডার আগামীকাল বিকেলে রাজশাহী পৌঁছাবে ।
18 ['[CLS]', 'Your', 'order', 'from', 'Baza', '##rg', '##har', 'will', 'reach', 'Raj', '##sha', '##hi', 'tom', '##or', '##row', 'afternoon', '.', '[SEP]']

FacebookAI/xlm-roberta-base | vocab: 250002
model: Unigram | Precompiled
English: 78 tokens / 62 words = 1.26
Bangla : 93 tokens / 46 words = 2.02
20 ['<s>', '▁বাজার', 'ঘর', 'ের', '▁অ', 'র্', 'ডার', '▁আগামী', 'কাল', '▁বি', 'কে', 'লে', '▁রাজশাহী', '▁প', 'ৌ', 'ঁ', 'ছা', 'বে', '।', '</s>']
  decode: বাজারঘরের অর্ডার আগামীকাল বিকেলে রাজশাহী পৌঁছাবে।
15 ['<s>', '▁Your', '▁order', '▁from', '▁Bazar', 'ghar', '▁will', '▁reach', '▁Raj', 'shah', 'i', '▁tomorrow', '▁afternoon', '.', '</s>']
Checkpointপ্রতি শব্দে, Englishপ্রতি শব্দে, বাংলামোট token, English → বাংলা
distilbert-base-multilingual-cased১.৩৫২.৯৬৮৪ → ১৩৬ (১.৬২ গুণ)
xlm-roberta-base১.২৬২.০২৭৮ → ৯৩ (১.১৯ গুণ)

একই অর্থে বাংলায় শব্দ কম (৪৬ বনাম ৬২), কারণ বিভক্তি শব্দের গায়েই বসে। তবু token বেশি, কারণ বাংলা শব্দ বেশি টুকরো হয়। একটি অর্থের জন্য বেশি token মানে context window-এ কম লেখা ধরে, আর token গুনে দাম নেওয়া সেবায় বেশি খরচ হতে পারে। ৬টি বাক্যের মাপ, নিজের অনুবাদে; সাধারণ অনুপাত হিসেবে নেবেন না। আর দুটি tokenizer-এর এই ফলে কোনটি “ভালো”, তা ঠিক হয় না: token কম হওয়া আর কাজের মান এক কথা নয়।

AI-এর ভিত্তি কোর্সের পর্ব ৭০-এ cl100k_base-এ বাংলা বাক্যটি ৬১ token, o200k_base-এ ১৮. tiktoken 0.14.0-এর source খুলে দেখলাম, cl100k-এর regex-এ শুধু \p{L}, আর o200k-এর regex-এ \p{M} যোগ করা। অংশ ৬-এর byte-gpt2 সারি দেখায়, এই পার্থক্য বড়। তবে o200k-এর ভান্ডারও বড় আর training ডেটাও আলাদা, তাই কোন কারণের ভাগ কত, তা আমরা আলাদা করে মাপিনি।

৮ · Special token আর round-trip পরীক্ষা

Special token লেখার অংশ নয়, model-এর জন্য সংকেত: [CLS] শুরু, [SEP] সীমা, [PAD] ফাঁকা ঘর (পর্ব ২৭-এর padding), [UNK] অচেনা। এদের id নির্দিষ্ট, আর এগুলো কখনো সাধারণ merge-এ ভাঙে না। সমস্যা হলো, ব্যবহারকারী যদি সরাসরি সেই লেখাটিই টাইপ করেন? সত্যিকারের run:

~/bazarghar-dl$ uv run python special.py
['[CLS]', 'দ', '##াম', 'ক', '##ত', '[SEP]', 'ফ', '##ের', '##ত', 'চ', '##াই', '[SEP]']
ids of [SEP]: [102, 102] | token_to_id: 102
skip_special_tokens=True : দাম কত ফেরত চাই
skip_special_tokens=False: [CLS] দাম কত [SEP] ফেরত চাই [SEP]
['[CLS]', 'দ', '##াম', 'ক', '##ত', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]'] [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
['[CLS]', 'প', '##্যা', '##কে', '##ট', 'ছ', '##ে', '##ঁ', '##ড়া', 'ছিল', '[SEP]'] [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

রিভিউতে টাইপ করা “[SEP]” আসল সংকেতের id 102 পেয়েছে, আর decode-এ চুপচাপ উধাও। ব্যবহারকারীর লেখা যদি বিশেষ সংকেত হয়ে যায়, তা যাচাই ছাড়া রাখা নিরাপদ নয়; chat ধাঁচের model-এ এটি prompt injection-এর একটি পথ হতে পারে। প্রতিরক্ষা: ব্যবহারকারীর লেখা থেকে এমন চিহ্ন মুছে বা ভেঙে দেওয়া, আর ইনপুটে special token-এর সংখ্যা যাচাই। নিচের সারি দুটি padding-এর সঙ্গে attention mask দেখায়।

Round-trip পরীক্ষার নিয়ম

Round trip মানে encode করে decode করলে কী ফেরে। পরীক্ষাটি এক লাইন: decode(encode(norm(s))) == norm(s), সব held-out বাক্যে। ব্যর্থ বাক্যগুলো ছেপে পড়ুন। বাংলা, ইংরেজি, অঙ্ক, emoji আর URL মেশানো একটি ছোট তালিকা নিজের পরীক্ষায় রাখুন। আর tokenizer model-এর অংশ, তাই তার ফাইল আর সংস্করণ checkpoint-এর পাশেই রাখুন; ভিন্ন tokenizer-এর id দিলে model চুপচাপ ভুল ফল দেয়, কোনো error ছাড়াই।

আরও গভীরে: Kudo-র ২০১৮ সালের প্রবন্ধ Unigram পদ্ধতি আর subword regularization দেখায়: training-এ একটি শব্দের ভিন্ন ভিন্ন ভাঙন নিলে translation-এর robustness বাড়তে পারে। Unicode 15.1 থেকে UAX #29-এ একটি নতুন নিয়ম (GB9c) বাংলাসহ কিছু লিপির যুক্তাক্ষরকে এক grapheme ধরে; তাই পুরোনো library-তে “ক্ষ” দুই ভাগ হতে পারে। Petrov ও সহকর্মীদের ২০২৩ সালের প্রবন্ধ tokenizer-এর ভাষাভেদের অসমতা মাপে। BPE training-এর দক্ষ রূপে সব জোড়া বারবার না গুনে শুধু বদলানো জোড়া হালনাগাদ করা হয়; Hugging Face tokenizers Rust-এ লেখা।

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

  • BPE আর WordPiece-এর encode-এ পার্থক্য? BPE শেখার ক্রমে merge চালায়; WordPiece ভান্ডারে সবচেয়ে লম্বা মিল নেয়।
  • Byte-level কেন OOV-মুক্ত? ২৫৬টি byte ভান্ডারে থাকে, আর যেকোনো লেখা byte-এ ভাঙা যায়।
  • “রাজ” UTF-8-এ কত byte? ৯, কারণ ৩ code point × ৩ byte.
  • Tokenizer বদলালে কী হয়? Token id-এর অর্থ বদলায়, তাই model-কে আবার train করতে হয়, বা অন্তত মানাতে হয়।
  • NFC বনাম NFKC কোথায়? সাধারণত NFC; NFKC খোঁজার জন্য, যেখানে চিহ্নের অর্থ হারালে ক্ষতি কম।

৯ · AI-এর যুগে — tokenizer কোডে AI-এর সাহায্য

AI assistant কয়েক সেকেন্ডে tokenizer training-এর কোড লিখে দেয়। সাধারণ ভুল: বাংলায় uncased checkpoint বা strip_accents রেখে দেওয়া; ইংরেজির regex বাংলায় চালানো; পুরোনো tokenizers আর transformers API মিশিয়ে ফেলা; train-এর লেখায় UNK মেপে “OOV নেই” বলা; বাংলার token অনুপাত না মেপে ইংরেজির “প্রতি token চার অক্ষর” ধরে নেওয়া; আর model-এর সঙ্গে ভিন্ন tokenizer জুড়ে দেওয়া।

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

আমি Hugging Face tokenizers-এর একটি নির্দিষ্ট সংস্করণে বাংলা লেখায় BPE tokenizer train করছি (নিজের সংস্করণ এখানে লিখুন)। ১. কোন normalizer, pre-tokenizer, decoder নিচ্ছ, প্রতিটির কারণ বলো; বাংলার কার-চিহ্ন (Unicode mark) এতে কাটা পড়ে কি না ব্যাখ্যা করো। ২. পুরো নথি ধরে train আর held-out ভাগ করো; UNK, প্রতি বাক্যে token আর round-trip শুধু held-out-এ মাপো। ৩. “বাজারঘরের”, “প্যাকেট”, “শাড়ি” আর একটি Latin ও emoji মেশানো বাক্যের token আর decode ছাপানোর কোড দাও। ৪. Round-trip ব্যর্থ বাক্যগুলোর প্রথম ১০টি ছাপাও। ৫. শব্দ আর code point-এর baseline একই টেবিলে দাও। ৬. যেসব API তুমি এই সংস্করণের docs-এ যাচাই করোনি, তা স্পষ্ট করে বলো।

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

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

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

প্র ০১১৯৯৪ সালের একটি compression কৌশল কেন আজকের ভাষা model-এর সাধারণ tokenizer হলো?

Philip Gage-এর BPE-এর লক্ষ্য ছিল ফাইল ছোট করা: ঘন ঘন আসা byte-জোড়াকে একটি অব্যবহৃত byte দিয়ে বদলে দেওয়া। এখানে ভাষার কোনো ধারণা নেই। প্রায় দুই দশক পরে অনুবাদের গবেষকেরা অন্য একটি সমস্যায় পড়েন। Neural অনুবাদে ভান্ডার ছিল শব্দের, আর সব বিরল শব্দ একটি অচেনা token-এ চলে যেত। জার্মান যৌগিক শব্দ, নাম আর বিভক্তি-ভরা ভাষায় এই ক্ষতি বড়। Sennrich আর সহকর্মীরা ২০১৬ সালের প্রবন্ধে দেখান, BPE দিয়ে শব্দকে টুকরো করলে অচেনা শব্দও অনুবাদ করা যায়, আর ফল উন্নত হতে পারে।

কেন টিকে গেল? প্রথমত, এটি সরল আর দ্রুত: একটি merge table ছাড়া আর কিছু নেই, আর ভাষাবিজ্ঞানের কোনো নিয়ম লাগে না। তাই একই কোড ইংরেজি, বাংলা বা চীনা ভাষায় চলে। দ্বিতীয়ত, ভান্ডারের আকার নিজের হাতে ঠিক করা যায়; embedding টেবিলের parameter তাই নিয়ন্ত্রণে থাকে। তৃতীয়ত, GPT-2-তে byte-স্তরে নামার পরে অচেনা লেখার সমস্যা মুছে যায়।

কিন্তু এর দাম আছে, আর এই পর্বে তা দেখেছেন। BPE শুধু গোনে। তাই “ের” বিভক্তি চিনতে পারা একটি কাকতাল, পরিকল্পনা নয়; “ার” কোনো অর্থের একক না হলেও প্রথম merge হয়েছে। ভাষা যত কম এসেছে, তার ভাগে merge তত কম। একটি merge-এর লোভী সিদ্ধান্ত পরে আর ফেরানো যায় না। Unigram-এর মতো সম্ভাবনা-ভিত্তিক পদ্ধতি একে কিছুটা সামলায়, তবু কোনো পদ্ধতি সরাসরি অর্থ দেখে না।

আরেকটি শিক্ষা হলো, এটি “যথেষ্ট ভালো” হওয়ার গল্প। Tokenizer-এর ভুল পরের ধাপে একটি বড় model কিছুটা শুধরে নিতে পারে, তাই সবচেয়ে নিখুঁত ভাঙন খোঁজার চেয়ে সরল, দ্রুত আর সব লেখায় চলে এমন পদ্ধতি বাস্তবে এগিয়ে যায়।

মূল উপলব্ধি: BPE জনপ্রিয় তার ভাষাজ্ঞানের জন্য নয়, ভাষাজ্ঞান না লাগার জন্য; সেই একই গুণ তাকে কম ডেটার ভাষায় দুর্বল করে।

প্র ০২Tokenizer কি ভাষাগুলোর মধ্যে অসমতা তৈরি করে, আর regex-এর একটি অক্ষর কতটা বড় ভূমিকা নিতে পারে?

Petrov ও সহকর্মীদের ২০২৩ সালের প্রবন্ধ আর Ahia ও সহকর্মীদের একই বছরের প্রবন্ধ দেখান, একই অর্থের লেখা ভাষাভেদে অনেক গুণ বেশি token নিতে পারে, আর token গুনে দাম হলে এর অর্থমূল্য আছে। এই পর্বের মাপ ছোট হলেও একই দিকে: ৬টি বাক্যে একটি tokenizer-এ বাংলার মোট token ইংরেজির ১.৬২ গুণ, আরেকটিতে ১.১৯ গুণ।

কারণ কয়েক স্তরের। এক, UTF-8: ইংরেজি অক্ষর ১ byte, বাংলা ৩ byte, তাই byte-level শুরুতেই ৩ গুণ পিছিয়ে। দুই, training ডেটা: যে ভাষা কম, তার ভাগে merge কম। তিন, যা সহজে চোখে পড়ে না: pre-tokenizer-এর regex. GPT-2-এর নকশায় \p{L}+ অংশটি ধরে নেয়, “letter” মিলেই শব্দ হয়। ইংরেজির জন্য এটি ঠিক। কিন্তু বাংলার কার-চিহ্ন Unicode-এ “mark”, ফলে প্রতিটি “া” বা “ি” শব্দ কেটে দেয়। আমাদের run-এ এই একটি বিষয়ে ৮,০০০-এর লক্ষ্যে byte-level token ১৩.৬ থেকে ৩৫.৭ হয়েছে, ভান্ডার আটকে গেছে ১,২৮২-এ। কোনো error আসেনি।

OpenAI-এর tiktoken-এর source-এ o200k-এর regex-এ \p{M} আছে, আর সেখানে এক মন্তব্যে লেখক বলেছেন, ভাষাগুলোর মধ্যে token বণ্টন আরও দক্ষ করা যেত। মানে নির্মাতারাও বিষয়টি জানেন, আর এটি স্থির কোনো নিয়তি নয়। তবে কোন পরিবর্তনের ভাগে কতটুকু উন্নতি, তা আমরা মাপিনি।

ন্যায্যতার প্রশ্নটি শুধু দামে থামে না। বেশি টুকরো মানে একই context-এ কম লেখা, আর হয়তো মানেও প্রভাব, যদিও মানের প্রভাব কতটা, তা এখনো গবেষণার বিষয়। আবার সব ভাষার জন্য আলাদা tokenizer বানালে একটি model-এ ভাষা মেশানো কঠিন হয়। Banglish আর ইংরেজি মেশানো বার্তাই বা কোন ভাষার? তাই সমাধান একরকম নয়। বাংলার কাজে ব্যবহারিক পথ হলো, নিজের লেখায় কয়েকটি tokenizer গোনা, আর কাজের মানের সঙ্গে মিলিয়ে বিচার করা।

মূল উপলব্ধি: ইংরেজি ধরে নেওয়া একটি ছোট নকশাগত সিদ্ধান্ত নীরবে অন্য ভাষার দাম বাড়াতে পারে; তাই tokenizer-কে নিরপেক্ষ যন্ত্র ধরে নেবেন না।

প্র ০৩বাংলার জন্য নিজের model বানালে কোন tokenizer বাছবেন: byte-level, code-point BPE, নাকি grapheme-সীমা মানা কিছু?

উত্তর নির্ভর করে তিনটি প্রশ্নে: লেখায় কী কী মিশে থাকে, ডেটা কত, আর পরের কাজ কী। Byte-level-এর বড় শক্তি হলো নিশ্চয়তা। URL, ইংরেজি, emoji, ভুল encoding, সব encode হয়, আর হুবহু ফেরে। বাজারঘরের রিভিউতে “delivery ta late”, ফোন নম্বরের ধরন আর emoji থাকবেই। আমাদের run-এ শুধু বাংলায় শেখা code-point BPE-তে “Rajshahi” হয়েছে আটটি [UNK]; তার সমাধান train-এ Latin রাখা, কিন্তু অজানা লিপি তবু থেকেই যায়।

Code-point BPE-র শক্তি হলো ছোট ভান্ডারে ভালো ব্যবহার। আমাদের টেবিলে ২,০০০ ঘরে code-point BPE ১৫.২ token, byte-plain ১৬.৬; ৫০০ ঘরে ২১.৯ বনাম ২৭.৭. ডেটা আর ভান্ডার যত ছোট, এই ব্যবধান তত বড়, কারণ byte-level-এ প্রথম কিছু merge শুধু অক্ষর গড়তে খরচ হয়।

Grapheme-সীমা মানা tokenizer কেমন? ধারণাটি লোভনীয়: কার-চিহ্ন আর যুক্তাক্ষর কখনো কাটা পড়বে না, তাই প্রতিটি token পড়া যায়। বানান যাচাই বা অক্ষর-স্তরের কাজে সুবিধা হতে পারে। আমাদের টেবিলে code-point BPE-তেও ২,০০০ ঘরে ১০.৪% সীমা অক্ষরের ভেতরে পড়েছে। কিন্তু দামও আছে: grapheme ধরার নিয়ম লাগে (UAX #29), Unicode-এর সংস্করণে তা বদলায়, আর “কার” আলাদা টুকরো হওয়া সবসময় খারাপ নয়। বিভক্তি অনেক সময় কার দিয়েই শুরু, যেমন “ের”। Grapheme মানলে “বাজারের” ভাঙতে হবে “বাজা” + “রে” + “র”-এর মতো করে, যেখানে আসল বিভক্তি “ের” আলাদা হতে পারে না। তাই এটি একতরফা জয় নয়, আর আমাদের সামনে এমন কোনো মাপ নেই যা একে নিশ্চিত ভালো বলে।

আমার পরামর্শের ক্রম হবে: মেশানো লেখা হলে byte-level, কিন্তু GPT-2-এর regex ছাড়া বা mark-সহ regex-এ; পরিষ্কার ও প্রায় শুধু-বাংলা লেখা হলে code-point BPE বা Unigram; আর pretrained model নিলে তার সঙ্গের tokenizer, কারণ সেখানে বাছাইয়ের সুযোগই নেই। শেষে ছোট একটি model train করে পরের কাজের মাপে তুলনা করুন, শুধু token সংখ্যায় নয়।

মূল উপলব্ধি: সেরা tokenizer বলে কিছু নেই; আছে আপনার ডেটা, ভান্ডারের সীমা আর কাজের সঙ্গে মাপা একটি বিনিময়।

প্র ০৪Normalization “পরিষ্কার করা”, নাকি তথ্য মুছে ফেলা? কতটা করবেন, কে ঠিক করবে?

Normalization-এর লোভ হলো একটিই: এক জিনিসের একাধিক রূপ কমলে ভান্ডার ছোট হয়, আর গোনা শক্তিশালী হয়। প্রশ্ন হলো, কোন দুই রূপ সত্যিই “এক জিনিস”? NFC-র উত্তর সবচেয়ে সংযত: Unicode নিজে যাদের canonical সমতুল্য বলে, শুধু তাদের। তাই এটি প্রায় সব বাংলা pipeline-এর নিরাপদ শুরু। তবু NFC একা যথেষ্ট নয়। ZWJ-এর সঙ্গে আর ছাড়া “র্য”-এর দুই লেখা, বা “ৎ”-এর দুই রূপ, NFC-র কাছে আলাদা। এগুলোর জন্য নিজের নিয়ম লিখতে হয়, যা AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এর আলোচনার পরের ধাপ।

NFKC আরেক স্তর ওপরে: এখানে “দেখতে কাছাকাছি” বা “একই ধরনের” চিহ্নও এক হয়। অনুসন্ধানে এটি কাজের: fullwidth “ABC” খুঁজলে “ABC” মেলে। কিন্তু “৫ m²”-এর “m2” হয়ে যাওয়া দোকানের পণ্যের মাপে সত্যিকারের ভুল। XLM-RoBERTa-র tokenizer-এ Precompiled নামের একটি normalizer দেখেছেন; SentencePiece এমন normalization map ফাইলের ভেতরেই রাখে। তাই decode-এর ফল আসল লেখা না-ও হতে পারে।

সবচেয়ে কড়া উদাহরণ uncased BERT-এর accent মোছা। ইংরেজি-কেন্দ্রিক অনেক কাজে এটি কাজের ছিল, আর multilingual checkpoint-এও ধারাটি রয়ে গেছে। বাংলায় ফল “খুশি” থেকে “খশি”, “ব্যাগ” থেকে “বযাগ”. শব্দের অর্থ আর পাঠযোগ্যতা দুটোই গেল, আর model train হয়েছে এই ভাঙা ভাষাতেই। Model হয়তো তবু কিছু কাজ করবে, কিন্তু লেখা তৈরি বা NER-এর span বের করায় সমস্যা হবে।

তাই সিদ্ধান্ত নিন কাজ দেখে। Classification-এ কিছুটা তথ্য হারালে ক্ষতি কম হতে পারে। লেখা তৈরি, অনুবাদ আর span ফেরত দেওয়ার কাজে উল্টো: আসল লেখায় ফিরতে হয়। পর্ব ৪৩ (Sequence labeling) আর পর্ব ৫৪-এ এই সীমার প্রশ্ন আবার আসবে। Offset রাখলে normalize করা লেখা থেকেও আসল জায়গা ফেরানো যায়, তাই tokenizer যে offset দেয়, তা ফেলে দেবেন না।

মূল উপলব্ধি: প্রতিটি normalization একটি দাবি, “এই দুটি একই”; সেই দাবি আপনার ভাষা আর কাজের জন্য সত্য কি না, তা মেপে আর পড়ে যাচাই করুন।

প্র ০৫Tokenizer কি একদিন উঠে যাবে? শুধু byte বা character-এ চলা model-এর যুক্তি কী?

পরিষ্কার একটি প্রস্তাব আছে: tokenizer-ই বাদ। ২০২২ সালের দুটি প্রবন্ধ এই দিকে কাজ করেছে: Xue ও সহকর্মীদের ByT5, যা সরাসরি byte-এ চলে, আর Clark ও সহকর্মীদের CANINE, যা character থেকে hash করা embedding নিয়ে শুরু করে আর ভেতরে ক্রম ছোট করে। যুক্তি শক্ত: কোনো ভাষা পক্ষপাতের merge table নেই, বানান ভুলে কম ভেঙে পড়ে, আর নতুন ভাষায় আলাদা ভান্ডার লাগে না। পর্ব ৩৬-এর MiniGPT-ও code point-এ চলেছিল।

তবে খরচের হিসাব সহজ। আমাদের ডেটায় code point-এ প্রতি বাক্যে ৫২ token, BPE-তে ১৫.২, মানে ৩.৪ গুণের বেশি। Byte-এ বাংলায় আরও প্রায় ২.৬ গুণ। Self-attention-এর হিসাব ক্রমের দৈর্ঘ্যের বর্গে বাড়ে (পর্ব ৩৩), আর KV cache-এর memory-ও দৈর্ঘ্যের সঙ্গে বাড়ে (পর্ব ৩৭)। তিন গুণ লম্বা ক্রম মানে attention-এর হিসাব প্রায় নয় গুণ। Subword tokenizer এক অর্থে একটি শেখা compression, যা model-এর কাজ কমায়। ByT5-এর প্রবন্ধেও ধীর গতির কথাটি একটি সীমাবদ্ধতা হিসেবে এসেছে।

আরেকটি প্রশ্ন, token-এর সীমা model-এর আচরণে কতটা দেখা যায়। অনেকে দেখেছেন, subword tokenizer-এর ভাষা model অক্ষর গোনা বা উল্টো বানানে প্রায়ই ভুল করে; একটি সম্ভাব্য ব্যাখ্যা হলো, model এখানে অক্ষরকে সরাসরি একক হিসেবে দেখে না। তবে এটিই একমাত্র কারণ, এমন দাবি প্রমাণিত নয়, আর এ বিষয়ে নতুন মডেল আচরণ বদলাতে পারে। এই পর্বের “প্যাকেট” যখন ছয় টুকরো, তখন “প্যাকেটে কয়টি অক্ষর” প্রশ্নটি কেন কঠিন, তা কল্পনা করা যায়।

ভবিষ্যৎ নিয়ে নিশ্চিত কিছু বলা ঠিক হবে না। Hardware সস্তা হলে দীর্ঘ ক্রমের খরচ কমতে পারে; আবার আরও লম্বা context-এর চাহিদা সেই লাভ খেয়েও ফেলতে পারে। আপাতত বাস্তব model-এর বড় অংশ subword tokenizer-এই চলে। আপনার জন্য কাজের কথা হলো, tokenizer-কে একটি নকশা-সিদ্ধান্ত হিসেবে দেখা, যা ভবিষ্যতে বদলাতে পারে, তাই কোড এমনভাবে লেখা যেন বদলানো যায়।

মূল উপলব্ধি: Tokenizer একটি compression-এর বিনিময়; ক্রমের খরচ যত কমবে, তাকে বাদ দেওয়ার যুক্তি তত শক্ত হবে, কিন্তু সেই দিন কবে, তা কেউ নিশ্চিত জানে না।

অনুশীলন

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

  1. পূর্ণ numerical (BPE হাতে): শব্দ: ঘরে ×৫, ঘরের ×২, পরে ×৩, কাল ×৪, কালে ×২. শুরু code point-এ। সমান হলে যে জোড়া তালিকার আগের শব্দে আগে আসে, সেটি নিন। (ক) তিনটি merge করুন, প্রতি ধাপে সেরা জোড়ার গোনা আর মোট token লিখুন। (খ) “কালের” কত token হবে? (গ) “ঘরে” UTF-8-এ কত byte?

    (ক) শুরুতে মোট token ৫×৩ + ২×৪ + ৩×৩ + ৪×৩ + ২×৪ = ৫২. Merge ১: র + ে = ৫ + ২ + ৩ = ১০; মোট ৪২. Merge ২: ঘ + রে = ৫ + ২ = ৭; মোট ৩৫. Merge ৩: ক + া আর া + ল দুটিই ৪ + ২ = ৬. ক + া আগে আসে, তাই কা; মোট ২৯. ফল: ঘরে · ঘরে/র · প/রে · কা/ল · কা/ল/ে. (খ) ক া ল ে র থেকে ক + া জুড়ে “কা ল ে র” = ৪ token (“র + ে” জোড়াটি এই শব্দে নেই, কারণ এখানে ক্রম “ে র”)। (গ) ঘ, র, ে তিনটি code point, প্রতিটি ৩ byte: ৯ byte.

  2. সমালোচনা ও নকশা: একজন লিখেছেন: “বাংলা রিভিউয়ের জন্য bert-base-multilingual-uncased-এর tokenizer নিলাম, আগে NFKC করেছি। Train-এর লেখায় মেপে UNK ০%, তাই tokenizer নিখুঁত। প্রতি token-এ ৪ অক্ষর ধরে খরচের হিসাব করেছি।” কী কী সমস্যা? কোন পরীক্ষায় ধরবেন?

    চারটি। এক, uncased normalizer বাংলার হসন্ত, উ-কার, নুকতা মুছে দেয়: “খুশি” হয় “খশি”. দুই, NFKC পণ্যের মাপ বা বিশেষ চিহ্ন বদলাতে পারে: “৫ m²” থেকে “৫ m2”. তিন, UNK মাপা হয়েছে train-এ, তাই প্রায় অর্থহীন; ইংরেজি-Banglish মেশানো held-out রিভিউতে মাপা দরকার। চার, “৪ অক্ষর” একটি ইংরেজি-ভিত্তিক মোটা হিসাব, যা বাংলায় খাটে না। পরীক্ষা: কয়েকটি বাক্যের decode চোখে দেখা, normalizer-এর config পড়া, held-out-এ round trip, আর আসল লেখায় token গোনা। Cased checkpoint বা বাংলার জন্য train করা tokenizer বিবেচনা করা উচিত, আর লাইসেন্স দেখা।

  3. আউটপুট অনুমান করুন: কী ছাপা হবে? import unicodedata as u; s = "রাজ"; print(len(s), len(s.encode("utf-8"))); তারপর print(len(u.normalize("NFC", "য়"))); তারপর print(s.encode("utf-8")[:2].decode("utf-8", "replace")); আর এই পর্বের apply_merge নিয়মে ("ক", "া", "ক", "া", "ল")-এ ক + া merge করলে কী ফেরে?

    3 9: তিন code point, ৯ byte. 2: NFC “য়”-কে য আর নুকতায় ভাঙে। �: E0 A6 অসম্পূর্ণ, তাই বদলি চিহ্ন। শেষটি ('কা', 'কা', 'ল'): প্রতিটি পাশাপাশি জোড়া বাঁ থেকে জোড়া লাগে।

  4. কোড ও পর্যবেক্ষণ (WordPiece): ভান্ডার: ঘর, দাম, ##ের, ##ে, ##র, ##ি. (ক) হাতে “ঘরের”, “ঘরেই” আর “দামি”-র token লিখুন। (খ) ৮–১০ লাইনে একটি Python ফাংশন লিখুন, যা longest match করে আর কোনো অংশ না মিললে পুরো শব্দকে [UNK] দেয়। (গ) “ঘরেই”-এর ফল থেকে একটি সতর্কতা লিখুন।

    (ক) ঘর ##ের; “ঘরেই”: ঘর, তারপর ##ে, তারপর “ই” — ##ই নেই, তাই পুরো শব্দ [UNK]; দাম ##ি. (খ) def wp(w, V): out, i = [], 0; while i < len(w): j = len(w); ভেতরে while j > i: p = w[i:j] if i == 0 else "##" + w[i:j]; if p in V: break; j -= 1; লুপ শেষে if j == i: return ["[UNK]"]; out.append(p); i = j; শেষে return out. (গ) একটি অচেনা বিভক্তি পুরো শব্দের তথ্য মুছে দিতে পারে, তাই ভান্ডারে প্রতিটি একক code point-এর ## রূপ রাখা ভালো, যাতে অন্তত অক্ষর ধরে ভাঙা যায়। BPE-তে সাধারণত এমন সমস্যা কম, কারণ প্রতিটি অক্ষর আলাদা থাকে।

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

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

ক্রমএকটি বাংলা বাক্য encode করে আবার লেখায় ফেরানোর ধাপগুলো সঠিক ক্রমে সাজান।

  1. Normalizer লেখাকে একটি মান্য রূপে আনে (যেমন NFC)
  2. Pre-tokenizer শব্দের সীমা চিহ্নিত করে
  3. Model লেখাকে subword টুকরোয় ভাঙে
  4. Post-processor special token বসায়
  5. Token id model-এ যায়, আর padding-এর সঙ্গে attention mask তৈরি হয়
  6. Decoder id থেকে আবার লেখা বানায়

শব্দের তালিকা: ঘর ×৩, ঘরে ×২, পর ×১. শুরু code point থেকে। BPE-র প্রথম merge কোন জোড়া, আর তার গোনা কত?

  • র + ে, ২ বার
  • ঘ + র, ৫ বার
  • ঘ + র, ৩ বার
  • প + র, ১ বার

একটি byte-level tokenizer-এর আউটপুট token ধরে ধরে পর্দায় দেখালে বাংলায় মাঝে মাঝে � আসে, কিন্তু পুরো id ক্রম একসঙ্গে decode করলে লেখা হুবহু ঠিক। সবচেয়ে ঠিক ব্যাখ্যা কী?

  • Tokenizer লেখার কিছু byte হারিয়ে ফেলেছে
  • পর্দার বাংলা font-এ কিছু অক্ষর নেই
  • Model ভুল token তৈরি করেছে
  • Token-এর সীমা code point-এর মাঝে পড়েছে, টুকরো একা বৈধ UTF-8 নয়

একটি WordPiece ভান্ডার: ঘর, ##ের, ##ে. “ঘরেই” শব্দের token কী হবে, যদি ভান্ডারে আর কিছু না থাকে আর অচেনা অংশে পুরো শব্দ একটি [UNK] হয়?

  • ঘর, ##ে, [UNK]
  • ঘর, ##ের
  • [UNK]
  • ঘর, ##ে, ##ই

“৫ m²” আর “① শাড়ি” লেখা দুটিতে NFC আর NFKC চালালে সবচেয়ে ঠিক কোনটি?

  • NFC দুটিই অপরিবর্তিত রাখে; NFKC “৫ m2” আর “1 শাড়ি” করে
  • NFKC অপরিবর্তিত রাখে; NFC বদলায়
  • দুটিই একই ফল দেয়, “৫ m2” আর “1 শাড়ি”
  • NFKC “৫”-কে “5” করে

একটি BERT checkpoint-এর normalizer-এ lowercase true আর strip_accents None. “খুশি” encode করে decode করলে “খশি” এল। কারণ কী?

  • Lowercase সরাসরি বাংলার উ-কারকে ছোট হাতের অক্ষর ভেবে মুছেছে
  • strip_accents None এখানে lowercase অনুসরণ করে, আর উ-কার Mn শ্রেণির চিহ্ন
  • NFC উ-কার মুছে দেয়
  • WordPiece-এর longest match উ-কার বাদ দেয়

একটি byte-level BPE-তে GPT-2-এর regex (\p{L}+ সহ) ব্যবহার করায় বাংলায় প্রতি বাক্যে token অনেক বেশি, আর ৮,০০০ ঘর চেয়ে ভান্ডার ১,২৮২-তে আটকে আছে। সবচেয়ে সম্ভাব্য কারণ?

  • UTF-8-এ বাংলা ৩ byte, তাই এর চেয়ে ভালো সম্ভব নয়
  • Training ডেটা খুব ছোট, এটিই একমাত্র কারণ
  • কার-চিহ্ন letter নয়, তাই regex শব্দ কেটে দেয় আর সীমা পেরিয়ে merge হয় না
  • Special token বেশি হওয়ায় ভান্ডার ভরে গেছে

শব্দকোষ

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

মনে রাখুন

  1. বিনিময়: শব্দে ক্রম ছোট কিন্তু অচেনা বেশি, code point-এ উল্টো। Subword মাঝখানে: ভান্ডার, token সংখ্যা আর OOV একসঙ্গে দেখে বাছুন।
  2. BPE: জোড়া গোনা, সেরাটি জোড়া দেওয়া, আবার গোনা। Encode-এ merge শেখার ক্রমে চলে; decode মানে টুকরো জোড়া।
  3. Byte-level: সব লেখা encode হয়, কিন্তু টুকরো একা বৈধ না-ও হতে পারে। বাংলায় GPT-2-এর regex সাবধানে; mark কেটে দেয়।
  4. WordPiece আর Unigram: WordPiece-এ longest match আর একটি ফাঁদ, পুরো শব্দ [UNK]. Unigram-এ সবচেয়ে সম্ভাব্য ভাঙন।
  5. যাচাই: NFC দিয়ে শুরু, NFKC আর accent মোছা বুঝে। Held-out-এ round trip, special token পরীক্ষা, আর নিজের লেখায় token গোনা।

পরের পর্বে: পর্ব ৪১ (Word embedding — word2vec, GloVe আর fastText)-এ দেখবেন, এই token-গুলো থেকে অর্থ-ধরা vector কীভাবে শেখা হয়, আর subword টুকরো বাংলার বিভক্তিতে কেন কাজে লাগে।

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

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