এই পর্বে যা শিখবেন
- Sequence labeling কী: NER, POS tagging, chunking
- BIO আর BIOES-এ রূপান্তর, span ফিরিয়ে আনা, আর invalid sequence-এর দুই রকম পড়া
- Linear-chain CRF-এর score, partition function আর loss; Viterbi আর forward হাতে ও NumPy-তে
- Span F1 হাতে, আর token accuracy কেন বিভ্রান্ত করে
- PyTorch-এ BiLSTM + softmax বনাম BiLSTM + CRF, কয়েকটি seed-এ, seqeval-এর ফাঁদসহ
এতদিন একটি টেক্সটের জন্য একটি উত্তর চেয়েছি, যেমন পর্ব ৪২-এর sentiment (Deep learning দিয়ে text classification). এবার উত্তর হবে প্রতিটি শব্দের জন্য একটি। ভেতরের যন্ত্র আপনার চেনা: পর্ব ২৭-এর embedding, পর্ব ২৯-এর LSTM আর PackedSequence, পর্ব ১১-এর padding, পর্ব ১৩-এর ignore_index. নতুন প্রশ্নটি হলো, পাশাপাশি label কি একে অন্যের কথা জানবে? পাতার চারটি ব্লক ব্রাউজারের Python আর NumPy-তে চলে; প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে। PyTorch-এর সব আউটপুট ডেস্কটপের সত্যিকারের run থেকে, CPU-তে।
১ · প্রতিটি শব্দে একটি label
বাজারঘরের অফিসে একজন কর্মী ফোনের অর্ডার খাতায় পড়ছেন। হাতে তিন রঙের মার্কার: জেলায় সবুজ, পণ্যে হলুদ, দামে গোলাপি। কোনো শব্দের একার অর্থ দিয়ে সব সময় রং ঠিক হয় না। “টাঙ্গাইল” একা একটি জেলা, কিন্তু “টাঙ্গাইল শাড়ি”-তে পণ্যের অংশ। আবার দাগ শুরু আর শেষ কোথায়, সেটিও সমান জরুরি। Sequence labeling এই মার্কারের কাজ।
গঠন একটিই: T-টি token ঢোকে, T-টি label বের হয়। Shape: input (N, T), embedding (N, T, D), প্রতি token-এর score (N, T, K), যেখানে K label-এর সংখ্যা। পর্ব ৩০-এর seq2seq-এর মতো দৈর্ঘ্য বদলায় না, আর ডান পাশের শব্দ দেখা বৈধ, তাই bidirectional মডেল এখানে স্বাভাবিক (forecasting-এর উল্টো)। লেখা ভাঙার সমস্যা আর বাংলা শব্দের গঠন আছে AI-এর ভিত্তি কোর্সের পর্ব ৬৭ আর পর্ব ৪০-এ (Tokenization).
| কাজ | কী চিহ্নিত হয় | কী মাপা হয় |
|---|---|---|
| NER (Named Entity Recognition) | নামওয়ালা জিনিসের টুকরো: জেলা, পণ্য, দাম | Span F1 |
| POS tagging | প্রতি শব্দের পদ: বিশেষ্য, ক্রিয়া… Universal Dependencies-এর সাধারণ তালিকায় ১৭টি tag | Token accuracy |
| Chunking | শব্দগুচ্ছের সীমা: noun phrase, verb phrase | Span F1 |
লক্ষ্য করুন শেষ কলাম: POS-এ প্রতি token-এর নিজের উত্তর আছে, তাই accuracy অর্থবহ। NER-এ মূল্যবান জিনিসটি পুরো টুকরো। মানদণ্ডের এই পার্থক্য অংশ ৭-এ ফিরে আসবে।
২ · BIO আর BIOES — টুকরোকে token-এর label বানানো
“লাল সুতি শাড়ি” তিনটি token, কিন্তু একটি পণ্য। শুধু “PROD” লিখলে পাশাপাশি দুটি পণ্য একটিতে মিশে যায়। তাই BIO নিয়ম: B-X মানে X ধরনের টুকরো এখানে শুরু, I-X মানে চলছে, O মানে কোনো টুকরোর বাইরে। E-টি ধরন হলে K = 2E + 1; জেলা, পণ্য, দাম মিলে ৭টি tag.
| ঢাকায় | |
| লাল | |
| সুতি | |
| শাড়ি | |
| ১২০০ | |
| টাকায় | |
| পাঠান | |
| খোলা span |
- শুরুর অবস্থা. Annotator তিনটি span দাগিয়েছেন: জেলা (০, ১), পণ্য (১, ৪), দাম (৪, ৬). শেষের সংখ্যাটি span-এর বাইরে। এবার প্রতি token-এ tag বসবে।
- ঢাকায়. এক token-এর span, তাই শুধু
B-DIST. বিভক্তি “য়” token-এর ভেতরেই থাকল; এটি একটি সিদ্ধান্ত, যা annotation নির্দেশিকায় লেখা থাকা চাই। - লাল. নতুন span শুরু, তাই
B-PROD. আগের span জেলার ছিল, এই শুরুই সেটি বন্ধ করল। - সুতি. পণ্য চলছে:
I-PROD. - শাড়ি. এখনো পণ্য:
I-PROD. কোথাও “শেষ” লেখা নেই; পরের tag দেখেই শেষ বোঝা যাবে। - ১২০০.
I-PRODনয়, কারণ ধরন বদলেছে।B-PRICEএলো, পণ্যের span বন্ধ হলো (১, ৪). - টাকায়. দামের অংশ:
I-PRICE. “১২০০ টাকা” পুরোটাই দাম ধরা হবে, এটিও নির্দেশিকার সিদ্ধান্ত। - পাঠান.
O. দামের span বন্ধ (৪, ৬). তিনটি span থেকে ৭টি tag, আর উল্টো পথে ৭টি tag থেকে হুবহু সেই তিনটি span.
BIOES (BILOU নামেও চেনা) আরও দুটি চিহ্ন আনে: E-X টুকরোর শেষ, S-X এক token-এর টুকরো। K হয় 4E + 1, জেলা-পণ্য-দামে ১৩। ওপরের বাক্যটি BIOES-এ: S-DIST B-PROD I-PROD E-PROD B-PRICE E-PRICE O. Ratinov ও Roth (২০০৯) এতে কিছু ক্ষেত্রে ভালো ফল পেয়েছিলেন। তবে পার্থক্য ডেটা-নির্ভর আর ডেটা অল্প হলে বাড়তি tag নিজেই বোঝা, তাই নিজের ডেটায় যাচাই করুন। BIO নিয়মের উৎস হিসেবে সাধারণত Ramshaw ও Marcus-এর ১৯৯৫ সালের chunking প্রবন্ধের কথা বলা হয়।
Model-এর কথা আসার আগেই একটি প্রশ্ন: আউটপুটে O I-PROD I-PROD এলে কী হবে? B-PROD তো নেই। নিচের কোডে দুটি উত্তরই আছে।
def to_bio(tokens, spans):
tags = ["O"] * len(tokens)
for kind, start, end in spans: # end is not included
tags[start] = "B-" + kind
for i in range(start + 1, end):
tags[i] = "I-" + kind
return tags
def get_spans(tags, strict=True):
out, cur = [], None
for i, t in enumerate(tags + ["O"]):
p, kind = t[:1], t[2:]
if cur and not (p == "I" and kind == cur[0]):
out.append((cur[0], cur[1], i)) # the open span ends here
cur = None
if p == "B" or (p == "I" and not strict and cur is None):
cur = (kind, i)
return out
toks = "ঢাকায় লাল সুতি শাড়ি ১২০০ টাকায় পাঠান".split()
gold = [("DIST", 0, 1), ("PROD", 1, 4), ("PRICE", 4, 6)]
tags = to_bio(toks, gold)
for w, t in zip(toks, tags):
print(f"{t:8s} {w}")
print("round trip ok:", get_spans(tags) == gold)
bad = ["B-DIST", "O", "I-PROD", "I-PROD", "B-PRICE", "I-PRICE", "O"]
print("invalid:", bad[1:4])
print("strict :", get_spans(bad, strict=True))
print("lenient:", get_spans(bad, strict=False))আউটপুট (পাতার Python-এ চালানো):
B-DIST ঢাকায়
B-PROD লাল
I-PROD সুতি
I-PROD শাড়ি
B-PRICE ১২০০
I-PRICE টাকায়
O পাঠান
round trip ok: True
invalid: ['O', 'I-PROD', 'I-PROD']
strict : [('DIST', 0, 1), ('PRICE', 4, 6)]
lenient: [('DIST', 0, 1), ('PROD', 2, 4), ('PRICE', 4, 6)]
Round trip পরীক্ষাটি ছোট, কিন্তু annotation-এর রূপান্তরে ভুল ধরার সবচেয়ে সস্তা উপায়। শেষ দুই লাইন আসল কথা: একই tag থেকে দুই নিয়মে দুই রকম span. Strict নিয়মে B- ছাড়া শুরু হয় না, পণ্য হারিয়ে গেল। Lenient নিয়মে I--ই শুরু করে দেয়, পণ্য (২, ৪) টিকে গেল। মডেলের স্কোর কোন নিয়মে মাপা, না জানলে দুটি স্কোরের তুলনা অর্থহীন।
কোড সহজ, কঠিন হলো কী দাগাব। বাজারঘরের নির্দেশিকার কয়েকটি সিদ্ধান্ত, যা এই পর্বের ডেটায় মানা হয়েছে। অন্য দল অন্যভাবেও ঠিক করতে পারে; দরকার একটি লিখিত আর মেনে-চলা নিয়ম।
| লেখা | সিদ্ধান্ত | কারণ |
|---|---|---|
| রংপুরে, রংপুরের | পুরো token B-DIST | Token ভাঙা হয়নি; বিভক্তি তার অংশ |
| টাঙ্গাইল শাড়ি | দুটিই পণ্য | এখানে জেলা নয়, পণ্যের নামের অংশ |
| ৮০০ টাকা, ৳৮০০ | “টাকা”-সহ PRICE; ৳৮০০ এক token | দুটিই একই অর্থ, ফরম্যাট আলাদা |
| ডেলিভারি চার্জ ৬০ টাকা | সব O | PRICE শুধু পণ্যের দাম; এটি প্রসঙ্গ দেখে বুঝতে হয় |
| ২টি, ৩ দিনে | O | পরিমাণ বা সময় এই তালিকায় নেই |
৩ · Per-token softmax আর তার ফাঁক
সবচেয়ে সরল tagger: BiLSTM প্রতি token-এ একটি vector দেয়, একটি linear স্তর তা থেকে K-টি logits বানায়, তারপর softmax. Loss প্রতি token-এর cross-entropy, pad-এর ঘর ignore_index-এ বাদ। খেয়াল করুন মডেলটি কী অনুমান করে নিচ্ছে:
P(y1, …, yT | x) = ∏t P(yt | x)
এখানে x পুরো বাক্য, yt t-তম token-এর tag. BiLSTM-এর hidden state প্রসঙ্গ আনে ঠিকই, কিন্তু x দেখার পরে tag-গুলো পরস্পর স্বাধীন। চূড়ান্ত argmax-এ একটি tag জানে না পাশেরটি কী বেছেছে।
| সুতি | |
| শাড়ি | |
| পাঠান | |
| Tag ক্রম | |
| Span | |
| পথের score |
- তিনটি আলাদা সিদ্ধান্ত. প্রতি token-এর তিনটি সম্ভাবনা। “সুতি”-তে O আর B-PROD প্রায় কাছাকাছি: মডেল দ্বিধায়। এই সংখ্যাগুলো একটি হাতে-বসানো উদাহরণের, অংশ ৪-এর E টেবিল থেকে softmax করে পাওয়া।
- সুতি → O. .457 > .374, তাই O. পরের token কোন tag পাবে, তা এই সিদ্ধান্তে আসে না।
- শাড়ি → I-PROD. “শাড়ি” পণ্যের শব্দ, আর পণ্য চলছে এমন বাক্যে অনেকবার দেখা। .499 সবচেয়ে বড়। আগের token O পেয়েছে, এই তথ্য এই সিদ্ধান্তে ঢোকে না।
- Invalid ক্রম.
O I-PROD O: টুকরো শুরু না হয়েই চলছে। Strict নিয়মে পণ্য নেই, lenient নিয়মে পণ্য শুধু “শাড়ি”; আসল পণ্য “সুতি শাড়ি”-র সঙ্গে কোনোটিই মেলে না। Loss-এ এমন কোনো পদ নেই যা ক্রমটিকে শাস্তি দেয়। - পাশের tag জানলে. O → I-PROD-এর score −3 বসালে (অংশ ৪) O I-PROD O পায় 1.8, আর B-PROD I-PROD O পায় 5.6. “সুতি”-র দ্বিধা মিটল “শাড়ি” থেকে আসা প্রমাণে।
৪ · Linear-chain CRF — পুরো পথের একটি score
সমাধান: আলাদা আলাদা token নয়, পুরো tag-পথকে একটি score দিন। Conditional Random Field (CRF) এনেছিলেন Lafferty, McCallum আর Pereira, ২০০১ সালে। আমরা তার সবচেয়ে সরল রূপ, linear-chain, দেখছি।
score(y) = S(y1) + ∑t E(t, yt) + ∑t≥2 A(yt−1, yt)
P(y | x) = exp(score(y)) / Z · Z = ∑y′ exp(score(y′))
E(t, j) হলো emission score: t-তম token-এ tag j কতটা মানায়; BiLSTM-এর শেষ স্তর থেকে আসে, অর্থাৎ softmax-এর আগের logits-ই। A(i, j) হলো transition score: tag i-এর ঠিক পরে j কতটা মানায়; K × K একটি শেখা টেবিল। S শুরুর score (চাইলে শেষের score-ও যোগ হয়, এখানে ০ ধরা)। Z হলো partition function: KT-টি পথের সবার exp(score) যোগ করে সম্ভাবনার যোগফল ১ বানায়। ফলে tag-গুলোর স্বাধীনতার অনুমান আর থাকে না; এক token-এর tag পাশের token-এর score-এ জড়ানো।
Softmax বনাম linear-chain CRF
Independent choices vs one score for the whole path
এই পর্বের ছোট উদাহরণের সব সংখ্যা। তিনটি tag, তিনটি token. এগুলো শেখা মান নয়, হাতে বসানো, যাতে হিসাব মেলানো যায়।
| E (t, tag) | O | B | I | A (সারি → কলাম) | O | B | I |
|---|---|---|---|---|---|---|---|
| সুতি | 1.2 | 1.0 | 0.2 | O → | 0.5 | 0.2 | −3.0 |
| শাড়ি | 0.3 | 1.0 | 1.4 | B → | 0.0 | −1.0 | 1.0 |
| পাঠান | 2.0 | 0.0 | 0.1 | I → | 0.2 | −0.5 | 0.5 |
শুরুর score S: O ০, B ০, I −৩. B আর I এখানে B-PROD আর I-PROD-এর সংক্ষেপ। একটি পথ হাতে: B I O-এর score = (0 + 1.0) + (1.0 + 1.4) + (0.2 + 2.0) = 5.6. আর O I O = (0 + 1.2) + (−3.0 + 1.4) + (0.2 + 2.0) = 1.8.
| প্রশ্ন | HMM | Linear-chain CRF |
|---|---|---|
| কী মডেল করে | P(x, y): শব্দ কীভাবে তৈরি হয় তাও (generative) | শুধু P(y | x) (discriminative) |
| Token-এর সংখ্যা | Emission সম্ভাবনা, P(শব্দ | tag) | যেকোনো বাস্তব score, পুরো বাক্য দেখে |
| পাশাপাশি tag | Transition সম্ভাবনা, যোগফল ১ | যেকোনো বাস্তব score |
| স্বাভাবিকীকরণ | প্রতি ধাপে | একবারে পুরো পথে, Z দিয়ে |
HMM-এর পথ-খোঁজা আর forward আপনি AI-এর ভিত্তি কোর্সের পর্ব ৫৩-এ করেছেন। পদ্ধতি একই dynamic programming, শুধু সংখ্যাগুলো সম্ভাবনার বদলে log-ধরনের score, তাই গুণের বদলে যোগ।
৫ · Viterbi — KT পথ না গুনে সেরা পথ
৭টি tag আর ১২টি token-এ পথ ৭১২ = প্রায় ১,৩৮৪ কোটি। সব গোনা অসম্ভব, দরকারও নেই। কারণ score পাশাপাশি জোড়ায় ভাগ হয়: t ধাপে j-তে পৌঁছানোর সেরা পথ জানলে তার আগের ইতিহাস আর লাগে না। ঢাকা থেকে সিলেটের সেরা রুট যদি ভৈরব হয়ে যায়, তবে ভৈরব পর্যন্ত অংশটি ঢাকা-ভৈরবের সেরা রুটই হবে।
δt(j) = E(t, j) + maxi [ δt−1(i) + A(i, j) ]
δt(j) হলো t-তম token-এ tag j-তে শেষ হওয়া সেরা পথের score; শুরু δ1(j) = S(j) + E(1, j). প্রতিটি max-এর বিজয়ীকে back pointer-এ রাখা হয়। খরচ O(T·K²): এখানে ১২ × ৪৯ = ৫৮৮টি যোগ-তুলনা (DSA কোর্সের dynamic programming যে কারণে কাজ করে)।
| δ₁ (সুতি) | |
| δ₂(O) | |
| δ₂(B) | |
| δ₂(I) | |
| back |
- আগের কলাম জানা. δ₁ = S + E(সুতি) = (1.2, 1.0, −2.8). এখন “শাড়ি”-র তিনটি tag-এর প্রতিটিতে তিন দিক থেকে আসা যায়, মোট ৯টি হিসাব।
- শাড়ি = O. প্রার্থী: 1.2 + 0.5 = 1.7, 1.0 + 0.0 = 1.0, −2.8 + 0.2 = −2.6. সেরা 1.7, আগেরটি O থেকে। তাতে E(শাড়ি, O) = 0.3 যোগ করে 2.0.
- শাড়ি = B. প্রার্থী: 1.2 + 0.2 = 1.4, 1.0 − 1.0 = 0.0, −2.8 − 0.5 = −3.3. সেরা 1.4, O থেকে। E যোগ করে 2.4.
- শাড়ি = I. প্রার্থী: 1.2 − 3.0 = −1.8, 1.0 + 1.0 = 2.0, −2.8 + 0.5 = −2.3. সেরা 2.0, B থেকে। E যোগ করে 3.4. O থেকে আসার পথ −3-এর জন্য হেরে গেল।
- লক্ষ্য করুন. “সুতি”-তে O-এর score বড় ছিল (1.2 > 1.0), তবু সেরা I-পথ B দিয়ে যাচ্ছে। Viterbi সিদ্ধান্ত স্থগিত রাখে: ঘরে ঘরে সেরা score আর পেছনের তীর জমে, চূড়ান্ত পথ ঠিক হয় শেষে, উল্টো দিকে হেঁটে।
import numpy as np
from itertools import product
tags = ["O", "B-PROD", "I-PROD"]
words = ["সুতি", "শাড়ি", "পাঠান"]
E = np.array([[1.2, 1.0, 0.2], [0.3, 1.0, 1.4], [2.0, 0.0, 0.1]]) # (T, K)
A = np.array([[0.5, 0.2, -3.0], [0.0, -1.0, 1.0], [0.2, -0.5, 0.5]]) # A[i, j]: i -> j
S = np.array([0.0, 0.0, -3.0]) # start score
def score(path):
s = S[path[0]] + E[0, path[0]]
for t in range(1, len(path)):
s += A[path[t - 1], path[t]] + E[t, path[t]]
return s
delta = S + E[0]
back = []
print("delta", words[0], np.round(delta, 2))
for t in range(1, len(E)):
cand = delta[:, None] + A # (K, K): from row to column
back.append(cand.argmax(0))
delta = cand.max(0) + E[t]
print("delta", words[t], np.round(delta, 2), "back", back[-1])
path = [int(delta.argmax())]
for b in reversed(back):
path.append(int(b[path[-1]]))
path = path[::-1]
print("viterbi:", [tags[k] for k in path], round(float(delta.max()), 2))
paths = list(product(range(3), repeat=3))
best = max(paths, key=score)
print("brute :", [tags[k] for k in best], round(float(score(best)), 2), "of", len(paths), "paths")
per_token = E.argmax(1)
print("argmax :", [tags[k] for k in per_token], round(float(score(per_token)), 2))আউটপুট (পাতার Python-এ চালানো):
delta সুতি [ 1.2 1. -2.8]
delta শাড়ি [2. 2.4 3.4] back [0 0 1]
delta পাঠান [5.6 2.9 4. ] back [2 2 2]
viterbi: ['B-PROD', 'I-PROD', 'O'] 5.6
brute : ['B-PROD', 'I-PROD', 'O'] 5.6 of 27 paths
argmax : ['O', 'I-PROD', 'O'] 1.8
পেছনে হাঁটা: শেষ কলামে সেরা 5.6, tag O (০). back-এর শেষ সারির ০-নম্বর ঘর ২, অর্থাৎ “শাড়ি” I. আগের সারির ২-নম্বর ঘর ১, অর্থাৎ “সুতি” B. পথ B I O. Brute force-এ ২৭টি পথের সব গুনেও একই উত্তর: DP-র কোড লিখলে এই পরীক্ষা ছোট ইনপুটে সব সময় করুন। শেষ লাইনে একই E থেকে প্রতি token-এর argmax: invalid পথ, score মাত্র 1.8.
পুরো trellis আর পেছনে হাঁটা
δ in each cell, the winning back pointers, and the best path in green
৬ · Forward algorithm — Z-এর হিসাব আর loss
Training-এ চাই সঠিক পথের সম্ভাবনা বাড়ানো। তাই loss হলো negative log-likelihood:
NLL = −log P(y* | x) = log Z − score(y*)
y* সঠিক tag-পথ। score(y*) একটি যোগ, সহজ। কিন্তু log Z-তে KT-টি পথ। Viterbi-র সূত্রে max-এর জায়গায় log-sum-exp বসালেই হয়, কারণ যোগফলও জোড়ায় জোড়ায় ভাগ হয়:
αt(j) = E(t, j) + LSEi [ αt−1(i) + A(i, j) ] · log Z = LSEj αT(j)
LSE(a, b, …) = log(ea + eb + …), যা সরাসরি হিসাব করলে বড় score-এ overflow হয়; তাই সবচেয়ে বড় মান আলাদা করে নেওয়া হয়। সৌভাগ্য যে এই পুরো গণনা differentiable, তাই PyTorch-এর autograd নিজেই gradient বের করে; আপনাকে forward-backward লিখতে হয় না।
import numpy as np
from itertools import product
tags = ["O", "B-PROD", "I-PROD"]
E = np.array([[1.2, 1.0, 0.2], [0.3, 1.0, 1.4], [2.0, 0.0, 0.1]])
A = np.array([[0.5, 0.2, -3.0], [0.0, -1.0, 1.0], [0.2, -0.5, 0.5]])
S = np.array([0.0, 0.0, -3.0])
np.set_printoptions(precision=3, suppress=True)
def lse(x, axis=None): # log-sum-exp, safe for large scores
m = np.max(x, axis=axis, keepdims=True)
return np.squeeze(m + np.log(np.exp(x - m).sum(axis=axis, keepdims=True)))
alpha = S + E[0]
print("alpha 1", alpha)
for t in range(1, len(E)):
alpha = lse(alpha[:, None] + A, axis=0) + E[t]
print("alpha", t + 1, alpha)
log_z = float(lse(alpha))
def score(p):
return S[p[0]] + E[0, p[0]] + sum(A[p[t-1], p[t]] + E[t, p[t]] for t in (1, 2))
all_s = np.array([score(p) for p in product(range(3), repeat=3)])
print("log Z forward:", round(log_z, 4), " brute:", round(float(lse(all_s)), 4))
gold = (1, 2, 0)
print("gold score:", round(float(score(gold)), 3))
print("P(gold) =", round(float(np.exp(score(gold) - log_z)), 4))
print("NLL(gold) =", round(float(log_z - score(gold)), 4))
print("sum of all P:", round(float(np.exp(all_s - log_z).sum()), 6))
P = np.exp(E) / np.exp(E).sum(1, keepdims=True) # softmax per token
print("softmax per token:\n", P)
print("softmax P(gold) =", round(float(P[0, 1] * P[1, 2] * P[2, 0]), 4))
print("softmax P(O I O) =", round(float(P[0, 0] * P[1, 2] * P[2, 0]), 4))আউটপুট (পাতার Python-এ চালানো):
alpha 1 [ 1.2 1. -2.8]
alpha 2 [2.412 2.628 3.435]
alpha 3 [6.251 3.626 4.593]
log Z forward: 6.4841 brute: 6.4841
gold score: 5.6
P(gold) = 0.4131
NLL(gold) = 0.8841
sum of all P: 1.0
softmax per token:
[[0.457 0.374 0.168]
[0.166 0.335 0.499]
[0.778 0.105 0.116]]
softmax P(gold) = 0.1455
softmax P(O I O) = 0.1777
Forward আর ২৭টি পথের brute force একই log Z দিল। সব পথের সম্ভাবনার যোগফল ১. α2(I) = 3.435, Viterbi-র 3.4-এর চেয়ে সামান্য বড়: যোগফল সব সময় সর্বোচ্চ মানের সমান বা বড়। CRF-এ সঠিক পথের সম্ভাবনা ০.৪১৩. একই E দিয়ে শুধু softmax-এ সঠিক পথ ০.১৪৫৫, আর invalid O I O বেশি, ০.১৭৭৭. সংখ্যাগুলো হাতে বসানো, তাই এটি শেখার নয়, শুধু গঠনের পার্থক্য দেখায়। Training-এর সময় NLL গোনা হয় forward দিয়ে, আর ব্যবহারের সময় পথ বের হয় Viterbi দিয়ে।
৭ · Span F1 — কেন token accuracy বিভ্রান্ত করে
পরীক্ষার খাতা দেখার কথা ভাবুন। অঙ্কের ধাপে ধাপে নম্বর দেওয়া এক জিনিস, শেষ উত্তর মিললেই নম্বর আরেক জিনিস। Token accuracy ধাপের নম্বর। Span F1 শেষ উত্তরের নম্বর: টুকরোর ধরন, শুরু আর শেষ, তিনটিই হুবহু মিললে তবে TP. Precision, recall আর F1-এর সূত্র AI-এর ভিত্তি কোর্সের পর্ব ৬০-এ; এখানে শুধু গোনার একক বদলেছে।
F1 = 2·TP / (2·TP + FP + FN)
দুটি কারণে accuracy ঠকায়। এক, বেশিরভাগ token O, তাই কিছু না করলেও অনেকটা স্কোর আসে। দুই, “লাল সুতি শাড়ি”-র শেষ token ভুল হলে accuracy-তে ৩টির ১টি ভুল, কিন্তু span-এ পুরোটাই ভুল: একটি FN (আসলটি ধরা পড়েনি) আর একটি FP (যা বলা হলো সেটি নেই)। এক সীমানার ভুল দুই দিকে শাস্তি পায়।
def spans(tags):
out, cur = set(), None
for i, t in enumerate(tags + ["O"]):
if cur and t != "I-" + cur[0]:
out.add((cur[0], cur[1], i)); cur = None
if t[0] == "B":
cur = (t[2:], i)
return out
def report(name, gold, pred):
n = right = tp = fp = fn = 0
for g, p in zip(gold, pred):
n += len(g); right += sum(a == b for a, b in zip(g, p))
G, P = spans(g), spans(p)
tp += len(G & P); fp += len(P - G); fn += len(G - P)
pr = tp / (tp + fp) if tp + fp else 0
re = tp / (tp + fn) if tp + fn else 0
f1 = 2 * pr * re / (pr + re) if pr + re else 0
print(f"{name:6s} acc {right / n:.3f} TP {tp} FP {fp} FN {fn} P {pr:.2f} R {re:.2f} F1 {f1:.2f}")
D, P, I, O = "B-DIST", "B-PROD", "I-PROD", "O"
B, J = "B-PRICE", "I-PRICE"
gold = [[O, O, D, O, P, I, I, O, B, J, O, O],
[P, I, O, O, D, O, O, B, J, O, O, O],
[O, D, O, O, O, P, I, I, O, O, O, O],
[O, O, P, I, I, O, B, J, O, D, O, O]]
cut = [[O, O, D, O, P, I, O, O, B, O, O, O], # one token short at each end
[P, O, O, O, D, O, O, B, O, O, O, O],
[O, D, O, O, O, P, I, O, O, O, O, O],
[O, O, O, I, I, O, B, O, O, D, O, O]]
pad = [O] * 10 # the rest of each order text
gold, cut = [g + pad for g in gold], [c + pad for c in cut]
report("cut", gold, cut)
report("all O", gold, [[O] * 22 for _ in gold])আউটপুট (পাতার Python-এ চালানো):
cut acc 0.920 TP 4 FP 6 FN 7 P 0.40 R 0.36 F1 0.38
all O acc 0.761 TP 0 FP 0 FN 11 P 0.00 R 0.00 F1 0.00
৮৮টি token-এর ৮১টি ঠিক, accuracy ০.৯২. দেখতে চমৎকার। কিন্তু ১১টি span-এর মাত্র ৪টি (জেলাগুলো) হুবহু মিলেছে। হাতে: F1 = ২·৪ / (২·৪ + ৬ + ৭) = ৮/২১ = ০.৩৮. এই মডেল দিয়ে অর্ডার প্রসেস হলে ১০টির মধ্যে ৬টি আউটপুট ভুল দাম বা ভুল পণ্য। আর কিছুই না করা “সব O” পায় ০.৭৬. তাই NER-এর রিপোর্টে শুধু token accuracy দেখলে প্রথমেই span F1 চান।
প্রতি ধরনে হাতে. একই “cut” উদাহরণে ধরন ধরে গুনুন। DIST: আসল ৪, বলা ৪, মিল ৪, F1 = ১. PROD: আসল ৪, বলা ৩, মিল ০, F1 = ০; একটি (০, ১) “ছোট” পণ্যও একটি FP. PRICE: আসল ৩, বলা ৩, মিল ০, F1 = ০. মোট TP = ৪, FP = ০ + ৩ + ৩ = ৬, FN = ০ + ৪ + ৩ = ৭: ওপরের আউটপুটের সঙ্গে হুবহু মিলল। সবগুলো span একসঙ্গে গুনে যে F1, তা micro (০.৩৮); তিন ধরনের F1-এর সাধারণ গড় macro (১/৩ = ০.৩৩). আবার দেখুন, “জেলা ১, পণ্য ০” এই খবর একটি মাত্র সংখ্যায় হারিয়ে যায়।
একটি ধরন খুব বিরল হলে micro F1 তাকে লুকিয়ে ফেলে, তাই প্রতি ধরনের F1 আলাদা দেখুন; class imbalance-এর দিকটি আছে মেশিন লার্নিং কোর্সের পর্ব ৫০-এ। Metric-এর পুরো ছবি পর্ব ৪৫-এ (NLP মূল্যায়ন).
৮ · বাজারঘরের অর্ডার: BiLSTM + softmax বনাম BiLSTM + CRF
এবার সত্যিকারের training. ডেটা পুরোপুরি কাল্পনিক: seed-সহ একটি generator ১৪টি ছাঁচ (template) থেকে অর্ডারের বার্তা বানায়। কোনো আসল মানুষের নাম, ফোন বা ঠিকানা নেই; জেলার নামগুলো শুধু সাধারণ ভৌগোলিক শব্দ। Test-এ কিছু জেলা, পণ্য আর বিশেষণ train-এ একবারও নেই, যাতে মুখস্থে পার পাওয়া না যায়। “টাঙ্গাইল শাড়ি”-র মতো পণ্যে জেলার নাম আছে, আর “ডেলিভারি চার্জ ৬০ টাকা” দাম নয়। সতর্কতা: ছাঁচ train আর test-এ একই, তাই এখানকার সংখ্যা আসল গ্রাহকের লেখায় খাটবে না। শুধু দুটি মডেলের তুলনার ধরন দেখাই লক্ষ্য।
~/bazarghar-dl$ uv run python gen_data.py
300 200 500
…
রংপুরে/B-DIST ছাতা/B-PROD পাঠাবেন/O ,/O দাম/O ৭৮০০/B-PRICE টাকা/I-PRICE ,/O ৫০/O টাকা/O অগ্রিম/O পাঠাতে/O পারব/O না/O
গত/O সপ্তাহে/O নতুন/B-PROD সুতি/I-PROD শাড়ি/I-PROD নিয়েছিলাম/O ৳১৫০০/B-PRICE ,/O এবার/O ফেনী/B-DIST পাঠান/O
…
সত্যিকারের run: Python 3.12, PyTorch 2.15.0 (CPU), seqeval 1.2.2 (লেখার সময়ে)। CRF স্তরটি নিজে লেখা, মাত্র কয়েক ডজন লাইন। নিচে তা সংক্ষেপে, মূল অংশটুকু; viterbi() আর batch-এর সীমানা-সংক্রান্ত সব লাইন বাদ।
class LinearChainCRF(nn.Module):
def __init__(self, n_tags):
super().__init__()
self.trans = nn.Parameter(torch.zeros(n_tags, n_tags)) # trans[i, j]: i -> j
self.start = nn.Parameter(torch.zeros(n_tags))
self.end = nn.Parameter(torch.zeros(n_tags))
def log_z(self, em, mask): # em: (N, T, K), forward in log space
alpha = self.start + em[:, 0] # (N, K)
for t in range(1, em.size(1)):
nxt = torch.logsumexp(alpha.unsqueeze(2) + self.trans
+ em[:, t].unsqueeze(1), dim=1)
alpha = torch.where(mask[:, t:t + 1].bool(), nxt, alpha)
return torch.logsumexp(alpha + self.end, dim=1)
def nll(self, em, tags, mask):
return (self.log_z(em, mask) - self.path_score(em, tags, mask)).mean()torch.where-এর লাইনটিই padding সামলায়: বাক্য শেষ হলে alpha আর বদলায় না। লেখার পর train করার আগে brute force-এ মিলিয়ে নিন। ৪টি tag, ৫টি token, এলোমেলো score:
~/bazarghar-dl$ uv run python check_crf.py
paths: 1024
log Z forward : 9.007175
log Z brute : 9.007175
best viterbi : [1, 0, 2, 0, 2]
best brute : [1, 0, 2, 0, 2]
log Z padded : 9.007175
nll >= 0 : True
পুরো মডেলটি। Softmax আর CRF-এর একমাত্র পার্থক্য শেষের loss আর decode-এ, তাই তুলনা পরিষ্কার। পর্ব ২৯-এর PackedSequence এখানেও কাজে লাগে, যাতে LSTM pad-এর ওপর না চলে। নিচে সংক্ষেপে; পূর্ণ train.py-তে batch, dev-এ সেরা epoch রাখা আর মূল্যায়নের লাইনও আছে।
class Tagger(nn.Module):
def __init__(self, vocab, use_crf, emb=48, hidden=48):
super().__init__()
self.emb = nn.Embedding(vocab, emb, padding_idx=0)
self.lstm = nn.LSTM(emb, hidden, batch_first=True, bidirectional=True)
self.drop = nn.Dropout(0.3)
self.out = nn.Linear(2 * hidden, len(TAGS))
self.crf = LinearChainCRF(len(TAGS)) if use_crf else None
def emissions(self, x, lengths): # x: (N, T)
h = self.drop(self.emb(x)) # (N, T, 48)
p = pack_padded_sequence(h, lengths.cpu(), batch_first=True, enforce_sorted=False)
o, _ = self.lstm(p)
o, _ = pad_packed_sequence(o, batch_first=True, total_length=x.size(1)) # (N, T, 96)
return self.out(self.drop(o)) # (N, T, 7)
# training step: only this differs
em = m.emissions(x, ln)
if use_crf:
loss = m.crf.nll(em, y.clamp(min=0), (y != -100).float())
else:
loss = ce(em.transpose(1, 2), y) # CrossEntropyLoss(ignore_index=-100)দুটি সূক্ষ্ম জিনিস দেখুন। CrossEntropyLoss চায় (N, K, T), তাই transpose. আর CRF-এর tag-সূচক −100 হতে পারে না, কারণ টেবিলের সূচক হিসেবে ব্যবহার হয়, তাই clamp; pad-এর ঘর mask-এ এমনিতেই বাদ। একই সঙ্গে mask ভুলে গেলে কোড ক্র্যাশ করে না, কিন্তু loss ভুল আসে। Parameter-এর হিসাবে CRF-এর বাড়তি খরচ সামান্য: ৭ × ৭ + ৭ + ৭ = ৬৩টি সংখ্যা। সময় বাড়ে মূলত log_z-এর প্রতি ধাপের K × K গণনায়।
দুটি মডেলে একই embedding (৪৮), একই BiLSTM (৪৮ × ২), একই dropout আর একই ২৫ epoch; train-এর ৩০০ বাক্য, Adam, batch ১৬. অজানা শব্দের জন্য train-এ ১৫% শব্দ এলোমেলোভাবে UNK-এ বদলানো (word dropout), আর সব অঙ্ক এক token <num>. Dev-এ সেরা epoch রাখা; dev-এর অজানা শব্দগুলো test-এর ভাণ্ডার থেকে, তবে dev আর test আলাদা বাক্য। তৃতীয় সারি “softmax+rule”: একই softmax মডেল, শুধু শেষে অবৈধ transition নিষিদ্ধ করে Viterbi (constrained decoding), অর্থাৎ নতুন training নেই।
~/bazarghar-dl$ uv run python train.py 1 2 3 4 5
train/dev/test: 300/200/500 vocab: 144 tags: 7
test tokens: 5540, not in train: 1015 (18.3%)
test spans: {'DIST': 416, 'PROD': 471, 'PRICE': 397}
all-O baseline: token accuracy 0.610, span F1 0.000
…
model acc spanF1 DIST PROD PRICE invalid sec
softmax 0.986 0.952 0.968 0.898 1.000 9.0 3.6 f1 sd 0.007
softmax+rule 0.986 0.952 0.966 0.900 1.000 0.0 3.6 f1 sd 0.007
crf 0.989 0.963 0.978 0.920 0.999 0.2 5.8 f1 sd 0.007
crf - softmax per seed: [0.012, 0.011, 0.01, 0.011, 0.01]
crf - softmax+rule per seed: [0.011, 0.011, 0.011, 0.011, 0.011]
own scorer == seqeval strict: True
সংখ্যাগুলো পড়ুন (৫ seed-এর গড়, স্কোর strict নিয়মে; একটি local run, CPU-তে, ২ thread):
- Accuracy ০.৯৮৬, F1 ০.৯৫২. ভুল token ১.৪%, কিন্তু span F1-এর ঘাটতি ৪.৮%: তিন গুণের বেশি। অংশ ৭-এর কথাই, এবার সত্যিকারের model-এ।
- CRF পাঁচটি seed-এই এগিয়ে, প্রায় +০.০১১. Seed-এর ছড়ানো (sd ০.০০৭) পার্থক্যের কাছাকাছি হলেও একই seed-এ জোড়া তুলনায় চিহ্ন বদলায়নি। জোড়া তুলনার পদ্ধতি মেশিন লার্নিং কোর্সের পর্ব ২২-এ। ডেটাসেট কিন্তু একটিই; seed শুধু training-এর এলোমেলোতা ধরে।
- লাভের প্রায় পুরোটাই পণ্যে: PROD ০.৮৯৮ থেকে ০.৯২০, যেখানে লম্বা টুকরো আর অজানা শব্দ বেশি। দাম প্রায় নিখুঁত, কারণ
<num> টাকাসহজ ছাঁচ। - Invalid tag গড়ে মাত্র ৯টি, ৫,৫৪০ token-এ। আর নিয়ম বসিয়ে সেগুলো মুছলে F1 প্রায় বদলায়নি। তাহলে CRF-এর লাভ শুধু “অবৈধ ক্রম আটকানো” নয়; একসঙ্গে শেখা বাকি অংশ থেকে আসছে। এটি এই run-এর সবচেয়ে কৌতূহলের ফল।
- সময়: ৩.৬ বনাম ৫.৮ সেকেন্ড, প্রায় ১.৬ গুণ। অন্য কাজও চলছিল, তাই সময় আনুমানিক।
ডেটা কম হলে কী হয়? একই dev আর test, একই ৫ seed; শুধু train-এর বাক্যসংখ্যা বদলানো। ১০০ আর ১,০০০ বাক্য একই generator-এর অন্য seed থেকে।
~/bazarghar-dl$ uv run python curve.py
n_train model spanF1 PROD invalid sec crf-softmax per seed
100 softmax 0.832 0.668 39.0 3.2
100 crf 0.876 0.760 19.8 7.1 [0.069, 0.05, 0.043, 0.036, 0.021]
300 softmax 0.952 0.898 9.0 8.3
300 crf 0.963 0.920 0.2 12.7 [0.012, 0.011, 0.01, 0.011, 0.01]
1000 softmax 0.972 0.941 8.0 25.4
1000 crf 0.983 0.965 0.0 31.8 [0.023, 0.01, 0.005, 0.013, 0.001]
১০০ বাক্যে CRF-এর গড় লাভ প্রায় ০.০৪৪, ৩০০ আর ১,০০০ বাক্যে প্রায় ০.০১. প্রতিটি আকারে পাঁচটি seed-এই CRF এগিয়ে, যদিও ১,০০০-এ একটি seed-এ লাভ মাত্র ০.০০১. অর্থাৎ এই ডেটায় ডেটা যত কম, কাঠামোর দাম তত বেশি। তবে শুধু আরও ডেটা আনলেই উন্নতি হয়েছে অনেক বেশি: softmax ০.৮৩২ থেকে ০.৯৭২. ৩০০-এর সারির F1 আগের টেবিলের সঙ্গে হুবহু মিলেছে (seed একই), কিন্তু সময় আলাদা, ৮.৩ বনাম ৩.৬ সেকেন্ড, কারণ এই run-এর সময় যন্ত্রে অন্য কাজ চলছিল। তাই এই পর্বের সময়ের সংখ্যাকে শুধু মোটামুটি ধারণা হিসেবে নিন।
Seed ১-এর ভুলগুলো ধরন অনুযায়ী, মোট ১,২৮৪টি আসল span-এর ওপর। একটি বাক্য CRF ঠিক ধরেছে, দুটিতে দুই মডেলই একই ভুল করেছে:
~/bazarghar-dl$ uv run python errors.py
softmax {'exact': 1211, 'boundary': 32, 'wrong_type': 23, 'missed': 18, 'spurious': 0}
softmax+rule {'exact': 1215, 'boundary': 34, 'wrong_type': 24, 'missed': 11, 'spurious': 0}
crf {'exact': 1230, 'boundary': 32, 'wrong_type': 17, 'missed': 5, 'spurious': 0}
…
শেরপুরে ফুলহাতা মেরুন পাঞ্জাবি পাঠাবেন , দাম ২৫৫০ টাকা
gold B-DIST B-PROD I-PROD I-PROD O O O B-PRICE I-PRICE
softmax O O I-PROD I-PROD O O O B-PRICE I-PRICE
crf B-DIST B-PROD I-PROD I-PROD O O O B-PRICE I-PRICE
both wrong:
…
জামালপুরে শাড়ি পাঠাবেন , দাম ৮১০০ টাকা , ২ দিনের মধ্যে চাই
gold B-DIST B-PROD O O O B-PRICE I-PRICE O O O O O
softmax B-PROD I-PROD O O O B-PRICE I-PRICE O O O O O
crf B-PROD I-PROD O O O B-PRICE I-PRICE O O O O O
টিফিন-বক্স ১,১৫০ টাকা , ঠিকানা নোয়াখালী সদর , ৪ দিনের মধ্যে চাই
gold B-PROD B-PRICE I-PRICE O O B-DIST O O O O O O
softmax O B-PRICE I-PRICE O O B-DIST O O O O O O
crf O B-PRICE I-PRICE O O B-DIST O O O O O O
প্রথম বাক্যে “শেরপুরে ফুলহাতা মেরুন” তিনটিই অচেনা শব্দ; softmax দ্বিধায় পড়ে O দিয়েছে, CRF পুরো পথ দেখে টুকরো ঠিক করেছে। দ্বিতীয় বাক্যে অচেনা “জামালপুরে”, ঠিক আগে “শাড়ি”: train-এ এই অবস্থান প্রায়ই পণ্যের বিশেষণের, তাই দুই মডেলই ভুল করেছে। এই ভুলের ওষুধ CRF নয়। শব্দ-ভিত্তিক embedding-এ UNK-এর ভেতরে “-ে” বিভক্তি দেখা যায় না; অক্ষর বা subword-এর তথ্য লাগবে (পর্ব ৪১ Word embedding-এর fastText আর পর্ব ৪৪ Contextual embedding এদিকেই)। Transition টেবিলটিও সত্যিই শিখেছে, যেমন B-PROD → I-PROD +০.৫৮, O → I-PROD −০.৫৬, B-PROD → B-PROD −০.৯৭.
seqeval-এর ফাঁদ. seqeval-এর f1_score default-এ lenient, অর্থাৎ B- ছাড়া I--কেও টুকরো ধরে। একই ক্রমে দুই রকম ফল, আর দিকও সব সময় এক নয়। উপরের টেবিলের “own scorer == seqeval strict: True” এই পর্বের scorer-কে mode="strict", scheme=IOB2-এর সঙ্গে মেলায়। রিপোর্টে নিয়মটি লিখুন।
~/bazarghar-dl$ uv run python seqeval_modes.py
gold: ['B-PROD', 'I-PROD', 'O', 'B-DIST']
pred: ['O', 'I-PROD', 'O', 'B-DIST']
default (lenient): 0.5
strict + IOB2 : 0.6666666666666666
pred: ['I-PROD', 'I-PROD', 'O', 'B-DIST']
default (lenient): 1.0
strict + IOB2 : 0.6666666666666666
৯ · বাস্তবে যা মনে রাখার — subword, ডেটা আর গোপনীয়তা
কখন CRF. সস্তা baseline আগে: BiLSTM + softmax, strict span F1 ধরে। ডেটা অল্প আর টুকরো লম্বা হলে CRF যোগ করে একই seed-এ তুলনা করুন; ওপরের run-এ লাভ সবচেয়ে বেশি ছিল ১০০ বাক্যে। শক্তিশালী Transformer encoder-এর ওপর CRF-এর লাভ অনেক গবেষণায় ছোট বা ডেটা-নির্ভর বলে রিপোর্ট হয়েছে, আর খরচ কিছুটা বাড়ে; নিজের ডেটায় মাপুন। শুধু invalid ক্রম আটকাতে চাইলে নিয়মভিত্তিক decoding প্রায় বিনা খরচে হয়।
Subword-এর সমস্যা. Tokenizer “টাঙ্গাইল”-কে কয়েকটি টুকরো করতে পারে, অথচ label শব্দে শব্দে। প্রচলিত উপায়: শব্দের প্রথম subtoken-এ label, বাকিগুলোয় −100, যাতে loss-এ না ঢোকে। স্কোর শব্দের স্তরে ফিরিয়ে আনতে হয়। বিস্তারিত আসছে পর্ব ৫৪-এ (Transformer fine-tuning).
একই শব্দে একাধিক সত্তা. সমতল BIO-তে এক token-এর একটিই tag. “টাঙ্গাইল শাড়ি”-তে জেলা আর পণ্য দুটোই দেখাতে চাইলে nested NER-এর আলাদা নকশা লাগে। আবার “ঢাকায়”-এর মতো শব্দে সীমা token-এর ভেতরে; কেউ বিভক্তি ছাড়া টুকরো চান। এসব annotation নির্দেশিকার সিদ্ধান্ত। দুজন annotator কতটা একমত, সেটি মাপার পদ্ধতি মেশিন লার্নিং কোর্সের পর্ব ৫-এ।
গোপনীয়তা. আসল NER ডেটা মানেই মানুষের নাম, ঠিকানা, ফোন নম্বর। শেখার জন্য বা public repo-তে এমন ডেটা রাখবেন না, AI assistant-এও পেস্ট করবেন না। এই পর্বে সে কারণেই ব্যক্তির নাম আর ফোনের label রাখা হয়নি। Bangla NER-এর public ডেটাও আছে, যেমন SemEval ২০২২-এর MultiCoNER-এ বাংলা ছিল, তবে এই পর্বে সেটি চালানো হয়নি; ব্যবহারের আগে নিজে তার licence আর শর্ত পড়ে নিন।
আরও গভীরে. log Z-এর gradient হলো মডেলের অধীনে প্রতিটি transition-এর প্রত্যাশিত গণনা; তাই CRF-এর NLL-এর gradient দাঁড়ায় “ডেটায় দেখা গণনা বিয়োগ মডেলের প্রত্যাশিত গণনা”, যা ক্লাসিক হিসাবে forward-backward দিয়ে বের হতো। BiLSTM-CRF জনপ্রিয় হয় Huang, Xu ও Yu (২০১৫), Lample ও সহকর্মী (২০১৬), Ma ও Hovy (২০১৬)-এর কাজে। Exact-match span F1-এর মান তৈরিতে CoNLL-২০০৩ shared task (Tjong Kim Sang ও De Meulder) গুরুত্বপূর্ণ।
পরীক্ষা ও ইন্টারভিউতে.
- E-টি ধরনে BIO-র tag কত? 2E + 1; BIOES-এ 4E + 1.
- Viterbi আর forward-এর পার্থক্য? Viterbi-তে max, forward-এ log-sum-exp; খরচ দুটিরই O(T·K²).
- CRF-এর loss? log Z − score(y*); log Z আসে forward থেকে।
- Accuracy দিয়ে NER মাপা যায় না কেন? বেশিরভাগ token O, আর এক সীমানার ভুলে পুরো span বাতিল হয়।
- Softmax কেন I- without B- দেয়? x দেখার পরে tag-গুলো স্বাধীন; পাশাপাশি tag-এর কোনো পদ loss-এ নেই।
১০ · AI-এর যুগে sequence labeling
AI assistant একটি BiLSTM-CRF খুব দ্রুত লিখে দেয়, আর কোড সাধারণত চলেও। ভুলগুলো চুপচাপ থাকে:
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| শুধু token accuracy রিপোর্ট করে | “সব O”-এর accuracy পাশে রাখুন, strict span F1 চান |
| seqeval-এর default-কে strict ভাবে | একটি invalid ক্রমে নিজে হিসাব করে মেলান |
| CRF-এ pad-এর ঘরও score-এ যোগ করে | একই বাক্যে padding বাড়িয়ে log Z অপরিবর্তিত কি না দেখুন |
log Z আর score-এর চিহ্ন উল্টো, বা sum-এর জায়গায় max | KT brute force-এ মেলান; NLL কখনো ঋণাত্মক নয় |
| একই পণ্যের নাম train আর test-এ রেখে বড় স্কোর দেখায় | অজানা নাম আলাদা করে test করুন |
| একটি seed-এর ফল থেকে “CRF ভালো” বলে | কয়েকটি seed-এ জোড়া তুলনা করুন |
| পুরোনো library-র আর্গুমেন্ট লেখে | নিজের version-এর docs পড়ুন, ছোট batch-এ চালান |
কোড চাওয়ার সময় যাচাইয়ের ধাপগুলোও চেয়ে নিন। প্রম্পটের শেষ দুটি লাইনই আসল।
AI-এর লেখা tagger হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Invalid tag মুছে ফেলার নিয়ম F1 বাড়াল না, অথচ CRF বাড়াল। এর ব্যাখ্যা কী হতে পারে?
প্রথমে সংখ্যাটির ওজন দেখুন। ৫,৫৪০ token-এ গড়ে মাত্র ৯টি invalid tag, আর span ১,২৮৪টি। তাই সবগুলো নিখুঁত ঠিক হলেও F1 খুব একটা নড়ার কথা নয়। তা ছাড়া নিয়ম শুধু সবচেয়ে ভালো বৈধ পথ বেছে দেয়। যে span-এ শুরুটাই ভুল ধরনের, নিয়ম তাকে বৈধ রেখে দিতে পারে, ঠিক করে না। Seed ১-এ নিয়মের পরে সীমানার ভুল ৩২ থেকে বেড়ে ৩৪, ধরা না-পড়া ১৮ থেকে কমে ১১: ভুল এক ঘর থেকে অন্য ঘরে সরেছে।
CRF-এর পার্থক্য হলো সে training-এর সময় কাঠামো দেখে। NLL-এ log Z থাকায় gradient শেখায়, “যে ভুল পথগুলো সঠিক পথের কাছে আসছে, তাদের score নামান।” এর চাপ BiLSTM পর্যন্ত যায়। ফলে emission-গুলোও বদলে যায়; মডেল আলাদা হয়ে ওঠে। অন্যদিকে softmax+rule-এ emission আগেই ঠিক হয়ে গেছে, পরে শুধু একটি ফিল্টার।
একটি বিকল্প ব্যাখ্যাও মাথায় রাখুন। CRF-এর loss প্রতি বাক্যে একটি পূর্ণ পথের ওপর, softmax-এর loss প্রতি token-এ; তাদের মান আলাদা, তাই একই learning rate-এ কার্যকর শেখার গতিও আলাদা হতে পারে। এই run-এ দুটি কারণ আলাদা করে মাপা হয়নি। সত্যিকারের গবেষণায় learning rate দুই মডেলের জন্য আলাদা করে খুঁজতে হতো। আর ডেটাটি ছাঁচে-গড়া, তাই ফলটি কৌতূহলের, প্রমাণ নয়।
মূল উপলব্ধি: কাঠামোকে শুধু শেষে বসানো আর training-এর ভেতরে আনা এক জিনিস নয়। আর কোনো উন্নতির কারণ জানতে হলে একটি জিনিস বদলে আলাদা পরীক্ষা করতে হয়, গল্প বানিয়ে নয়।
প্র ০২HMM তো ছিলই। ২০০১ সালে CRF কেন আনতে হলো, আর “label bias” কী?
১৯৯০-এর দশকে POS tagging আর NER-এর একটি বড় হাতিয়ার ছিল HMM. এর সমস্যা হলো সে শব্দ তৈরি হওয়ার মডেলও বানায়, P(শব্দ | tag). বাস্তবে আমরা জানতে চাই শব্দের বড় হাতের অক্ষর আছে কি না, শেষে “-তে” আছে কি না, আগের ও পরের শব্দ কী। এই ওভারল্যাপিং feature-গুলো HMM-এ ঢোকানো কঠিন, কারণ তাদের জন্য একটি সুসংগত generative গল্প লাগে।
McCallum, Freitag ও Pereira ২০০০ সালে MEMM আনেন: প্রতি ধাপে আগের tag আর feature দেখে পরের tag-এর একটি স্থানীয় softmax. Feature ইচ্ছেমতো দেওয়া গেল। কিন্তু Lafferty, McCallum ও Pereira তাঁদের ২০০১ সালের প্রবন্ধে একটি দুর্বলতা দেখান, যার নাম label bias: প্রতিটি tag থেকে বের হওয়া সম্ভাবনার যোগফল ১ হতেই হবে। তাই যে tag-এর পরে অল্প কয়েকটি পথ, সে পরের শব্দের প্রমাণ প্রায় উপেক্ষা করতে পারে; নিশ্চিত পথে সম্ভাবনা ভাগ হওয়ার সুযোগই নেই।
CRF স্বাভাবিকীকরণ পুরো পথের ওপর একবারে করে, Z দিয়ে। Score স্থানীয়ভাবে যেকোনো বাস্তব মান হতে পারে, তাই একটি শক্ত প্রমাণ দূরের সিদ্ধান্ত পর্যন্ত প্রভাব ফেলে। দাম হলো Z-এর হিসাব: training অনেক ধীর হয়, বিশেষ করে হাতে-গড়া লাখো feature-এর যুগে। পরে নিউরাল এনকোডার হাতে-গড়া feature-এর জায়গা নিল, আর CRF স্তরটি একই থেকে গেল। একে অনেকে “মডেলের মাথায় কাঠামো” হিসেবে ভাবেন।
মূল উপলব্ধি: প্রতি ধাপে সম্ভাবনা বানানো সহজ, কিন্তু তা তথ্যের প্রবাহ আটকাতে পারে। পুরো পথের ওপর স্বাভাবিকীকরণ খরচ বাড়ায়, বিনিময়ে ভবিষ্যতের প্রমাণ ব্যবহার করতে দেয়।
প্র ০৩Exact-match span F1 কি ন্যায্য? “লাল সুতি শাড়ি”-র বদলে “সুতি শাড়ি” বললে নম্বর একেবারে শূন্য কেন?
CoNLL-২০০০ আর ২০০৩-এর shared task exact match-কে জনপ্রিয় করে, প্রধানত কারণ এটি একটি স্পষ্ট, একটি-সংখ্যার মান দেয় আর তুলনা সহজ করে। কিন্তু এর একটি অদ্ভুত পরিণতি আছে: সীমানার একটি ভুলে একই সঙ্গে FP আর FN দুটোই হয়। আর কিছুই না দাগালে হয় শুধু একটি FN. অর্থাৎ F1-এর হিসাবে “প্রায় ঠিক” বলা “কিছু না বলা”-র চেয়ে খারাপ। Christopher Manning ২০০৬ সালের একটি ব্লগ লেখায় এই বিষয়টি নিয়ে সতর্ক করেছিলেন; তাঁর যুক্তির সারকথা মোটামুটি এই যে, শুধু F1-এর জন্য tune করলে নিশ্চিত না হওয়া মডেলের পক্ষে কিছু না দাগানোই লাভের হয়ে ওঠে।
আগের MUC মূল্যায়নে আংশিক নম্বরের ব্যবস্থা ছিল, আর পরে SemEval ২০১৩-এর একটি মূল্যায়নে কয়েকটি স্তর দেখানো হয়: হুবহু মিল, শুধু সীমানা, শুধু ধরন, আংশিক। সব ক্ষেত্রে একটি উত্তর নেই। ধরুন কাজটি স্বয়ংক্রিয় অর্ডার: দামের টুকরো ভুল কাটা গেলে ভুল টাকা কাটা হবে, তাই কঠোর মান ঠিক আছে। আবার যদি কাজটি একজন কর্মীকে সম্ভাব্য পণ্যের নাম দেখানো হয়, “সুতি শাড়ি” বেশ কাজের; সেখানে আংশিক নম্বর বাস্তবের কাছাকাছি।
তাই ভালো চর্চা হলো strict F1-কে প্রধান সংখ্যা রেখে পাশে ভুলের ধরন দেখানো, যেমন অংশ ৮-এর exact, boundary, wrong_type, missed, spurious. এতে বোঝা যায় মডেল কোথায় হারছে, আর পরের পরিশ্রম কোথায় দেবেন। সীমানার ভুল বেশি হলে annotation নির্দেশিকা বা CRF ভাবুন; ধরনের ভুল বেশি হলে প্রসঙ্গের তথ্য।
মূল উপলব্ধি: Metric কোনো নিরপেক্ষ বিচারক নয়; একটি নির্দিষ্ট ব্যবহারের ধারণা তার ভেতরে বসানো। আগে ভুলের বাস্তব খরচ জানুন, তারপর metric বাছুন।
প্র ০৪বাংলায় NER ইংরেজির চেয়ে কঠিন কেন বলা হয়? কোন সমস্যা model-এর, কোনটি ডেটার?
প্রথমত, বড় হাতের অক্ষর নেই। ইংরেজিতে capitalization নামের একটি শক্ত সংকেত; পুরোনো NER সিস্টেম এর ওপর অনেকটা নির্ভর করত। বাংলা লিপিতে এই সংকেত নেই। দ্বিতীয়ত, বিভক্তি শব্দের গায়ে লাগানো: “ঢাকা, ঢাকায়, ঢাকার, ঢাকাতে” word embedding-এ চারটি আলাদা শব্দ, যা ডেটা ভাগ করে দেয়; অংশ ৮-এর “জামালপুরে” ভুল এর সঙ্গে জড়িত। এর উত্তর model-এ আছে: অক্ষর-স্তরের CNN বা LSTM, fastText, subword tokenizer. Lample ও সহকর্মীদের ২০১৬ সালের কাজে অক্ষর-স্তরের representation NER-এ বড় ভূমিকা রেখেছিল।
তৃতীয়ত, অর্থের দ্ব্যর্থতা: বাংলায় অনেক সাধারণ শব্দ নামও হয়, যেমন “আকাশ” বা “নদী” একটি নাম হতে পারে। “টাঙ্গাইল শাড়ি”-র মতো পণ্যে জেলা ঢুকে আছে। এখানে প্রসঙ্গ-সচেতন মডেল সাহায্য করে (পর্ব ৪৪), তবে নিশ্চয়তা নেই। চতুর্থত, লিপি ও বানানের বৈচিত্র্য: Banglish, সাধু-চলিত, একই নামের একাধিক বানান। NFC না করলে একই দেখতে দুটি শব্দ কম্পিউটারের কাছে আলাদা (AI-এর ভিত্তি কোর্সের পর্ব ৬৭).
আর সবচেয়ে বড়টি ডেটার সমস্যা: ইংরেজির তুলনায় ভালো মানের, স্পষ্ট licence-এর label-করা ডেটা কম। Annotation ব্যয়বহুল আর annotator-দের নিয়ম ভিন্ন হলে label-এই noise ঢোকে। Model যত ভালোই হোক, পরিষ্কার নির্দেশিকা আর সত্যিকারের গ্রাহকের লেখা ছাড়া ফল ধরা ছোঁয়ার বাইরে থাকে। বিশেষ করে এই পর্বের মতো ছাঁচে-গড়া ডেটা থেকে পাওয়া ০.৯৬ দেখে কখনোই ধরে নেবেন না আসল বার্তায় একই হবে।
মূল উপলব্ধি: ভাষার কাঠামোর সমস্যা model-এ অনেকটা সামলানো যায়, ডেটার ঘাটতি নয়। আগে নির্দেশিকা আর একটি ছোট, সৎ test set বানান, তারপর model নিয়ে তর্ক করুন।
প্র ০৫গ্রাহকের বার্তা থেকে নাম-ফোন-ঠিকানা মুছতে NER ব্যবহার করলে কী কী ঝুঁকি থাকে?
Redaction, অর্থাৎ লগ বা বিশ্লেষণের আগে ব্যক্তিগত তথ্য মুছে ফেলা, NER-এর একটি প্রচলিত ব্যবহার। কিন্তু এখানে ভুলের খরচ অসমান। একটি সাধারণ শব্দ ভুল করে মুছলে বিশ্লেষণে সামান্য ক্ষতি; একটি ফোন নম্বর রয়ে গেলে একজন মানুষের ক্ষতি। তাই এখানে precision-এর চেয়ে recall অনেক গুরুত্বপূর্ণ, আর একটি গড় F1 যথেষ্ট নয়। Threshold বা CRF-এর marginal দিয়ে মডেলকে বেশি সন্দেহপ্রবণ করা যায়, যদিও তখন বেশি শব্দ মুছবে।
আর ৯৮% recall শুনতে ভালো হলেও মানে হলো প্রতি ১০০টি নামের ২টি রয়ে যায়; লাখো বার্তায় সেটি বড় সংখ্যা। অতএব নিয়মভিত্তিক স্তর (ফোন নম্বরের regex), মডেল আর স্যাম্পলে মানুষের যাচাই একসঙ্গে দরকার। মডেল বিরল নামের ওপর ও গ্রামীণ ঠিকানায় বেশি ভুল করতে পারে; তাহলে সুরক্ষাও সবার জন্য সমান থাকে না। তাই ধরন আর গোষ্ঠী ধরে আলাদা করে মাপা দরকার।
দ্বিতীয় ঝুঁকি training ডেটাতেই। Model শেখাতে আসল নাম-ঠিকানা লাগলে সেই ফাইলটিই বড় ঝুঁকি: annotator, ব্যাকআপ, লগ। আর ভাষা মডেল training ডেটার অংশ মনে রাখতে পারে, এমন গবেষণাও প্রকাশিত হয়েছে। কাল্পনিক ডেটা নিরাপদ, কিন্তু এই পর্বের মতোই তার ফল আসল ডেটায় খাটে না। মাঝের পথ হলো আসল ছাঁচের ওপর নকল নাম বসানো, নিয়ন্ত্রিত পরিবেশে সামান্য আসল ডেটায় যাচাই, আর আইন ও কোম্পানির নীতি মেনে চলা (আইনের প্রশ্নে যোগ্য পরামর্শকের কথা শুনুন)।
মূল উপলব্ধি: NER-কে গোপনীয়তার নিশ্চয়তা ভাববেন না; এটি একটি ঝুঁকি-কমানোর স্তর। Metric বাছুন ক্ষতি দেখে, আর ডেটা নিজেই যে ঝুঁকি, সেটি ভুলবেন না।
অনুশীলন
খাতা-কলম নিন। প্রথম দুটি পরীক্ষার ধরনের হিসাব।
-
Viterbi হাতে (সংখ্যার কাজ): দুটি tag X, Y; তিনটি token. E = [[2, 1], [0, 3], [1, 1]] (সারিতে token, কলামে X, Y). A(X→X) = 1, A(X→Y) = −1, A(Y→X) = 0, A(Y→Y) = 2. শুরুর score ০. প্রতিটি δ, back pointer আর সেরা পথ বের করুন। প্রতি token-এর argmax-এর সঙ্গে তুলনা করুন।
δ₁ = (2, 1).
δ₂(X) = max(2 + 1, 1 + 0) + 0 = 3, back X. δ₂(Y) = max(2 − 1, 1 + 2) + 3 = 6, back Y.
δ₃(X) = max(3 + 1, 6 + 0) + 1 = 7, back Y. δ₃(Y) = max(3 − 1, 6 + 2) + 1 = 9, back Y.
শেষে সেরা 9, Y. পেছনে Y, তারপর Y. সেরা পথ Y Y Y, score 9. Argmax: প্রথমে X (2 > 1), তারপর Y, শেষে টাই। প্রথম token-এ X-এর E বড় হলেও Y → Y-এর +2 সিদ্ধান্ত বদলেছে।
-
BIO আর span F1 হাতে: বাক্য: “সিলেটে নীল ছাতা ৮০০ টাকা”. (ক) সঠিক tag লিখুন, জেলা, পণ্য, দাম ধরে। (খ) মডেল দিল
B-DIST O B-PROD B-PRICE O. Token accuracy আর strict span F1 কত?(ক)
B-DIST B-PROD I-PROD B-PRICE I-PRICE.(খ) মিলেছে শুধু ১ম আর ৪র্থ tag, তাই accuracy ২/৫ = ০.৪০. Span: সঠিক (DIST, ০, ১), (PROD, ১, ৩), (PRICE, ৩, ৫). মডেল (DIST, ০, ১), (PROD, ২, ৩), (PRICE, ৩, ৪). TP = ১, FP = ২, FN = ২. F1 = ২/(২ + ২ + ২) = ০.৩৩.
-
Output অনুমান করুন: অংশ ২-এর
get_spans-এ["I-PROD", "B-PROD", "I-DIST", "O"]দিলেstrict=Trueআরstrict=False-এ কী ছাপবে? চালিয়ে মিলিয়ে নিন।Strict:
[('PROD', 1, 2)]. প্রথমI-PRODশুরু করতে পারে না;I-DISTধরন না মেলায় পণ্য বন্ধ করে, কিন্তু নিজে শুরু করে না।Lenient:
[('PROD', 0, 1), ('PROD', 1, 2), ('DIST', 2, 3)]. প্রতিটি মেলে-নাI-নতুন টুকরো শুরু করে। একটি ক্রমে ১টি বনাম ৩টি span. -
নকশা ও সমালোচনা: একটি দল জানাল: “বাজারঘরের অর্ডার-NER-এ ৯৯% accuracy; পণ্যের নামগুলো train আর test-এ একই; একটি run; ব্যবহার করব অটো-ডেলিভারিতে।” কী কী প্রশ্ন করবেন, আর কী বদলাতে বলবেন?
Metric: “সব O”-এর accuracy কত? Strict span F1 প্রতি ধরনে চাই, বিশেষ করে জেলা আর দামে। ডেটা: অজানা নামের test set আর আসল (অনুমোদিত, গোপনীয়তা-রক্ষিত) বার্তার একটি নমুনা চাই; ছাঁচ-ভিত্তিক হলে বলতে হবে। ঝুঁকি: ভুল জেলায় পার্সেল গেলে খরচ কত? কম আত্মবিশ্বাসে মানুষের কাছে পাঠানোর পথ রাখুন। বিশ্বাসযোগ্যতা: কয়েকটি seed, seqeval-এর কোন mode, baseline-এর পাশে। শেষে ভুলের ধরন ধরে পরের পরিশ্রম ঠিক করুন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমBiLSTM-CRF-এ একটি বাক্যের সেরা tag-পথ বের করার ধাপগুলো সাজান (▲▼ চেপে সরান)
- BiLSTM প্রতি token-এর emission score দেয়
- প্রথম token-এ δ = শুরুর score + emission
- পরের প্রতি tag-এ: আগের δ + transition-এর সর্বোচ্চ, তাতে emission যোগ
- প্রতিটি সর্বোচ্চের উৎস back pointer-এ রাখা
- শেষ token-এ সবচেয়ে বড় δ বেছে নেওয়া
- Back pointer ধরে পেছনে হেঁটে পুরো পথ বের করা
৪ ধরনের entity-তে BIO scheme-এ tag কতটি, আর BIOES-এ কতটি?
- ৮ আর ১২
- ৯ আর ১৭
- ৯ আর ১৩
- ৫ আর ৯
Per-token softmax tagger কেন B-PROD ছাড়া I-PROD দিতে পারে?
- BiLSTM বাক্যের আগের শব্দ দেখতে পারে না
- Tag-এর সংখ্যা বেশি হলেই এমন হয়
- x দেখার পরে tag-গুলো পরস্পর স্বাধীন, পাশের tag-এর পদ loss-এ নেই
- ignore_index ভুল মান পেয়েছে
Linear-chain CRF-এ Viterbi আর forward algorithm-এর মূল পার্থক্য কী?
- Viterbi নেয় max, forward নেয় log-sum-exp
- Viterbi সব K^T পথ গোনে, forward গোনে না
- শুধু forward transition score ব্যবহার করে
- Viterbi train-এ লাগে, forward শুধু test-এ
৮৮টি token-এর ৮১টি ঠিক, আর ১১টি আসল span-এর জন্য TP = 4, FP = 6, FN = 7. Token accuracy আর span F1 কত?
- ০.৯২ আর ০.৪০
- ০.৯২ আর ০.৩৬
- ০.৭৬ আর ০.৩৮
- ০.৯২ আর ০.৩৮
৩টি tag আর ৩টি token-এর CRF-এ একটি পথের score(y*) = 5.6, আর log Z = 6.4841. NLL কত?
- −0.8841
- 0.8841
- 12.0841
- 0.4131
seqeval-এর default f1_score দিয়ে দুটি NER মডেলের তুলনার সময় কোন বিষয়টি সবচেয়ে সতর্ক থাকার মতো?
- এটি শুধু token accuracy মাপে
- এটি একটিমাত্র entity ধরন মাপতে পারে
- Default lenient, B- ছাড়া I- থেকেও span ধরতে পারে
- এটি বাংলা শব্দ থাকলে কাজ করে না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Sequence labeling-এ T-টি token, T-টি label; span-কে আনা হয় BIO-তে, K = 2E + 1.
- Softmax প্রতি tag আলাদা বাছে; CRF পুরো পথকে score দেয়: E + A, আর P(y | x) = exp(score)/Z.
- Viterbi max নেয়, forward log-sum-exp নেয়; দুটিরই খরচ O(T·K²). Loss = log Z − score(y*).
- NER মাপুন strict span F1-এ, প্রতি ধরনে; token accuracy আর seqeval-এর default নিয়ে সতর্ক থাকুন।
- CRF-এর লাভ ছোট হতে পারে; কয়েকটি seed-এ baseline-এর পাশে মাপুন, কোনো আসল মানুষের ডেটা ছাড়া।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৪ · Contextual embedding — ELMo থেকে BERTপরবর্তী পর্বএকই শব্দের ভিন্ন অর্থে ভিন্ন vector, আর তা মেপে দেখা।
- ডিপ লার্নিং, NLP আর Generative AI · কোর্স হোম৯১টি পর্বপরের পর্ব ৪৪: Contextual embedding — ELMo থেকে BERT.
- পর্ব ২৯ · LSTM আর GRUফিরে দেখুনBiLSTM-এর যন্ত্র আর PackedSequence.
- AI-এর ভিত্তি · পর্ব ৫৩ · Markov chain আর HMMপূর্বশর্তHMM-এ Viterbi আর forward-এর প্রথম দেখা।
- মেশিন লার্নিং · পর্ব ৫ · Label আর ডেটার মানপাশের ট্র্যাকAnnotation, noise আর agreement.
- DSA · Dynamic programming-এর ভিত্তিপাশের ট্র্যাকViterbi যে কৌশলের ওপর দাঁড়িয়ে।