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

NLP মূল্যায়ন — F1, BLEU, ROUGE, chrF আর মানুষের বিচার

NLP Evaluation — F1, BLEU, ROUGE, chrF, Bootstrap Confidence Intervals and Human Judgement

Taught in Bangla, this lesson matches metrics to tasks and computes BLEU with clipped precision and brevity penalty, chrF and ROUGE by hand and in in-page Python. It checks the code against sacrebleu and rouge-score in a real desktop run, shows why BLEU punishes Bangla inflections that chrF credits, exposes rouge-score's default tokenizer dropping Bangla text, and adds bootstrap confidence intervals and kappa for human ratings.

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

  • কোন NLP task-এ কোন metric, আর প্রতিটির ফাঁদ
  • BLEU, chrF আর ROUGE হাতে হিসাব করা ও নিজে কোডে লেখা
  • নিজের হিসাব sacrebleu আর rouge-score-এর সঙ্গে মেলানো, পার্থক্যের কারণ বের করা
  • দুটি system-এর তুলনায় bootstrap CI আর মানুষের মূল্যায়নে kappa
  • বাংলা reference, উপভাষা আর Unicode কীভাবে নম্বর বদলে দেয়

১ · পরীক্ষার খাতা দেখার নিয়ম: task অনুযায়ী metric

বাংলা রচনার খাতা আর গণিতের খাতা একই নিয়মে দেখা হয় না। গণিতে উত্তর মিললেই নম্বর। রচনায় একই কথা হাজারভাবে লেখা যায়, তাই পরীক্ষক অর্থ আর ভাষা দুটোই দেখেন। NLP-তেও তাই। কোনো task-এর উত্তর একটিই হলে হিসাব সহজ। অনুবাদ বা সারাংশে সঠিক উত্তর অনেক, আর আমাদের হাতে থাকে মাত্র একটি বা দুটি নমুনা উত্তর, যাকে বলে reference.

পর্ব ৩৯ (NLP-এর পথ)-এ প্রতিটি task-কে input থেকে output হিসেবে সাজানো হয়েছে। এখানে দেখব তাদের নম্বর দেওয়ার নিয়ম:

Taskপ্রধান metricপ্রধান ফাঁদ
Classificationmacro-F1, প্রতি class-এর F1Accuracy বড় class-এ লুকিয়ে থাকে
NER, sequence labelingspan F1Token accuracy ভুল সীমা ধরে না
অনুবাদchrF, BLEU, TERTokenizer, একটিমাত্র reference
সারাংশROUGE, মানুষের বিচারকপি করলেই recall বাড়ে; তথ্য ভুল ধরে না
Character-স্তরের কাজCER (পর্ব ৩০)Grapheme আর code point এক নয়
LLM-এর উত্তরপর্ব ৫৮ (LLM মূল্যায়ন)-এBenchmark মুখস্থ, বিচারকের পক্ষপাত
মূল ধারণা

Metric হলো আসল লক্ষ্যের একটি সস্তা প্রতিনিধি। আসল প্রশ্ন “ব্যবহারকারীর কাজ হলো কি না”। সেটি প্রতিবার মাপা ব্যয়বহুল, তাই আমরা একটি সূত্র ব্যবহার করি। সূত্রটি কোথায় সত্যের কাছে যায়, আর কোথায় দূরে সরে, এই পর্ব সেটিই দেখায়।

২ · F1-এর তিন রূপ: macro, micro আর span

Precision, recall আর F1-এর সংজ্ঞা AI-এর ভিত্তি কোর্সের পর্ব ৬০-এ আছে। অনেক class-এর গড় নেওয়ার আলোচনা মেশিন লার্নিং কোর্সের পর্ব ২৫-এ। এখানে শুধু NLP-র জন্য জরুরি হিসাবটি।

বাজারঘরের ১০০টি রিভিউ (বানানো সংখ্যা): ৮০টি ভালো, ১৫টি খারাপ, ৫টি মিশ্র। একটি model-এর ফল:

ClassTPFPFNF1 = 2TP / (2TP + FP + FN)
ভালো৭৮১০২১৫৬ / ১৬৮ = ০.৯২৯
খারাপ৮৪৭১৬ / ২৭ = ০.৫৯৩
মিশ্র০০৫০
  • Macro-F1: প্রতিটি class-এর F1-এর সাধারণ গড়, (০.৯২৯ + ০.৫৯৩ + ০) / ৩ = ০.৫০৭. ছোট class-ও সমান ভোট পায়।
  • Micro-F1: আগে সব TP, FP, FN যোগ, তারপর F1: ২×৮৬ / (২×৮৬ + ১৪ + ১৪) = ০.৮৬. প্রতিটি রিভিউতে ঠিক একটি label থাকলে এটি accuracy-র সমান।

০.৮৬ দেখে মনে হয় model ভালো। অথচ মিশ্র রিভিউ সে একটিও চেনে না। ০.৫০৭ সেই ঘাটতি দেখায়। পর্ব ৪২ (Deep learning দিয়ে text classification)-এ এই হিসাবই আসল ডেটায় চলে।

Span F1: শব্দ নয়, পুরো টুকরো মিলতে হবে

বিস্তারিত পর্ব ৪৩ (Sequence labeling)-এ। মূল কথা একটি বাক্যেই। বাক্য: “রাজশাহী থেকে চাল ৫০০ টাকায় অর্ডার করলাম আজ”, মোট ৮টি token. সঠিক span ৩টি: রাজশাহী (জেলা), চাল (পণ্য), ৫০০ টাকায় (দাম)। Model দিল: রাজশাহী (জেলা), চাল (জেলা), ৫০০ (দাম)।

  • Token accuracy: “চাল” আর “টাকায়” ভুল, বাকি ৬টি ঠিক, তাই ৬/৮ = ৭৫%.
  • Span F1: সীমা আর ধরন দুটোই মিলেছে শুধু রাজশাহীর। P = ১/৩, R = ১/৩, তাই F1 = ০.৩৩.

৭৫% শুনতে ভালো, কিন্তু অর্ডারের তথ্য তোলার কাজে তিনটি তথ্যের দুটিই ভুল। বেশিরভাগ token আসলে কোনো entity-র বাইরে, তাই token accuracy সহজে বড় দেখায়।

৩ · BLEU: clipped precision আর brevity penalty

২০০২ সালে Papineni ও সহকর্মীরা BLEU প্রস্তাব করেন। ধারণাটি সরল: মানুষের অনুবাদের সঙ্গে যন্ত্রের অনুবাদের যত বেশি শব্দের টুকরো (n-gram) মেলে, তত ভালো। কিন্তু সরল হিসাবে দুটি ফুটো আছে। একটি শব্দ বারবার লিখলে “মিল” বাড়ে। আর খুব ছোট বাক্য লিখলে শতভাগ precision মেলে। দুটি ফুটো বন্ধ করার দুটি কৌশল এই পর্বের প্রথম stepper দুটিতে।

বাজারের ফর্দে লেখা আছে: ১ কেজি চাল। দোকানি ৩ কেজি চাল দিলেও আপনি ১ কেজির দামই দেবেন। ফর্দে যতটুকু আছে, তার বেশি গোনা হবে না। এটাই clipping.

Candidate: খুব খুব খুব ভালো পণ্য · Reference: এই পণ্য খুব ভালো
ধাপ
গণনা
Precision
  1. সরল হিসাব প্রতারিত হয়। Candidate-এর ৫টি শব্দই reference-এ কোথাও আছে, তাই precision ১.০০. “খুব খুব খুব” অনুবাদ হিসেবে ভালো নয়, তবু পূর্ণ নম্বর।
  2. Candidate গোনা। কোন শব্দ কতবার এসেছে: খুব ৩, ভালো ১, পণ্য ১।
  3. Reference গোনা। Reference-এ “খুব” মাত্র ১ বার আছে। এটিই ফর্দের সীমা।
  4. Clip করা। প্রতিটি শব্দের গণনা ফর্দের সীমায় আটকে দিই: ১ + ১ + ১ = ৩। ভাগ করি candidate-এর মোট ৫ দিয়ে। Clipped precision ০.৬০.
  5. একাধিক reference. দ্বিতীয় reference-এ “খুব” ২ বার। সীমা হয় সব reference-এর সর্বোচ্চ, মানে ২। এবার ৪/৫ = ০.৮০. Reference বাড়লে স্বাভাবিক ভিন্নতা বেশি ধরা পড়ে।

Clipping precision-এর ফাঁক বন্ধ করে। কিন্তু খুব ছোট বাক্যের ফাঁক খোলাই থাকে, কারণ precision-এর হরে candidate-এর নিজের দৈর্ঘ্য। তাই আসে brevity penalty (BP):

Reference ১০ শব্দ · Candidate: আজ সকালে ঢাকার বাজারে নতুন চাল
ধাপ
হিসাব
BLEU
  1. নিখুঁত precision. Candidate-টি reference-এর প্রথম ৬টি শব্দ হুবহু। চার রকম n-gram-এ সব মিল। BP না থাকলে BLEU ১০০, অথচ দাম কমার খবরটিই নেই।
  2. দৈর্ঘ্য তুলনা। c হলো candidate-এর দৈর্ঘ্য, r হলো reference-এর। এখানে ৬ আর ১০।
  3. সূচক। ১ − r/c = −০.৬৬৭. ঋণাত্মক সূচক মানে শাস্তি আছে।
  4. BP আর ফল। e−০.৬৬৭ = ০.৫১৩, তাই BLEU = ৫১.৩৪. ৪০% ছোট হওয়ায় নম্বর প্রায় অর্ধেক।
  5. বড় হলে। c ≥ r হলে BP = ১. বাড়তি শব্দের শাস্তি precision-ই দেয়। Corpus-এ c আর r পুরো corpus-এর যোগফল, প্রতি বাক্যের আলাদা BP নয়।

BLEU = BP × exp( (১/N) ∑n=1..N log pn )

এখানে pn হলো n-gram-এর clipped precision, আর N সাধারণত ৪। Log-এর গড় নিয়ে exp করা মানে গুণোত্তর গড়। তাই কোনো একটি pn শূন্য হলে পুরো BLEU শূন্য। এবার নিজে চালান:

import math
from collections import Counter
def grams(toks, n):
    return Counter(tuple(toks[i:i + n]) for i in range(len(toks) - n + 1))
def bleu(cand, refs, N=4):
    c, rs = cand.split(), [r.split() for r in refs]
    ps = []
    for n in range(1, N + 1):
        best = Counter()
        for r in rs:
            best |= grams(r, n)               # প্রতিটি n-gram-এর সর্বোচ্চ গণনা
        got = grams(c, n)
        ps.append((sum((got & best).values()), sum(got.values())))  # & = clip
    r_len = min((len(r) for r in rs), key=lambda L: (abs(L - len(c)), L))
    bp = 1.0 if len(c) >= r_len else math.exp(1 - r_len / len(c))
    if min(m for m, _ in ps) == 0:
        return ps, bp, 0.0
    logp = sum(math.log(m / t) for m, t in ps) / N
    return ps, bp, 100 * bp * math.exp(logp)
ref = "আজ সকালে ঢাকার বাজারে নতুন চাল এসেছে বলে দাম কমেছে"
tests = {"হুবহু": ref,
         "বাজারের": ref.replace("বাজারে", "বাজারের"),
         "দোকানে": ref.replace("বাজারে", "দোকানে"),
         "ছোট": "আজ সকালে ঢাকার বাজারে নতুন চাল",
         "চাল×৬": "চাল চাল চাল চাল চাল চাল"}
for name, h in tests.items():
    ps, bp, s = bleu(h, [ref])
    print(f"{name:8}", " ".join(f"{m}/{t}" for m, t in ps), f"BP={bp:.3f} BLEU={s:.2f}")
ps, bp, s = bleu("খুব খুব খুব ভালো পণ্য", ["এই পণ্য খুব ভালো"], N=1)
print("clip, এক reference:", ps[0])
ps, bp, s = bleu("খুব খুব খুব ভালো পণ্য", ["এই পণ্য খুব ভালো", "খুব খুব ভালো পণ্য"], N=1)
print("clip, দুই reference:", ps[0])

দুটি লাইন একসঙ্গে দেখুন: বাজারের 9/10 7/9 5/8 3/7 BLEU=65.80 আর দোকানে 9/10 7/9 5/8 3/7 BLEU=65.80. শব্দের রূপ সামান্য বদলানো আর পুরো অন্য শব্দ বসানো, BLEU-র কাছে দুটোই একই। চাল×৬-এ unigram মিল clip হয়ে 1/6, আর উচ্চতর n-gram-এ মিল নেই, তাই BLEU শূন্য। (3, 5) আর (4, 5) stepper-এর হাতের হিসাব মিলিয়ে দেয়।

৪ · বাংলায় BLEU কেন কষ্ট পায়, আর chrF কী করে

বাংলায় একটি শব্দের অনেক রূপ: বাজার, বাজারে, বাজারের, বাজারটি, বাজারগুলোতে। পর্ব ৩৯-এ দেখেছি বিভক্তি আর প্রত্যয় মিলে এই বৈচিত্র্য তৈরি করে। ইংরেজিতে অনেক সময় বিভক্তির কাজ করে আলাদা শব্দ (“in the market”), ফলে একটি ভুলে মাত্র একটি শব্দ নষ্ট হয়। বাংলায় সব একই শব্দের গায়ে লেগে থাকে। BLEU-তে শব্দটি মিলল কি মিলল না, মাঝামাঝি কিছু নেই। আর একটি শব্দের ভুল আশপাশের সব n-gram নষ্ট করে।

১০ শব্দের বাক্যের ৪ নম্বর শব্দ: বাজারে → বাজারের
ধাপ
নষ্ট
pn
ফল
  1. Unigram. শুধু ১টি শব্দ অমিল। ৯/১০, এখনো ভালো দেখায়।
  2. Bigram. শব্দটি ২টি পাশাপাশি জোড়ায় আছে, দুটোই গেল। ৭/৯.
  3. Trigram. ৩টি টুকরো গেল। ৫/৮.
  4. 4-gram. ৪টি টুকরো গেল। ৩/৭. একটি ছোট বিভক্তি ৪-gram-এর অর্ধেকের বেশি শেষ করেছে।
  5. গড়। গুণোত্তর গড় ০.৬৫৮, তাই BLEU ৬৫.৮০. অর্থ প্রায় একই, ব্যাকরণে সামান্য ভুল, তবু এক ধাক্কায় ৩৪ নম্বর গেল।
  6. ভুল ছড়ালে। §৬-এর System A-তে ৬টি বাক্যে ছড়ানো বিভক্তির ভুল। 4-gram precision ১.৪%, BLEU ১২.৭০. chrF তখনো ৭৭.১৩.

chrF: শব্দের বদলে অক্ষরের টুকরো

chrF (Maja Popović, ২০১৫) শব্দ ভেঙে অক্ষরের n-gram গোনে। সাধারণত n = ১ থেকে ৬। প্রতিটি n-এ precision আর recall বের করে তাদের গড় নেয়। শেষে F-score:

chrFβ = (১ + β²) · P · R / (β² · P + R)

P আর R হলো ছয় রকম n-এর গড় precision আর recall. β = ২ মানে recall-কে বেশি গুরুত্ব, অর্থাৎ reference-এর তথ্য হারানোর শাস্তি বেশি। নিচের ছবিতে মূল পার্থক্য:

একই ভুল, দুই রকম চোখে

Word n-grams see a miss; character n-grams see a near match

Word-level miss versus character-level overlap Word level (BLEU) ঢাকার বাজারে নতুন চাল ঢাকার বাজারের নতুন চাল মিল নেই · 2 bigram gone Character level (chrF) বাজারে বাজারের দোকানে reference word word chrF 93.26 word chrF 5.56 Same BLEU in a sentence: 65.80 and 65.80
ছবি ৪৫.১ — BLEU-র কাছে দুটি ভুল এক; chrF একটিকে প্রায় ঠিক, অন্যটিকে প্রায় পুরো ভুল ধরে।
from collections import Counter

def cgrams(s, n):
    s = s.replace(" ", "")                    # sacrebleu-র মতো ফাঁকা বাদ
    return Counter(s[i:i + n] for i in range(len(s) - n + 1))

def chrf(cand, ref, N=6, beta=2, detail=False):
    Ps, Rs = [], []
    for n in range(1, N + 1):
        gc, gr = cgrams(cand, n), cgrams(ref, n)
        if not gc or not gr:                  # এত লম্বা n-gram নেই
            continue
        m = sum((gc & gr).values())
        Ps.append(m / sum(gc.values()))
        Rs.append(m / sum(gr.values()))
    P, R = sum(Ps) / len(Ps), sum(Rs) / len(Rs)
    F = (1 + beta**2) * P * R / (beta**2 * P + R) if P + R else 0.0
    return (Ps, Rs, 100 * F) if detail else 100 * F

print("code point:", list("বাজারে"), "বনাম", len("বাজারের"), "টি")
print("শব্দ দুটির chrF:", round(chrf("বাজারের", "বাজারে"), 2),
      "| বাজারে-দোকানে:", round(chrf("দোকানে", "বাজারে"), 2))

ref = "আজ সকালে ঢাকার বাজারে নতুন চাল এসেছে বলে দাম কমেছে"
for w in ("বাজারের", "দোকানে"):
    Ps, Rs, F = chrf(ref.replace("বাজারে", w), ref, detail=True)
    print(f"{w:8} P:", " ".join(f"{p:.2f}" for p in Ps), f"chrF={F:.2f}")

neg = ref.replace("কমেছে", "কমেনি")           # অর্থ উল্টো
print("কমেনি (অর্থ উল্টো):", round(chrf(neg, ref), 2))
print("beta=1 হলে বাজারের:", round(chrf(ref.replace("বাজারে", "বাজারের"), ref, beta=1), 2))

একই বাক্যে বাজারের chrF=92.81 আর দোকানে chrF=80.69. BLEU দুটিকে এক নম্বর দিয়েছিল, chrF দেয়নি। এবার সতর্কতার লাইনটি: কমেনি (অর্থ উল্টো): 94.79. দাম কমল আর দাম কমেনি, অর্থ পুরো উল্টো, তবু নম্বর বাজারের-এর চেয়েও বেশি। chrF morphology-কে সদয়ভাবে দেখে, অর্থ বোঝে না। এটিও খেয়াল করুন: এখানে “অক্ষর” মানে Unicode code point, grapheme নয় ('ে' আলাদা একটি উপাদান)। Unicode-এর ভিত্তি AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এ আছে।

আরেকটি নাম জেনে রাখুন: TER (Translation Edit Rate; Snover ও সহকর্মী, ২০০৬)। Reference-এ পৌঁছাতে কতগুলো শব্দ বদলানো, মুছা, যোগ করা বা সরানো লাগে, তাকে reference-এর শব্দসংখ্যা দিয়ে ভাগ করা। এটি কম হলে ভালো। §৫-এ এর আসল মানও দেখবেন।

৫ · সত্যিকারের run: sacrebleu-র সঙ্গে মেলানো

নিজের কোড সঠিক কি না, তার পরীক্ষা হলো প্রতিষ্ঠিত library-র সঙ্গে মেলানো। ডেস্কটপে CPU-তে চালিয়েছি (লেখার সময়ে Python 3.13.5, sacrebleu 2.6.0, rouge-score 0.1.2; version বদলাতে পারে)। নিচের কোড আসল script-এর সংক্ষিপ্ত রূপ। “নিজের” কলাম আসে পাতার ব্লকের একই হিসাবের একটি ছোট module থেকে। আউটপুট হুবহু।

cd ~/bazarghar-dl
uv add sacrebleu rouge-score
from sacrebleu.metrics import BLEU, CHRF, TER
raw = BLEU(tokenize="none", smooth_method="none")  # tokenizer ও smoothing বন্ধ
print(raw.corpus_score([h], [[ref]]).score, CHRF().corpus_score([h], [[ref]]).score,
      TER().corpus_score([h], [[ref]]).score)
for t in ("none", "13a", "intl", "char"):           # tokenizer বদলে
    print(t, BLEU(tokenize=t, smooth_method="none").corpus_score([h], [[r]]).score)
print(BLEU(tokenize="none").corpus_score([h4], [[r4]]))   # default smoothing: exp
sacrebleu 2.6.0
candidate  নিজের-BLEU sacre-BLEU  নিজের-chrF sacre-chrF  TER
বাজারের       65.80      65.80      92.81      92.81  10.0
দোকানে        65.80      65.80      80.69      80.69  10.0
ছোট           51.34      51.34      63.66      63.66  40.0

-- ১. tokenizer: দাঁড়ি লেগে থাকলে --
none  BLEU  50.81  tokens: 5
13a   BLEU  50.81  tokens: 5
intl  BLEU 100.00  tokens: 6
char  BLEU 100.00  tokens: 31

-- ২. smoothing: 4-gram মিল শূন্য --
নিজের   0.0
none    BLEU = 0.00 42.9/16.7/0.0/0.0 (BP = 1.000 ratio = 1.167 hyp_len = 7 ref_len = 6)
exp     BLEU = 14.54 42.9/16.7/10.0/6.2 (BP = 1.000 ratio = 1.167 hyp_len = 7 ref_len = 6)
সই      nrefs:1|case:mixed|eff:no|tok:none|smooth:exp|version:2.6.0

-- ৩. Unicode: চোখে এক, code point-এ আলাদা --
একই লেখা? False | বাড়ি-র code point: 6 বনাম 7
NFC ছাড়া BLEU 53.73 chrF 82.22
দুটোয় NFC BLEU 100.0

ছাঁকা হিসাবে সব কলাম দুই দশমিক পর্যন্ত মিলেছে। তাহলে “BLEU” নম্বর আলাদা হয় কোথায়? আউটপুটের বাকি তিনটি অংশে তিনটি কারণ:

  • Tokenizer. Candidate-এ দাঁড়ির আগে ফাঁকা (পৌঁছাবে ।), reference-এ লাগানো (পৌঁছাবে।). পড়তে একই বাক্য। sacrebleu-র default tokenizer 13a ASCII যতিচিহ্ন আলাদা করে, কিন্তু দাঁড়ি নয়। তাই ৫০.৮১. intl Unicode যতিচিহ্নও আলাদা করে, তাই ১০০. একই বাক্যে শুধু tokenizer বদলে প্রায় ৫০ নম্বরের ফারাক। বাংলার জন্য intl বা নিজের পরীক্ষিত tokenizer ঠিক করে নিন, আর সব system-এ একই রাখুন।
  • Smoothing. আমাদের কোডে ৩-gram আর ৪-gram-এ মিল শূন্য, তাই BLEU ০। sacrebleu-র default exp শূন্যের জায়গায় ছোট মান বসায়: প্রথম শূন্যে ১০০/(২×৫) = ১০.০, পরেরটিতে ১০০/(৪×৪) = ৬.২৫. ফল ১৪.৫৪. কোনোটিই ভুল নয়, তবে দুটি এক metric নয়।
  • Unicode. “বাড়ি”-র “ড়” একক code point (U+09DC) হতে পারে, আবার ড + ় দুটিও হতে পারে। চোখে এক, অথচ NFC ছাড়া BLEU ৫৩.৭৩. NFC করলে ১০০. sacrebleu নিজে থেকে এটি করেনি, এই পরীক্ষাই তার প্রমাণ। নিয়মটি পর্ব ১১-এর মতোই: মেলানোর আগে দুই পাশে একই normalisation.

BLEU ৩৪ আর BLEU ৩১ তুলনা করার আগে দেখুন সই লাইনটি (get_signature()): কত reference, কোন tokenizer, কোন smoothing, কোন version. Matt Post ২০১৮ সালে “A Call for Clarity in Reporting BLEU Scores” প্রবন্ধে দেখান, এই সেটিং অনুল্লিখিত থাকলে প্রকাশিত নম্বর তুলনা করা যায় না। আর sentence-level BLEU গড় করে corpus BLEU বলাও ভুল: corpus BLEU গণনাগুলো আগে যোগ করে।

৬ · দুটি system, দুই metric, উল্টো রায়

এবার ইচ্ছা করে বানানো ৬টি বাজারঘর বাক্যে সত্যিকারের পরীক্ষা। System A-র অর্থ ঠিক, কিন্তু বিভক্তির ভুল ছড়ানো। System B-র লম্বা অংশ হুবহু, কিন্তু মূল তথ্য ভুল। বাক্যগুলো বানানো, তাই ফলাফল কোনো আসল system সম্পর্কে কিছু বলে না; এটি শুধু metric-এর আচরণ দেখায়।

৪ নম্বর বাক্য
Referenceভাঙা পণ্য ফেরত দিলে সাত দিনের মধ্যে টাকা ফেরত পাবেন
System Aভাঙা পণ্যের ফেরত দিলে সাত দিনে মধ্যে টাকার ফেরত পাবেন
System Bভাঙা পণ্য ফেরত দিলে কোনো টাকা পাবেন না
system A: BLEU 12.70  precisions [63.0, 31.2, 9.5, 1.4]  chrF2 77.13  TER  37.0
system B: BLEU 49.02  precisions [69.4, 58.1, 51.4, 41.9]  chrF2 62.81  TER  38.9
system A: ROUGE-1 F 0.628  ROUGE-2 F 0.312
system B: ROUGE-1 F 0.660  ROUGE-2 F 0.551

BLEU বলছে B প্রায় চার গুণ ভালো। chrF বলছে A এগিয়ে। TER প্রায় সমান। আর ৪ নম্বর বাক্যটি পড়লে আপনি নিজেই দেখবেন, B গ্রাহককে উল্টো কথা বলছে: টাকা ফেরত পাবেন “না”। A ব্যাকরণে কাঁচা, কিন্তু গ্রাহক বুঝবেন।

এর মানে “chrF সবসময় ঠিক” নয়। §৪-এ দেখেছি, “কমেনি”-র মতো অর্থ-উল্টানো ভুলে chrF-ও উদার। একটি বড় গবেষণা (Kocmi ও সহকর্মী, ২০২১, “To Ship or Not to Ship”) মানুষের বিচারকে মানদণ্ড ধরে শুধু BLEU-র ওপর নির্ভর করতে নিষেধ করে, COMET-কে প্রধান আর chrF-কে সহায়ক বা বিকল্প হিসেবে সুপারিশ করে। সেই ফল সব ভাষায় সমানভাবে খাটে, এমন দাবি বাংলার জন্য আমি যাচাই করিনি।

BERTScore-এর ধারণা: শব্দ হুবহু না মিললেও অর্থে কাছাকাছি কি না, তা মাপার চেষ্টা। Reference আর candidate-এর প্রতিটি token-এর contextual embedding নিয়ে (পর্ব ৪৪, Contextual embedding) cosine দিয়ে সবচেয়ে কাছের মিল খোঁজা হয়, তা থেকে P, R, F (Zhang ও সহকর্মী, ২০২০)। এর মান নির্ভর করে কোন model দিয়ে মাপছেন তার ওপর, আর embedding-এ কাছাকাছি হওয়া মানেই অর্থ এক নয়: “কমেছে” আর “কমেনি”-র context প্রায় একই। এই পর্বে এটি চালাইনি, শুধু ধারণা দিলাম।

Goodhart: নম্বর যখন লক্ষ্য হয়ে যায়

মেশিন লার্নিং কোর্সের পর্ব ২-এ Goodhart-এর নিয়ম আছে: মাপ যখন লক্ষ্য হয়, তখন সেটি আর ভালো মাপ থাকে না। উপরের System B দেখায় কীভাবে: reference-এর লম্বা টুকরো কপি করলেই BLEU বাড়ে। আসল বিপদ আসে দলের মধ্যে: “এই সপ্তাহে BLEU ২ বাড়াতে হবে” লক্ষ্য হলে কেউ post-processing দিয়ে test reference-এর ধাঁচে লেখা মেলাবেন, বা test set দেখে tuning করবেন (AI-এর ভিত্তি কোর্সের পর্ব ৫৬)। প্রতিকার: একাধিক metric, আলাদা করে রাখা test set, আর নিয়মিত মানুষের নমুনা পড়া।

৭ · ROUGE: সারাংশের নম্বর, আর rouge-score-এর বাংলা ফাঁদ

ROUGE (Chin-Yew Lin, ২০০৪) সারাংশ মাপে। ROUGE-N-এ মডেলের সারাংশ আর মানুষের সারাংশের n-gram মিল গোনা হয়, মিল clip করে। সারাংশে হারানো তথ্য বড় বিষয়, তাই recall গুরুত্বপূর্ণ:

P = মিল / pred-এর n-gram, R = মিল / target-এর n-gram, F = 2PR / (P + R)

from collections import Counter

def grams(toks, n):
    return Counter(tuple(toks[i:i + n]) for i in range(len(toks) - n + 1))

def rouge(pred, target, n):
    gp, gt = grams(pred.split(), n), grams(target.split(), n)
    m = sum((gp & gt).values())
    P = m / max(sum(gp.values()), 1)
    R = m / max(sum(gt.values()), 1)
    F = 2 * P * R / (P + R) if P + R else 0.0
    return m, sum(gp.values()), sum(gt.values()), P, R, F

def show(label, pred, target):
    for n in (1, 2):
        m, a, b, P, R, F = rouge(pred, target, n)
        print(f"{label} ROUGE-{n}: মিল {m}, pred {a}, target {b}"
              f" → P={P:.3f} R={R:.3f} F={F:.3f}")

target = "ডেলিভারি দেরিতে এলেও চালের মান ভালো ছিল আর দাম কম ছিল"
pred = "চালের মান ভালো কিন্তু ডেলিভারি দেরিতে এসেছে"
show("ঠিক ক্রম ", pred, target)
show("উল্টো ক্রম", target, pred)              # target আর pred অদলবদল

source = target + " তবে প্যাকেট ছেঁড়া ছিল আর ফোনে কেউ ধরেনি আর দোকান দূরে আর রাস্তা খারাপ"
show("পুরো উৎস", source, target)              # সারাংশ নয়, পুরো লেখা কপি

হাতে মিলিয়ে নিন: মিল ৫টি শব্দ (চালের, মান, ভালো, ডেলিভারি, দেরিতে), pred ৭, target ১১, তাই P=0.714 R=0.455 F=0.556. উল্টো ক্রম-এ P আর R অদলবদল হলো, F একই থাকল। তাই শুধু F দেখলে এই ভুল ধরা পড়ে না। পুরো উৎস-এ R=1.000: পুরো লেখা কপি করলে recall পূর্ণ, F-ও ০.৫৫৬ থেকে বেড়ে 0.611. অর্থাৎ সারাংশ না লিখেই নম্বর বাড়ল! তাই ROUGE-এর সঙ্গে দৈর্ঘ্যের সীমাও দিন।

সত্যিকারের run: default tokenizer বাংলা ফেলে দেয়

Google-এর rouge-score package খুব প্রচলিত। নিচের কোড চালিয়েছি (sc._tokenizer শুধু ভেতরটা দেখার জন্য; এটি private, নিজের কাজে এর ওপর ভরসা করবেন না):

import re
from rouge_score import rouge_scorer
fmt = lambda r: {k: tuple(round(x, 3) for x in v) for k, v in r.items()}
target = "ডেলিভারি দেরিতে এলেও চালের মান ভালো ছিল আর দাম কম ছিল"
pred = "চালের মান ভালো কিন্তু ডেলিভারি দেরিতে এসেছে"
sc = rouge_scorer.RougeScorer(["rouge1", "rouge2"])   # default tokenizer
print("১. default:", fmt(sc.score(target, pred)))
print("   tokens :", sc._tokenizer.tokenize(target))
print("   নিজের সঙ্গে নিজে:", sc.score(target, target)["rouge1"].fmeasure)
t2 = "USB চার্জারটি দুই দিনেই নষ্ট হয়ে গেছে, টাকা ফেরত চাই"
p2 = "দারুণ USB চার্জার, খুব টেকসই, সবাইকে কিনতে বলব"
print("২. উল্টো অর্থ:", fmt(sc.score(t2, p2)))
print("   tokens :", sc._tokenizer.tokenize(t2), sc._tokenizer.tokenize(p2))
print("   অঙ্ক   :", sc._tokenizer.tokenize("দাম ৫০০ টাকা, ওজন 5 কেজি"))
print(r"৩. \w+ ফাঁদ:", re.findall(r"\w+", "বাজারের চাল"))
class BanglaTok:                                      # শুধু tokenize() লাগে
    def tokenize(self, text):
        return re.findall(r"[ঀ-৿‌‍a-zA-Z0-9]+", text)
print("৪. tokens :", BanglaTok().tokenize("বাজারের চাল ভালো। USB ৫০০"))
fix = rouge_scorer.RougeScorer(["rouge1", "rouge2"], tokenizer=BanglaTok())
print("   ঠিক    :", fmt(fix.score(target, pred)))
print("   উল্টো অর্থ:", fmt(fix.score(t2, p2)))
১. default: {'rouge1': (0.0, 0.0, 0.0), 'rouge2': (0.0, 0.0, 0.0)}
   tokens : []
   নিজের সঙ্গে নিজে: 0.0
২. উল্টো অর্থ: {'rouge1': (1.0, 1.0, 1.0), 'rouge2': (0.0, 0.0, 0.0)}
   tokens : ['usb'] ['usb']
   অঙ্ক   : ['5']
৩. \w+ ফাঁদ: ['ব', 'জ', 'র', 'র', 'চ', 'ল']
৪. tokens : ['বাজারের', 'চাল', 'ভালো', 'USB', '৫০০']
   ঠিক    : {'rouge1': (0.714, 0.455, 0.556), 'rouge2': (0.5, 0.3, 0.375)}
   উল্টো অর্থ: {'rouge1': (0.125, 0.1, 0.111), 'rouge2': (0.0, 0.0, 0.0)}

প্রতিটি অংশ আলাদা শিক্ষা:

  1. সব শূন্য, কোনো error নেই। Package-এর tokenize ছোট হাতের a-z0-9 ছাড়া সব token বাদ দেয় (source-এ নিয়মটি ^[a-z0-9]+$). বাংলা token তালিকা খালি। হুবহু একই লেখার নম্বরও ০.০.
  2. অদ্ভুত ১.০. “চার্জার নষ্ট” আর “দারুণ টেকসই”, উল্টো অর্থ। শুধু usb বেঁচে ছিল, দুই পাশেই। ROUGE-1 F = ১.০. বাংলা অঙ্ক ৫০০ বাদ, ইংরেজি 5 থাকল। মিশ্র লেখায় (Banglish, ব্র্যান্ডের নাম) তাই শূন্য নয়, ভুলভাবে উঁচু নম্বর আসে।
  3. আরেকটি ফাঁদ: \w+. Python-এর re-তে বাংলা কার (Mc, Mn শ্রেণির চিহ্ন) \w-এ পড়ে না। “বাজারের” ভেঙে হলো ব, জ, র, র। তাই “সহজ regex” দিয়েও ঠিক হয় না।
  4. ঠিক করা। tokenizer=-এ এমন object দিন যার tokenize() আছে। Regex-এ বাংলা ব্লক U+0980–U+09FF রাখুন, ZWNJ আর ZWJ-ও। দাঁড়ি (U+0964) এই ব্লকের বাইরে, তাই আপনাআপনি সরে। এবার নিজের লেখা কোডের সঙ্গে হুবহু মিল: ০.৭১৪, ০.৪৫৫, ০.৫৫৬.

দুটি নিয়ম। এক, প্রতিটি নতুন metric-এ আগে identity test: একই লেখা দুই পাশে দিলে ১.০ আসে কি না। মাত্র একটি লাইনে এই পুরো ফাঁদ ধরা পড়ত। দুই, score(target, prediction)-এ আগে target; আগে pred দিলে P আর R উল্টে যায়। বাংলা সারাংশের গবেষণায় XL-Sum-এর লেখকেরা (Hasan ও সহকর্মী, ২০২১) নিজেদের multilingual ROUGE implementation প্রকাশ করেছেন, যাতে বাংলা stemming-ও আছে; নম্বর তুলনা করতে হলে কোন implementation, সেটিও মিলতে হবে।

৮ · নম্বরটি কতটা পাকা: bootstrap আর kappa

৪০টি বাক্যের একটি test set-এ দুটি system-এর BLEU ৭৭.৮ আর ৬৮.১. অন্য ৪০টি বাক্য হলে কি একই হতো? Bootstrap এর উত্তর অনুমান করে (তত্ত্ব মেশিন লার্নিং কোর্সের পর্ব ২১-এ)। শুধু একটি কথা: BLEU সাধারণ গড় নয়, তাই বাক্য ধরে resample করে গণনাগুলো আবার যোগ করতে হয়। আর দুই system-এ একই সূচক নিতে হয়, যাতে তুলনাটি জোড়া থাকে (মেশিন লার্নিং কোর্সের পর্ব ২২)।

মানুষের বিচার: adequacy আর fluency

মানুষ দিয়ে অনুবাদের মূল্যায়নে দীর্ঘদিনের দুটি মাপ adequacy আর fluency. Adequacy: উৎসের অর্থ কতটা এসেছে। Fluency: বাংলা হিসেবে কতটা স্বাভাবিক। §৬-এর System A adequacy-তে ভালো, fluency-তে কাঁচা; B উল্টো। স্কোরার দুজন একমত না হলে স্কোরটিই অনির্ভরযোগ্য, তাই তাঁদের মিল মাপতে হয়। Cohen's kappa (১৯৬০) কাকতালীয় মিল বাদ দেয় (annotation-এর বিস্তারিত মেশিন লার্নিং কোর্সের পর্ব ৫-এ)।

κ = (po − pe) / (1 − pe)
ধাপ
হিসাব
  1. কাঁচা মিল। দুজন ২০টির মধ্যে ১৫টিতে হুবহু একই স্কোর দিয়েছেন, তাই ০.৭৫.
  2. প্রান্তিক গণনা। প্রত্যেকে কোন স্কোর কতবার দিয়েছেন। কাকতালীয় মিল হিসাবের জন্য এটুকুই লাগে।
  3. কাকতালীয় মিল। দুজন স্বাধীনভাবে নিজেদের অভ্যাসে স্কোর দিলে কতটা মিলত: ০.২৯২৫.
  4. Kappa. সম্ভাব্য বাড়তি মিলের কতটা পাওয়া গেল: ০.৬৪৭. ১ মানে পূর্ণ মিল, ০ মানে কাকতালীয়ের সমান।
import numpy as np
from collections import Counter as C
rng = np.random.default_rng(42)
items = ["চাল", "ডাল", "তেল", "চিনি", "লবণ", "আটা"]
towns = ["ঢাকার", "রাজশাহীর", "খুলনার", "সিলেটের", "বরিশালের"]
refs = [f"{towns[rng.integers(5)]} গুদাম থেকে {items[rng.integers(6)]} আজ বিকেলে আপনার ঠিকানায় পাঠানো হবে"
        for _ in range(40)]
def noisy(ref, p):                       # p সম্ভাবনায় শব্দে ভুল বিভক্তি
    return " ".join(w + "ের" if rng.random() < p else w for w in ref.split())
def g(t, n): return C(tuple(t[i:i+n]) for i in range(len(t)-n+1))
def stats(c, r):                         # m1..m4, t1..t4, c_len, r_len
    c, r = c.split(), r.split()
    m = [sum((g(c, n) & g(r, n)).values()) for n in range(1, 5)]
    return m + [len(c)-n+1 for n in range(1, 5)] + [len(c), len(r)]
def bleu(S):                             # S-এর শেষ অক্ষে যোগ করা ১০টি গণনা
    logp = np.log(np.maximum(S[..., :4], 1e-9) / S[..., 4:8]).mean(-1)
    return 100 * np.minimum(1, np.exp(1 - S[..., 9] / S[..., 8])) * np.exp(logp)
X = np.array([stats(noisy(r, .10), r) for r in refs])
Y = np.array([stats(noisy(r, .13), r) for r in refs])
idx = rng.integers(0, 40, size=(2000, 40))   # একই সূচক দুই system-এ: জোড়া
bx, by = bleu(X[idx].sum(1)), bleu(Y[idx].sum(1))
ci = lambda v: np.percentile(v, [2.5, 97.5]).round(1)
print(f"X={bleu(X.sum(0)):.1f} CI {ci(bx)} | Y={bleu(Y.sum(0)):.1f} CI {ci(by)}")
print(f"X-Y CI {ci(bx - by)}")
a = np.array([4,4,3,2,4,1,3,3,2,4,3,1,4,2,3,4,3,2,4,3])  # adequacy ১–৪, বানানো
b = np.array([4,3,3,2,4,2,3,4,2,4,3,1,3,2,3,4,2,2,4,3])
def kappa(a, b):
    po = np.mean(a == b)
    pe = sum(np.mean(a == v) * np.mean(b == v) for v in range(1, 5))
    return po, pe, (po - pe) / (1 - pe)
print("po=%.2f pe=%.4f kappa=%.3f" % kappa(a, b))
ks = [kappa(a[i], b[i])[2] for i in rng.integers(0, 20, size=(1000, 20))]
print("kappa CI", np.percentile(ks, [2.5, 97.5]).round(2))

ডেটা ছাঁচে বানানো, তাই এই নম্বর আসল লেখায় খাটে না; শিখুন পদ্ধতিটি। প্রতিটি system-এর ৯৫% confidence interval এখানে একে অপরের ওপর পড়েছে: X=77.8 CI [71.6 83.4] আর Y=68.1 CI [60.6 75.3]. জোড়া পার্থক্যের CI [ 0.3 19.7] শূন্যের সামান্য ওপরে, কিন্তু খুব চওড়া। মানে X এগিয়ে থাকার ইঙ্গিত আছে, কতটা তা অস্পষ্ট। Kappa-র মান 0.647, কিন্তু ২০টি বাক্যে তার CI [0.34 0.92]. “০.৬৪৭” একা লিখলে এই অনিশ্চয়তা লুকিয়ে যেত।

Kappa-র “০.৬–০.৮ মানে ভালো” ধরনের নামকরণ (Landis ও Koch, ১৯৭৭) খুব প্রচলিত, কিন্তু সীমাগুলো তাঁদের নিজেদের ভাষাতেও স্বেচ্ছাচারী। স্কোর ক্রমযুক্ত (১–৪) হলে ৩ আর ৪-এর অমিল ১ আর ৪-এর অমিলের সমান নয়; তখন weighted kappa ভালো।

৯ · বাংলায় মূল্যায়ন: reference নিজেই প্রশ্নের মুখে

সব reference-ভিত্তিক metric ধরে নেয়, reference ঠিক। বাংলায় এই ধারণা নড়বড়ে হতে পারে:

  • একটি reference মানে একটি সমাধান। “দাম কমেছে” আর “দাম কমে গেছে” দুটোই ঠিক। §৩-এ দেখেছি দ্বিতীয় reference কীভাবে মান বদলায়।
  • সাধু, চলিত আর উপভাষা। “করিয়াছে”, “করেছে” আর চট্টগ্রাম বা সিলেটের রূপ, n-gram-এর কাছে সব আলাদা। Reference শুধু প্রমিত চলিতে হলে উপভাষায় ঠিক উত্তরও ভুল গোনা হবে। ফলে metric অজান্তে এক দলের ভাষাকে পুরস্কৃত করতে পারে।
  • বানান আর Banglish. “পৌঁছাবে/পৌছাবে”, “delivery/ডেলিভারি”: পাঠকের কাছে এক, BLEU-র কাছে আলাদা।
  • Reference-এর উৎস। Reference নিজেই যন্ত্রে অনুবাদ করে সম্পাদনা করা হলে তা যন্ত্রের ধাঁচ পুরস্কৃত করে। ডেটা কার্ডে উৎস দেখুন। বড় পাবলিক উদাহরণ BanglaNMT (Hasan ও সহকর্মী, ২০২০), XL-Sum-এর বাংলা অংশ; নিজের কাজে ব্যবহারের আগে তাদের license আর উৎস পড়ুন।

এই পর্বের ব্যবহারিক রিপোর্ট-ছক:

আরও গভীরে: Callison-Burch, Osborne ও Koehn (২০০৬) দেখান, BLEU বাড়া মানেই মান বাড়া নয়; একই BLEU-র বহু ভিন্ন বাক্য তৈরি করা যায়। Reiter (২০১৮) NLP গবেষণার পর্যালোচনা করে BLEU-কে মূলত system-এর প্রাথমিক পরীক্ষায় ব্যবহারের পরামর্শ দেন। COMET (Rei ও সহকর্মী, ২০২০) মানুষের স্কোরে শেখানো একটি neural metric; নতুন ভাষায় তার মান সেই ভাষার training ডেটার ওপরও নির্ভর করে। chrF++ word n-gram-ও যোগ করে; sacrebleu-তে CHRF(word_order=2).

পরীক্ষা ও ইন্টারভিউতে: BLEU-তে clip কেন? একই শব্দ বারবার লিখে precision বাড়ানো বন্ধ করতে। BP কেন? Precision-এর ফাঁকে ছোট output পুরস্কৃত হয়, তাই e1−r/c. Micro বনাম macro? Macro প্রতিটি class-কে সমান ওজন দেয়; একক-label-এ micro-F1 = accuracy. chrF কেন বাংলায়? শব্দের রূপ বদলালেও অক্ষরের মিল থাকে। Kappa কেন, কাঁচা মিল নয়? কাকতালীয় মিল বাদ দিতে।

১০ · AI-এর যুগে: metric-এর কোড AI লিখলে

AI সহকারী metric-এর কোড দ্রুত লিখে দেয়, আর ভুলগুলো চুপচাপ থাকে। সাধারণ ভুল: rouge-score default-এই বাংলায় চালানো; score()-এর argument উল্টো দেওয়া; sentence BLEU গড় করে corpus BLEU বলা; smoothing আর tokenizer না বলা; একটি run-এর সংখ্যা দিয়ে “system ভালো” ঘোষণা; মনগড়া benchmark নম্বর বা প্রবন্ধের নাম; পুরোনো argument. Model “অর্থ বুঝেছে” দাবিও উড়িয়ে দিন, n-gram মিল সে কথা বলে না।

নিচের prompt-এ উত্তরের সঙ্গে যাচাইয়ের উপায়ও চাওয়া হয়েছে। নিজের ব্যক্তিগত বা গ্রাহকের ডেটা কোনো prompt-এ দেবেন না।

আমি বাংলা অনুবাদ আর সারাংশ model মূল্যায়ন করব। sacrebleu আর rouge-score দিয়ে Python কোড লেখো। ১. প্রতিটি library-র version লেখো, আর আমার version-এ argument ঠিক আছে কি না যাচাইয়ের লিংক দাও। ২. BLEU-তে কোন tokenizer আর smoothing নিলে, কেন, আর signature ছাপাও। ৩. বাংলার জন্য rouge-score-এ custom tokenizer দাও (কার, হসন্ত, ZWJ যেন না ভাঙে), আর identity test যোগ করো। ৪. NFC কোথায় করছ দেখাও। ৫. Bootstrap CI বাক্য ধরে আর জোড়ায় করো। ৬. তিনটি ছোট test দাও যার উত্তর আমি হাতে মেলাতে পারব। কোনো প্রকাশিত নম্বর বা প্রবন্ধের নাম নিশ্চিত না হলে বলো।

AI-এর দেওয়া মূল্যায়ন কোডের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর উত্তর খুলুন।

প্র ০১BLEU-র এত সমালোচনা, তবু কেন প্রায় সব অনুবাদের প্রবন্ধে এটি থাকে? এটি কি বাদ দেওয়া উচিত?

২০০২ সালের আগে অনুবাদ মাপা মানে ছিল মানুষ দিয়ে পড়ানো, যা ধীর ও ব্যয়বহুল। Papineni ও সহকর্মীরা এমন একটি সংখ্যা দেন যা কয়েক সেকেন্ডে, প্রায় বিনা খরচে পাওয়া যায়। রাতারাতি পরীক্ষা চালানো সম্ভব হলো, ফলে statistical অনুবাদের গবেষণা অনেক দ্রুত এগোল। এই ঐতিহাসিক অবদান অস্বীকার করা যায় না। তাঁদের দাবি ছিল corpus-স্তরে, মানুষের মূল্যায়নের সঙ্গে সম্পর্ক নিয়ে, বিশেষ করে এক ধরনের system-এর তুলনায়।

সমালোচনাও সমান পুরোনো। Callison-Burch ও সহকর্মীরা ২০০৬ সালে দেখান, বাক্য নানাভাবে এলোমেলো করেও একই BLEU রাখা যায়, আর ভিন্ন ধরনের system-এর তুলনায় BLEU আর মানুষের রায় মিল না-ও খেতে পারে। Post ২০১৮ সালে দেখান, শুধু সেটিং-এর পার্থক্যে নম্বর উল্লেখযোগ্য বদলায়। Kocmi ও সহকর্মীরা ২০২১ সালে মানুষের রায়ের বড় সংগ্রহ নিয়ে একে একক metric হিসেবে ব্যবহার করতে নিষেধ করেন।

তবু কেন টিকে আছে? প্রথমত অভ্যাস আর তুলনার চাপ: পুরোনো প্রবন্ধের সঙ্গে মেলাতে BLEU লাগে। দ্বিতীয়ত সরলতা: কোনো trained model লাগে না, কোনো ভাষা-নির্ভর সম্পদও না। বাংলার মতো ভাষায় যেখানে শেখানো metric-এর জন্য মানুষের স্কোর কম থাকতে পারে, সেখানে string-ভিত্তিক metric-এর ব্যবহারিক সুবিধা আছে। তৃতীয়ত এটি একই system-এর দুই version-এ দ্রুত ধরা পড়া বড় ভাঙনের পরীক্ষা হিসেবে এখনো কাজের।

এই পর্বের System B দেখিয়েছে, অর্থ উল্টে দিয়েও BLEU উঁচু রাখা যায়। তাই পরিণত অবস্থান হলো, “বাদ” বা “অন্ধ বিশ্বাস” কোনোটিই নয়। সঙ্গে chrF দিন, signature দিন, CI দিন, আর চূড়ান্ত সিদ্ধান্তে মানুষকে রাখুন।

মূল উপলব্ধি: একটি metric একই সঙ্গে ঐতিহাসিকভাবে মূল্যবান আর সিদ্ধান্তের জন্য অপর্যাপ্ত হতে পারে; প্রশ্ন “কোন কাজে, কোন সীমায়”।

প্র ০২বাংলার জন্য chrF না নিয়ে stemmer বা lemmatizer দিয়ে শব্দের মূল রূপে BLEU করলে হয় না?

চিন্তাটি যুক্তিসংগত, আর এর ইতিহাসও আছে। METEOR (Banerjee ও Lavie, ২০০৫) stem আর সমার্থক শব্দ ব্যবহার করে মেলাত, অর্থাৎ “ঠিক মিল”-এর শর্ত নরম করত। বাংলায় “বাজারে”, “বাজারের”, “বাজারটি” সব “বাজার”-এ নামালে BLEU-র অকারণ কঠোরতা কমে।

কিন্তু তিনটি প্রশ্ন আসে। প্রথমত, stemmer নিজেই একটি model, তার ভুল আছে। বাংলার জটিল সন্ধি আর প্রত্যয়ে কোন শব্দ কোথায় কাটা হবে, তার ফলে metric-এর মান সেই stemmer-এর version-এর ওপর নির্ভর করবে। দুই দলের নম্বর তুলনায় তখন stemmer-ও মিলতে হবে। দ্বিতীয়ত, বিভক্তি কখনো অর্থ বহন করে: “বাজারে” (কোথায়) আর “বাজারের” (কার) এক নয়। Stem করলে এই পার্থক্য পুরো মুছে যায়, অর্থাৎ শাস্তি শূন্য। chrF মাঝামাঝি থাকে: ছোট শাস্তি দেয়, যা এখানে ৯২.৮১ আর হুবহু মিলের ১০০-র ফাঁকে দেখা গেছে। তৃতীয়ত, chrF-এর জন্য কোনো ভাষা-নির্ভর সম্পদ লাগে না।

chrF-এরও দুর্বলতা আছে। সে অর্থ বোঝে না: “কমেনি” ৯৪.৭৯ পেয়েছে। সে code point গোনে, তাই একটি যুক্তাক্ষর কয়েকটি একক হিসেবে গণনা হয়, আর NFC না করলে ৮২.২২-এ নেমে যায়। আর একই রকম দেখতে দুটি শব্দ অর্থে ভিন্ন হতে পারে; ছোট এক অক্ষরের পার্থক্য তার কাছে ছোটই। XL-Sum-এর লেখকদের বাংলা stemming-সহ ROUGE দেখায়, গবেষকরা stem-ভিত্তিক পথও বেছে নেন। তাই এটি বিকল্পের বিচার, একটি জয়ী নেই।

ব্যবহারিক পরামর্শ: প্রধান হিসেবে chrF আর signature-সহ BLEU দুটোই রাখুন। Stem-ভিত্তিক সংস্করণ চালালে সেটি পরিষ্কার লিখুন, যেন অন্যরা বুঝতে পারেন। আর যে ভুল আপনার পণ্যে সবচেয়ে ক্ষতিকর, যেমন “না” বাদ পড়া বা দাম ভুল, তার জন্য আলাদা নিয়মভিত্তিক পরীক্ষা লিখুন।

মূল উপলব্ধি: প্রতিটি নরম করার কৌশল কিছু ন্যায্য মিল ফিরিয়ে দেয়, কিন্তু কিছু আসল ভুলও ক্ষমা করে; কোনটি ক্ষমা করা চলে, সেটি আপনার task ঠিক করে।

প্র ০৩rouge-score কেন error না দিয়ে শূন্য দেয়? এই “নীরব ব্যর্থতা” থেকে software নকশার কী শিক্ষা?

Chin-Yew Lin-এর আদি ROUGE ছিল একটি Perl টুল, মূলত ইংরেজি সারাংশের সম্মেলনগুলোর জন্য। Google-এর rouge-score তারই আচরণের কাছাকাছি একটি Python রূপ। তার source-এর মন্তব্যেই আছে, tokenization-এ Lin-এর পদ্ধতি অনুসরণের লক্ষ্য। তাই ইংরেজি ছাড়া ভাষায় আচরণটি কোনো দুষ্টুমি নয়, মূল পরিকল্পনার সীমা: লেখা ছোট হাতের করা, a-z আর ০-৯ ছাড়া বাদ দেওয়া। ইংরেজিতে যতিচিহ্ন বাদ দেওয়ার জন্য এটি যুক্তিসংগত। বাংলায় সেই একই নিয়ম পুরো লেখা মুছে দেয়।

বিপদ শূন্যে নয়, শূন্য যে সন্দেহজনক দেখায় তাতে কিছুটা রক্ষা আছে। বিপদ অন্য ক্ষেত্রে। ধরুন আপনার বাংলা সারাংশে কিছু ইংরেজি শব্দ আছে: ব্র্যান্ড, USB, তারিখ। তখন স্কোর ০ নয়, বরং ০.২ বা ১.০-এর মতো একটি বিশ্বাসযোগ্য সংখ্যা। এই পর্বের run-এ উল্টো অর্থের দুটি রিভিউ ROUGE-1 F ১.০ পেয়েছে। কেউ সেই সংখ্যা প্রবন্ধে বা ড্যাশবোর্ডে বসাতে পারেন, আর কোথাও কোনো লাল সতর্কতা আসবে না।

Software নকশায় এটি পরিচিত নীতি: fail fast. খালি token তালিকা পেলে একটি warning দিলে সমস্যা শুরুতেই ধরা পড়ত। তবে library-র পক্ষেও যুক্তি আছে: কোনো টুকরো লেখা সত্যিই শুধু যতিচিহ্ন হতে পারে, আর বড় ব্যাচে warning-এর স্রোত বিরক্তিকর। এই কারণে দায়িত্বের একটি অংশ ব্যবহারকারীরও।

তাই নিজের pipeline-এ পাতলা একটি পাহারা লিখুন: identity test, খালি token হলে থামা, সন্দেহজনক ১.০ বা ০ গুনে রিপোর্ট করা, আর ছোট উদাহরণে হাতের সঙ্গে মেলানো। এই তিন লাইনের পরীক্ষা বহু মাসের ভুল নম্বর থেকে বাঁচাতে পারে। একই কথা \w+-এর ক্ষেত্রেও: কোডে ভুল দেখা যায় না, আউটপুটে ফুটে ওঠে কেবল যদি আপনি token ছেপে দেখেন।

মূল উপলব্ধি: “কোনো error আসেনি” আর “হিসাব ঠিক হয়েছে” এক কথা নয়; ইংরেজির জন্য লেখা টুল বাংলায় চালানোর আগে তার ভেতরের ধারণাগুলো পরীক্ষা করুন।

প্র ০৪Model-কে সরাসরি ROUGE বা BLEU বাড়াতে শেখালে কী হয়? এটি কি ভালো ধারণা?

এটি বাস্তবে চেষ্টা করা হয়েছে। BLEU আর ROUGE পার্থক্যযোগ্য নয়, তাই সরাসরি gradient নেওয়া যায় না, কিন্তু reinforcement learning-এর কৌশলে তাদের reward বানানো যায়। Paulus ও সহকর্মীদের ২০১৮ সালের সারাংশের কাজে এমন প্রশিক্ষণ ব্যবহৃত হয়। তাঁরা নিজেরাই উল্লেখ করেন, ROUGE বাড়ানো মানেই পড়ার মতো সারাংশ নিশ্চিত হয় না, তাই সাধারণ loss-এর সঙ্গে মিশিয়ে ব্যবহার করেন। অনুবাদেও আগে BLEU-কে সরাসরি tuning-এর লক্ষ্য করা (MERT, Och ২০০৩) বহুল প্রচলিত ছিল; সেখানে এটি বিশেষ করে linear ওজন ঠিক করতে কাজে লাগত।

কেন এটি আংশিক কাজ করে? কারণ metric-এর সঙ্গে মানের কিছু সম্পর্ক সত্যিই আছে, আর আপনি training লক্ষ্যকে পরীক্ষার লক্ষ্যের কাছে আনছেন। কেন এটি বিপজ্জনক? Goodhart-এর নিয়মের কারণে। ROUGE শব্দের মিল চায়, তাই model লম্বা, বাক্য-জোড়া, উৎস থেকে সরাসরি কপি করা output-এর দিকে ঝুঁকতে পারে। এই পর্বের “পুরো উৎস” উদাহরণে F ০.৫৫৬ থেকে ০.৬১১ হয়েছে, যদিও কোনো সারাংশই লেখা হয়নি। আবার ROUGE বোঝে না কোনো তথ্য সত্য কি না। একটি সংখ্যা বা “না” ভুল হলে শাস্তি প্রায় শূন্য, অথচ গ্রাহকের ক্ষতি বড়।

আধুনিক সারাংশের গবেষণা তাই আলাদা করে factual consistency মাপার দিকে নজর দিয়েছে, আর LLM যুগে preference alignment-এর পথ (পর্ব ৫৭) মানুষের পছন্দকেই লক্ষ্য বানাতে চায়। সেখানেও নতুন Goodhart আসে: মানুষ যা পছন্দ করে, তা সবসময় যা সত্য তা নয়।

বাজারঘরের দলের জন্য পরামর্শ: training-এ cross-entropy, model বাছাইয়ে একাধিক metric, আর লঞ্চের সিদ্ধান্তে মানুষের নমুনা পড়া ও A/B test (মেশিন লার্নিং কোর্সে আছে)। কোনো একটি সংখ্যাকে দলের একমাত্র লক্ষ্য না করাই নিরাপদ।

মূল উপলব্ধি: যে মাপ দিয়ে শেখান, model সেটির ফাঁকগুলোও শেখে; তাই শেখানোর মাপ আর বিচারের মাপ আলাদা রাখা স্বাস্থ্যকর।

প্র ০৫মানুষের বিচারই কি চূড়ান্ত সত্য? দুজন মানুষ একমত না হলে কী হবে?

মানুষের বিচার আমাদের সবচেয়ে কাছের মানদণ্ড, তবে নিখুঁত নয়, আর পদ্ধতির ওপর খুব নির্ভরশীল। Adequacy আর fluency-র স্কেল ১৯৯০-এর দশকের যুক্তরাষ্ট্রের ARPA-র অনুবাদ মূল্যায়ন থেকে পরিচিত। পরে দেখা গেছে, দুই বৈশিষ্ট্য আলাদা করা স্কোরারদের পক্ষে সবসময় সহজ নয়: সাবলীল লেখা পড়লে অর্থ ঠিক মনে হওয়ার প্রবণতা থাকে। বাক্য বেশি সাবলীল হলে ভুল চোখে কম পড়তে পারে, যা নিউরাল অনুবাদে বাস্তব উদ্বেগ।

একটি ভালো উদাহরণ “human parity” বিতর্ক। ২০১৮ সালে একটি গবেষণা দাবি করে, একটি চীনা-ইংরেজি system মানুষের সমান। Läubli, Sennrich ও Volk একই বছর পুনঃমূল্যায়ন করে দেখান, বাক্য আলাদাভাবে না দেখে পুরো নথি পড়লে পার্থক্য স্পষ্ট হয়। পরের কাজগুলোতে স্কোরারের দক্ষতার প্রভাবও আলোচিত হয়েছে। অর্থাৎ প্রশ্নটি শুধু “মানুষ কী বলল” নয়, “কোন মানুষ, কী দেখে, কোন নিয়মে”।

দুজন একমত না হলে সেটি নিছক গোলমাল নাও হতে পারে। প্রথমে নির্দেশিকা দেখুন: স্কোর ৩ আর ৪-এর পার্থক্য লেখা আছে কি? অমিলের বাক্যগুলো পড়ে নিয়ম ঠিক করুন, তারপর আবার মাপুন। অনেক অমিল এখানেই কমে। কিন্তু কিছু অমিল আসল: রাজশাহীর একজন আর সিলেটের একজন স্কোরার একটি বাক্যকে আলাদাভাবে স্বাভাবিক মনে করতে পারেন। তখন গড় করে সেটি মুছে না ফেলে আলাদা দেখানোই ভালো, কারণ model কার জন্য ভালো, সেটিই তথ্য।

আরও একটি সীমা: এই পর্বের kappa ০.৬৪৭, অথচ ২০ বাক্যে CI ০.৩৪ থেকে ০.৯২. ছোট নমুনায় মিলের মাপও অনিশ্চিত। আর kappa দুই স্কোরারের মিল মাপে, তাঁদের সঠিকতা নয়; দুজন একইভাবে ভুল করতে পারেন। পাশাপাশি স্কোরারের শ্রমের মূল্য আর গোপনীয়তাও বিবেচ্য: গ্রাহকের আসল ঠিকানা তাঁদের সামনে রাখা ঠিক নয়।

মূল উপলব্ধি: মানুষের বিচারও একটি মাপার যন্ত্র; তার পদ্ধতি, স্কোরারের পরিচয় আর মিলের মাত্রা না লিখলে সেটিও একটি অস্পষ্ট সংখ্যা মাত্র।

অনুশীলন

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

  1. হাতে BLEU আর ROUGE (সংখ্যার কাজ): Candidate: “আজ চাল খুব খুব ভালো”; reference: “আজ চাল খুব ভালো আছে”। N = ২ ধরে BLEU আর ROUGE-1 F বের করুন।

    Unigram: খুব-এর গণনা clip হয়ে ১, তাই ৪/৫ = ০.৮. Bigram: আজ-চাল, চাল-খুব, খুব-ভালো মেলে, খুব-খুব মেলে না, তাই ৩/৪ = ০.৭৫. c = r = ৫, BP = ১। BLEU-2 = ১০০ × √(০.৮ × ০.৭৫) = ৭৭.৪৬. ROUGE-1: মিল ৪, P = R = ৪/৫, তাই F = ০.৮.

  2. আউটপুট অনুমান করুন: rouge-score 0.1.2-এ default tokenizer দিয়ে score("দাম 500 টাকা", "মূল্য 500 টাকা নয়") চালালে ROUGE-1 (P, R, F) কত? ফলটি কি বিশ্বাসযোগ্য?

    দুই পাশে একটিই token থাকে: 500. তাই Score(precision=1.0, recall=1.0, fmeasure=1.0) (সত্যিকারের run-এ মিলিয়েছি)। বিশ্বাসযোগ্য নয়: “নয়” যোগ হয়ে অর্থ উল্টে গেছে, আর বাংলার কিছুই গোনা হয়নি।

  3. রিপোর্ট সমালোচনা করুন (নকশা): এক দল লিখল: “নতুন model BLEU ৩৪, পুরোনোটি ৩১; আমরা বাজারঘরের গ্রাহক-বার্তার বাংলা অনুবাদে এটিই চালু করছি।” কী কী প্রশ্ন করবেন, আর কী পরীক্ষা চাইবেন?

    সেটিং: tokenizer, smoothing, reference সংখ্যা, NFC, দুই নম্বর কি একই সেটিংয়ে? ডেটা: test set কত বড়, গ্রাহক-বার্তার মতো কি না, tuning-এ ব্যবহার হয়েছে কি? অনিশ্চয়তা: জোড়া bootstrap-এ পার্থক্যের CI কত? ধরন: chrF আর TER কী বলে? মানুষ: কয়েক ডজন বাক্যে adequacy আর fluency, kappa-সহ। ক্ষতি: টাকার অঙ্ক, “না”, ঠিকানার ভুল আলাদাভাবে গোনা। সবশেষে ছোট পরিসরে নিয়ন্ত্রিত চালু করে আসল ফল মাপা।

  4. Kappa (কোড বা হাতে): ৫০টি রিভিউতে দুই annotator: দুজনেই হ্যাঁ ২০, দুজনেই না ১৫, A হ্যাঁ ও B না ৫, A না ও B হ্যাঁ ১০। Kappa বের করুন। তারপর bootstrap ব্লকের a, b বদলে একই ফল কোডে মিলিয়ে দেখুন।

    po = ৩৫/৫০ = ০.৭. A-র হ্যাঁ ২৫/৫০ = ০.৫; B-র হ্যাঁ ৩০/৫০ = ০.৬. pe = ০.৫×০.৬ + ০.৫×০.৪ = ০.৫. κ = (০.৭ − ০.৫) / ০.৫ = ০.৪. কাঁচা ৭০% মিল শুনতে ভালো, কাকতালীয় অংশ বাদে তা মাঝারি। কোডে স্কোর দুটি মান ১ ও ২ দিয়ে লিখলে একই ফল আসবে, কারণ range(1, 5)-এর অন্য মানগুলো শূন্য যোগ করে।

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

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

ক্রমএকটি বাংলা অনুবাদ model মূল্যায়নের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. আলাদা test set আর reference-এর উৎস ঠিক করা
  2. Candidate আর reference-এ NFC ও একই tokenizer প্রয়োগ
  3. Identity test দিয়ে metric-এর কোড যাচাই
  4. chrF আর BLEU মাপা, signature লিখে রাখা
  5. জোড়া bootstrap-এ পার্থক্যের CI বের করা
  6. মানুষের নমুনা পড়া, দুই স্কোরারের kappa-সহ

Candidate “ভালো ভালো ভালো ভালো”, reference “ভালো চাল ভালো”। Clipped unigram precision কত?

  • ৪/৪ = ১.০
  • ২/৪ = ০.৫
  • ২/৩ ≈ ০.৬৭
  • ০

Candidate-এর দৈর্ঘ্য ৫, reference-এর ১০ হলে BLEU-র brevity penalty কত?

  • ১.০
  • ০.৫
  • প্রায় ০.৩৬৮
  • প্রায় ০.১৩৫

rouge-score 0.1.2-এ default tokenizer দিয়ে দুটি হুবহু একই বাংলা বাক্যের ROUGE-1 F কত আসে, আর কেন?

  • ০, কারণ বাংলা token সব বাদ পড়ে
  • ১.০, কারণ দুটি লেখা একই
  • কোনো ফল নয়, UnicodeError দেয়
  • ০.৫, কারণ অচেনা শব্দে গড় ধরা হয়

তিন class-এর একক-label classification-এ F1 যথাক্রমে ০.৯, ০.৬ আর ০. Accuracy ০.৮৫. কোন কথাটি ঠিক?

  • Macro-F1 = ০.৮৫
  • Micro-F1 = ০.৫
  • ছোট class-এর ব্যর্থতা macro-F1-এ দেখা যায় না
  • Macro-F1 = ০.৫ আর micro-F1 = ০.৮৫

দুই স্কোরারের কাঁচা মিল po = ০.৮, কাকতালীয় মিল pe = ০.৫. Cohen's kappa কত?

  • ০.৮
  • ০.৩
  • ০.৬
  • ০.৩৭৫

Reference-এ “বাজারে”। একটি অনুবাদে “বাজারের”, অন্যটিতে “দোকানে”, বাকি বাক্য হুবহু। সাধারণত কী দেখা যাবে?

  • BLEU “বাজারের”-কে বেশি দেবে, chrF দুটিকে সমান
  • BLEU দুটিকে সমান দেবে, chrF “বাজারের”-কে বেশি
  • দুটি metric-ই “দোকানে”-কে বেশি দেবে
  • দুটি metric-ই অর্থ দেখে “বাজারের”-কে ১০০ দেবে

শব্দকোষ

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

মনে রাখুন

  1. Metric আসল লক্ষ্যের প্রতিনিধি: task অনুযায়ী বাছুন, আর এক সংখ্যায় থামবেন না।
  2. BLEU = BP × clipped n-gram precision-এর গুণোত্তর গড়; tokenizer আর smoothing বদলালে নম্বর বদলায়।
  3. “বাজারে/বাজারের” BLEU-তে “দোকানে”-র সমান ভুল; chrF ছোট শাস্তি দেয়, তবে অর্থ বোঝে না।
  4. Identity test আগে: rouge-score-এর default tokenizer বাংলায় চুপচাপ ০ দেয়, custom tokenizer লাগে।
  5. নম্বরের সঙ্গে জোড়া bootstrap CI দিন; মানুষের স্কোরে kappa আর আসল output পড়া বাদ দেবেন না।

পরের পর্বে: মডিউল ৮ শুরু। n-gram এখানে মাপার যন্ত্র ছিল; পর্ব ৪৬-এ সেটিই হবে একটি language model, যেখান থেকে neural LM আর perplexity-র পথ খুলবে।

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

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