Bengali-English code-mixed hallucination detection dataset and benchmark. We will create and manually annotate a small corpus of LLM-generated responses, classify different types of hallucinations, and compare N-gram, Skip-gram, RNN, LSTM, and BERT-based approaches.
Jupyter Notebook
0
17 commits
updated Sep 20, 2026
A corpus of Bengali question–answer pairs, a ladder of 20 detection systems from a two-line rule up to a fine-tuned BanglaBERT, and one honest evaluation procedure to compare them all.
Two names, one project.
BangHalluis the name used in the paper and in the demo.Bhibranti(ভ্রান্তি, Bengali for "delusion") is the repository's codename. They are the same thing.
Language models — the programs behind chatbots — sometimes give an answer that sounds completely natural but is simply untrue. That is called a hallucination. It is dangerous because nothing in the wording warns you: a fluent wrong answer looks exactly like a fluent right one, and a student asking a question usually cannot check it, because they are asking precisely because they don't know.
This project asks one narrow question, in Bengali:
Given a question, a proposed answer, and sometimes a passage of supporting text — is the answer trustworthy, or made up?
Think of a teacher marking a student's answer. Sometimes the teacher has the textbook page open (a passage); sometimes they must mark from memory. We build software that plays that teacher, and — the part that matters — we measure how much of its skill is real understanding and how much is a cheap trick.
What we built
What we found, in four lines
Label convention — the reverse of most papers.
1= faithful / correct,0= hallucinated / made up. The positive class is the truthful one, everywhere in this repository.
Skip this section if you already work in NLP.
When a language model doesn't know something, it doesn't say so — it produces the most plausible-sounding text. Ask it what year a mosque was founded and it will give you a year, fluently and confidently, whether or not it is correct. That confident falsehood is a hallucination.
For every item — one question plus one proposed answer — the system outputs one of two labels:
| Label | Name | Meaning |
|---|---|---|
1 | Faithful | The answer is correct (and supported by the passage, when there is one) |
0 | Hallucinated | The answer is made up |
Formally, with $q$ = question, $a$ = proposed answer, $c$ = supporting passage (which may be absent):
$$f_\theta(q,,a,,c);\longrightarrow;{0,,1}$$
Three real examples show the range of difficulty (English glosses in brackets):
| Example | Why it is that way | |
|---|---|---|
| Easy, with a passage |
Q: ২০১০ সালে ইংল্যান্ডের বাংলাদেশ সফরে টেস্টে সাকিব কত উইকেট নিয়ে সর্বোচ্চ উইকেট শিকারী হন? (How many wickets did Shakib take to become the top wicket-taker in the Tests of England's 2010 tour of Bangladesh?) Correct: ৯ উইকেট (9 wickets) Made up: ১০ উইকেট (10 wickets) | The passage says "৯ উইকেট". The wrong answer's text never appears in the passage, so a plain string search settles it. No understanding needed. |
| Hard, with a passage |
Q: মেট্রোপলিটান কমিউনিটি চার্চ অফ এডিনবার্গ কত সালে প্রতিষ্ঠিত হয়? (In which year was the Metropolitan Community Church of Edinburgh established?) Correct: ১৯৬৮ (1968) Made up: ১৯৯৫ (1995) | Both years appear in the passage. 1968 is when the denomination was founded; 1995 is when the Edinburgh congregation began worshipping independently. String matching finds both and cannot choose. Only a system that tracks which year belongs to which entity gets it right. |
| Hard, no passage |
Q: ‘উত্তর’ ও ‘উত্তর’ শব্দ দুটির সন্ধিতে কোন শব্দ গঠিত হয়? (Which word is formed by joining 'uttar' and 'uttar'?) Correct: উত্তরোত্তর Made up: উত্তরউত্তর | No passage to check against. The wrong answer is a plausible-looking but invalid form, made by naive concatenation. The system must know Bengali grammar. |
| Term | Plain-language meaning |
|---|---|
| Item | One question paired with one proposed answer. The unit the system judges. |
| Pair | One question with both of its answers — the correct one and the made-up one. One pair = two items. |
| Passage (context) | A short piece of Bengali text supplied with the question. Present for about 6 items in 10. |
| Has-context / no-context | Items with a passage vs. without. Two genuinely different tasks; we always report them separately. |
| Easy / hard | Measured, not guessed: an item is easy if the string-matching shortcut separates its two answers, hard if it doesn't. |
| Baseline | A simple reference any real system must beat. Ours are rules that involve no learning. |
| Macro-F₁ | Our score, 0 → 1, higher is better. Always answering "faithful" scores 0.333 — the floor. (§6) |
| Training / dev / test split | Train: what models learn from. Dev: what we tune and compare on. Test: locked away, opened once at the very end. |
| Seed | The starting number of a random-number generator. We run every random model with three (42, 1337, 2024) and report mean ± spread. |
| Pretraining | Teaching a model general language by reading enormous amounts of text before it ever sees our task. |
| Fine-tuning | Then continuing to train that pretrained model on our specific task. |
| BERT-family encoder | A pretrained Transformer network that turns text into numbers. BanglaBERT, MuRIL and XLM-R are three of them. |
| Cohen's κ (kappa) | How much two annotators agree beyond chance. 0 = chance, 1 = perfect. ≥ 0.6 is "substantial". |
| McNemar's test | A significance test for "is model A really better than model B?", using only the items they disagree on. (§6.4) |
| Retrieval / RAG | Looking things up (web, database, similar examples) while answering. Forbidden in this project. |
These constraints hold for the entire project and are what make the numbers meaningful:
| Rule | Why |
|---|---|
| No retrieval at inference. The system sees the question, the passage stored with that item (if any) and the candidate answer — nothing else. No web search, no database lookup, no fetching similar training examples. | The corpus is partly drawn from public question banks. A system allowed to search them could score well without judging anything. §5.8 shows exactly this happening. |
| Bengali only, question-answering only. No fill-in-the-blank items. | Fill-in-the-blank rewards copying a span out of the passage. A plain string matcher scored 0.929 on it, which would have inflated every result. |
| Split by pair, never by item. | Otherwise the correct answer could land in training and the wrong one in testing, and a model could score by memorising the question. |
| The test split is opened exactly once, for the final system. | Otherwise you slowly tune to it, and it stops being an honest estimate. |
| Every result reported next to its baselines, split by easy/hard. | A single averaged number hides everything that matters here. |
| Nothing is re-rolled until it looks good. Seeds are fixed (42, 1337, 2024). | Otherwise variance is being mistaken for progress. |
flowchart LR
RAW["Raw sources<br/>62,084 items"] --> CLEAN["Clean and<br/>de-duplicate<br/>56,480"]
CLEAN --> PAIRS["Build pairs<br/>4,480 pairs"]
PAIRS --> LABEL["Human labelling<br/>kappa = 0.865"]
LABEL --> SPLIT["Split by pair<br/>train / dev / test"]
SPLIT --> GATE{"Shortcut audit<br/>probe below 0.60?"}
GATE -->|"yes: 0.514"| LADDER["Train the ladder<br/>20 systems"]
LADDER --> SCORE["One shared scorer<br/>evaluate.py"]
SCORE --> DEMO["Demo and<br/>paper tables"]
style GATE fill:#fff3cd,stroke:#b58900,color:#000
style DEMO fill:#0ca30c,color:#fff,stroke:none
| Stage | What happens | Script | Produces |
|---|---|---|---|
| 1. Collect | Raw Bengali QA records from Wikipedia-grounded and question-bank sources | (done; see data/SOURCES.md) | data/raw/ |
| 2. Clean | Normalise, de-duplicate | src/build_bn_pool.py | data/interim/bn_pool.jsonl |
| 3. Build | Form pairs, filter, split by pair | src/build_corpus.py | data/corpus/, data/splits/ |
| 4. Annotate | Human labels, agreement, adjudication | src/build_annotation_sheets.py, src/score_test_agreement.py, src/merge_annotation.py | data/annotated/ |
| 5. Audit | Prove the label can't be guessed from surface features | src/audit.py | pass / fail gate |
| 6. Prepare text | Bengali cleaning, tokenising, input formats | src/text_bn.py, src/preprocess.py | model inputs |
| 7. Train | Every rung of the ladder | src/train_classical.py, src/train_neural.py, src/train_transformer.py | fitted models, results/experiment_log.csv |
| 8. Score | One scorer, sliced every way | src/evaluate.py | metrics, tables |
| 9. Serve | Interactive demo | src/serving.py, src/app.py | web app |
Every stage is a separate script you can run on its own (python src/<name>.py --help).
We want to be exact about this, because it affects how the corpus should be interpreted.
provenance field saying how its
text was produced.src/llm_annotate.py), never to write a
label. Letting an LLM write the labels would be circular: a detector trained to reproduce an LLM's blind
spots measures nothing. The script physically cannot overwrite a label — where it disagrees, the row is
flagged for a human.| Step | Count |
|---|---|
| Raw records (14 source files) | 62,084 |
| After cleaning and de-duplication | 56,480 |
| Corpus built — pairs (× 2 = items) | 4,480 (8,960) |
| Removed after human review (broken item, or stored label confirmed wrong) | −180 pairs |
| Usable for training and scoring | 4,300 pairs (8,600 items) |
The 180 removed pairs are kept in the files, marked excluded = true; src/splits.py drops them
automatically — which is why all data must be loaded through it (see §10.4).
{
"id": "bnp_004674_1", // unique item id (…_0 = correct, …_1 = wrong, for this pair)
"pair_id": "bnp_004674", // both answers to one question share this
"question": "২০১০ সালে ইংল্যান্ডের বাংলাদেশ সফরে টেস্টে সাকিব কত উইকেট নিয়ে …?",
"context": "২০১০-এর ফেব্রুয়ারি-মার্চে দুই টেস্ট ও তিন ওয়ানডে খেলতে …", // "" when there is no passage
"candidate_answer": "১০ উইকেট", // the answer being judged
"reference_answer": "৯ উইকেট", // the known-correct answer for this question
"label": 0, // 1 = faithful, 0 = hallucinated
"condition": "has_context", // or "no_context"
"difficulty": "easy", // measured: does string matching separate this pair?
"hallucination_type": "numeric",// what kind of mistake (only for label 0)
"subject": "history",
"excluded": false // true = removed after human review
}
hallucination_type, type_source, annotator_1, annotator_2 and adjudicated all reveal the label.
They exist for reporting ("how did the model do on numeric errors?") and are never model inputs. The record
schema is frozen in configs/schema.json — fields may be added but never renamed,
removed or repurposed.
| Train | Dev | Test | |
|---|---|---|---|
| Pairs | 3,067 | 619 | 614 |
| Items | 6,134 | 1,238 | 1,228 |
| With a passage | 59.8% | 57.0% | 58.5% |
| Hard subset | 28.9% | 29.7% | 28.7% |
| Faithful (label = 1) | 50.0% | 50.0% | 50.0% |
The classes are exactly balanced by construction (every question contributes one of each), so plain accuracy would be a misleading score — see §6.1.
13 subjects, drawn from school and civil-service exam material. Nothing was dropped for being hard — law, science and BCS questions that need outside knowledge stay in, and we report on them honestly.
| Subject | Share of corpus |
|---|---|
| Reading comprehension | 28.2% |
| History | 20.1% |
| Grammar | 8.3% |
| Law | 7.1% |
| The other nine: mathematics, science, literature, antonyms, idiom meaning, civil service (BCS), vocabulary, synonyms, others | 1.7% – 4.9% each |
Answers are short (median 16 characters — often one name, date or number); questions have a median of 55 characters; passages a median of 259, up to 3,132. That shape drives a design decision in §4.4: when input is too long, cut the passage, never the answer.
Every wrong answer is labelled with the kind of mistake, so we can ask not just how often a model is wrong but where:
| Type | Meaning | Where | Share* |
|---|---|---|---|
| Fabricated | Invented, unsupported content | no-context | 42.3% |
| Numeric | Wrong number, year or quantity | has-context | 26.4% |
| Entity | Wrong person, place or named thing | has-context | 15.0% |
| Relational | Right things, wrong relationship between them | has-context | 8.9% |
| Contradiction | States the opposite of the passage | has-context | 7.5% |
*Of the 1,233 wrong answers in dev + test. Types are annotated for dev, test and a 20% spot-check of train; per-type results are therefore reported on dev/test only.
The most important design decision in the project is how difficulty is defined. We never ask a human whether a question feels hard. We ask a mechanical question:
Does plain string matching separate the two answers of this pair?
If the correct answer appears in the passage and the wrong one does not, the pair is easy — a trivial rule solves it. If the shortcut can't tell them apart, the pair is hard. About 29% of the corpus is hard.
flowchart TD
P["A pair: correct answer + wrong answer,<br/>with a passage"] --> Q{"Does string matching<br/>separate them?"}
Q -->|"Yes: one appears in the passage, the other doesn't"| E["EASY<br/>a trivial rule solves it"]
Q -->|"No: both appear, or neither does"| H["HARD<br/>the system must actually do something more"]
style E fill:#dbeafe,stroke:#2a78d6,color:#000
style H fill:#fee2e2,stroke:#e34948,color:#000
The same no-learning rule, measured on each subset — this table is the reason the hard subset exists:
| Rule (applied where a passage exists) | Macro-F₁ |
|---|---|
| Exact string match, all items | 0.823 |
| Exact string match, easy subset | 0.987 ← near-perfect, and it demonstrates nothing |
| Exact string match, hard subset | 0.454 ← worse than always guessing one class |
| Fuzzy string match, hard subset (allows ~40% of characters to differ, to absorb spelling and inflection) | 0.591 ← the real bar every learned model must clear |
| Always answer "faithful" | 0.333 |
A model that scores 0.83 overall has not shown anything — it barely beats a string search. That is why this project's target is the hard subset.
docs/ANNOTATION_GUIDELINES.md).data/annotated/round1/label_disputes.csv)
— a finding about the corpus, not something silently "fixed".Before any model was trained, we checked that the label cannot be guessed from the answer's surface properties — its length, punctuation, digit share, script mix, capitalisation.
| Check | Result | Requirement |
|---|---|---|
| Metadata-only classifier, macro-F₁ (all records) | 0.534 | < 0.60 |
| Metadata-only classifier, macro-F₁ (usable records) | 0.514 | < 0.60 |
This is a gate: had it failed, the corpus itself would be broken and every later number meaningless. It
must pass before anything is trained (python src/audit.py --data data/splits --probe metadata; exits
non-zero on failure).
The MIT licence in this repository covers the code only. The text under data/ is derived from Bengali
Wikipedia (CC BY-SA 4.0 — attribution and share-alike apply) and from BCS/school question banks whose
redistribution terms have not been confirmed. The full register, including the open questions, is in
data/SOURCES.md. Please read it before reusing or redistributing any data file.
Standard NLP recipes are written for English, and applied to Bengali they silently destroy the data:
| English-oriented habit | What it does to Bengali |
|---|---|
re.sub(r'[^A-Za-z\s]', '', text) ("strip non-letters") | Deletes every Bengali character and every digit — the text becomes empty |
| English stop-word list, Porter stemmer, WordNet lemmatiser | Match nothing (or the wrong things) in Bengali |
| Dropping "small" words | Drops negation (না, নয়, নি, নেই, নাই), which is exactly what flips a claim into its opposite |
| Stripping digits | Destroys the very signal for numeric errors |
So this project has its own Bengali text tools (src/text_bn.py).
য় as
one code point vs. য + ়). Everything is normalised so identical text really is identical.configs/bn_protected_words.txt)
that is never removed: the negation words and number words.Nine safety rules are checked on the real data (python src/text_bn.py --check), and the module has its own
test file. Try it: python src/text_bn.py "কলেজের ছাত্ররা বইটি পড়েনি।" prints every cleaning step.
We tested five cleaning recipes plus one deliberately harmful one:
| Variant | What it does |
|---|---|
| V0 | Split on spaces only, no cleaning (lower bound) |
| V1 | Clean + tokenise — the default |
| V2 | V1 + remove stop words (protected words kept) |
| V3 | V1 + stemming (protected words kept) |
| V4 | V1 + stop words removed + stemming |
| V2-demo | V2 with no protection — negation deleted, to show the damage |
| Model | Variant | Overall | Hard | Contradiction | Numeric | Relational | Features |
|---|---|---|---|---|---|---|---|
| TF-IDF + LogReg | V0 | 0.542 | 0.592 | 0.742 | 0.558 | 0.586 | 259,454 |
| TF-IDF + LogReg | V1 | 0.549 | 0.610 | 0.712 | 0.561 | 0.581 | 215,601 |
| TF-IDF + LogReg | V2 | 0.544 | 0.596 | 0.710 | 0.548 | 0.581 | 209,650 |
| TF-IDF + LogReg | V3 | 0.551 | 0.610 | 0.742 | 0.573 | 0.571 | 207,385 |
| TF-IDF + LogReg | V4 | 0.549 | 0.610 | 0.726 | 0.555 | 0.586 | 201,504 |
| TF-IDF + LogReg | V2-demo | 0.530 | 0.609 | 0.528 | 0.549 | 0.570 | 209,054 |
| Skip-gram + XGBoost | V0 | 0.522 | 0.557 | 0.558 | 0.549 | 0.541 | 90.1% cov. |
| Skip-gram + XGBoost | V1 | 0.519 | 0.557 | 0.513 | 0.539 | 0.493 | 94.0% cov. |
| Skip-gram + XGBoost | V2 | 0.498 | 0.501 | 0.528 | 0.509 | 0.440 | 92.7% cov. |
Full table: results/tables/table6_preprocessing_ablation.csv.
Two findings:
contradiction errors collapses
from 0.712 to 0.528. The damage only appears when you slice by error type, which is the strongest
argument in the project for reporting sliced results. Stop-word removal also actively hurts Skip-gram
(0.501 on hard, its worst cell — its best variant is V0, no cleaning at all).Every model gets its input from src/preprocess.py in one of three formats:
| Format | Layout | Used for |
|---|---|---|
| F1 | question + answer | the only option when there is no passage |
| F2 | passage + question + answer, as one flat text | word-counting and from-scratch models |
| F3 | passage ‖ question + answer, as two segments | pretrained encoders — the way a BERT-style model expects "does this passage support this claim?" |
$$x = \texttt{[CLS]};\oplus;c;\oplus;\texttt{[SEP]};\oplus;q;\oplus;a;\oplus;\texttt{[SEP]}\qquad(c\text{ omitted when there is no passage})$$
Each part is labelled in Bengali (প্রসঙ্গ: context / প্রশ্ন: question / উত্তর: answer) so the model always knows where each begins.
Models cannot read unlimited text, so input is capped at 256 tokens. Two rules govern what gets cut:
In practice this rarely matters: at 256 tokens only 1.5% of passages need cutting at all, and measurable loss of supporting evidence affects 7 of 1,634 checkable pairs (0.4%). (At 128 tokens that would be 7.2% — which is why 256 was chosen.)
We do not present one model. We present a ladder: each rung adds one capability, so every gain (or lack of one) can be attributed to something specific. Progress is deliberately not assumed to be monotonic — and it isn't.
flowchart LR
R0["No learning<br/>string rules<br/><b>0.591</b>"] --> R1["Counting words<br/>TF-IDF, Naive Bayes,<br/>LogReg, SVM<br/><b>0.610</b>"]
R1 --> R2["Word vectors<br/>Skip-gram from<br/>our own text<br/><b>0.529</b>"]
R2 --> R3["Recurrent nets<br/>RNN, BiRNN, BiLSTM,<br/>+ attention<br/><b>0.605</b>"]
R3 --> R4["Transformer<br/>trained from scratch<br/><b>0.433</b>"]
R4 --> R5["Pretrained encoders<br/>BanglaBERT, MuRIL,<br/>XLM-R<br/><b>0.825</b>"]
style R0 fill:#f3f4f6,stroke:#6b7280,color:#000
style R4 fill:#fee2e2,stroke:#e34948,color:#000
style R5 fill:#0ca30c,color:#fff,stroke:none
Best hard-subset macro-F₁ per family, dev split.
Everything below the pretrained rung is trained only on our 6,134 training items, on a laptop CPU, in seconds to minutes. They answer one question: how far can you get using nothing but this data?
| Rule | How it works | Hard-subset score |
|---|---|---|
| Exact string match | Is the answer's text literally in the passage? | 0.487 (dev) |
| Fuzzy string match (V6) | Is it nearly in the passage — edit distance ≤ 40.5% of its length? | 0.591 (dev) |
These are the bars. The fuzzy rule was tuned on train (never dev) and is the number every learned model must
beat. Code: src/features.py (python src/features.py --baseline).
Idea in plain words. Count which words appear in an item, then learn which words tend to go with wrong answers. No understanding of order or meaning — a "bag" of words.
src/train_classical.py. Cross-checked against a from-scratch Naive Bayes
(--reference-check): 100% agreement on 200 dev records.Result: TF-IDF + LogReg and TF-IDF + SVM tie at 0.610 on hard (McNemar p = 1.000 — see §6.4). One model, BoW + SVM, is flagged unreliable: raw counts run up to 55 with no ceiling and the SVM never converges even after 20,000 iterations; TF-IDF rescales everything to ≤ 1 and settles in seconds. We report its score but mark it — why it fails shows exactly what TF-IDF's weighting is for.
Idea. Build one model of "how correct answers are written" and one of "how wrong ones are", each predicting the next character from the previous few. Then ask which model finds a new answer more likely.
Result: the answer-only character model scores 0.605 on hard (0.529 overall). Its answer-style classifier (M11b) reaches 0.529–0.546 macro-F₁ overall — essentially the same as the answer-only probe (0.542), i.e. there is no generator fingerprint in the answer's writing style that gives the label away.
Idea. Compare the answer with its own passage and question using seven hand-made numbers: whether the answer appears verbatim, token overlap, edit distance to the best-matching piece of the passage, how "passage-like" the answer looks to a language model, how much it repeats the question, answer length, and digit share. Feed them to Logistic Regression.
Rule: features are computed from a record and only its own question and passage — never by comparing it with other records. Anything else is retrieval.
Result — and why this model matters more than its score:
| Overall | Hard | |
|---|---|---|
| Similarity features + LogReg | 0.730 (best overall of any non-pretrained system) | 0.490 (worse than guessing) |
This model learned the string-match shortcut, not the task. Judged by the overall number it looks like the project's best result. Judged on the hard subset it's nearly the worst. A single averaged score would have presented it as a triumph; splitting by measured difficulty exposes it immediately.
Idea. Learn a list of 200 numbers per word by reading text and noticing which words keep similar company — so "similar" words get similar numbers. Average them to represent a whole answer.
gensim) has no build for Python 3.14, so Skip-gram is implemented from scratch in
NumPy (src/skipgram.py), using negative sampling (5 fake neighbours per real one) instead
of a full softmax over the vocabulary, which would be far too slow.python src/skipgram.py --check): neighbours are sensible — e.g. ১৯৭১ → মার্চ, জিয়াউর;
সরকার → মুজিবনগর, প্রবাসী.Result: 0.500–0.529 on hard — below simple word counting. Averaging word vectors throws away word order, and 6,134 items is too little text to learn good vectors.
Idea. Read the text in order, one word at a time, keeping a running "memory" — so unlike a bag of words these models can see sequence.
flowchart LR
T["Token ids<br/>passage, question, answer"] --> E["Embeddings 200-d<br/>initialised from our Skip-gram"]
E --> F["LSTM reading<br/>left to right"]
E --> B["LSTM reading<br/>right to left"]
F --> H["Hidden states<br/>one per word"]
B --> H
H --> A["Attention:<br/>weight each word<br/>by how much it matters"]
A --> D["Dropout 0.3"]
D --> L["Linear layer"]
L --> S["Sigmoid<br/>P(faithful)"]
style A fill:#dbeafe,stroke:#2a78d6,color:#000
style S fill:#0ca30c,color:#fff,stroke:none
Four variants, each adding one capability:
| Model | Adds |
|---|---|
| RNN | Reads left → right, classifies from the final state |
| BiRNN | Also reads right → left, joins both endings |
| BiLSTM (2 layers) | LSTM cells with a longer, gated memory |
| BiLSTM + attention | Can also look back at every word it read, weighting the important ones (--attention shows where it looked) |
Settings: hidden size 128, dropout 0.3, Adam lr 1e-3, batch 32, up to 20 epochs, early stopping on dev loss
(patience 3), gradient clipping at 5; all on CPU. Code: src/train_neural.py.
python src/train_neural.py --check demonstrates each)<PAD> steps and its "final" state is really the state after running on padding.
Here every batch is packed, so the final state is the one at the real last word.Each is guarded by a test that fails if the fix is removed (mutation-tested).
Result: the simplest model wins — RNN 0.605 on hard (overall 0.560 ± 0.009). Bidirectionality, stacking and attention each cost a little; none beats the best word-counting model (0.610). With so little data, extra capacity has nothing to learn from.
Idea. The same architecture as BanglaBERT (self-attention layers), but with no pretraining — random
starting weights, trained only on our data. 2 layers, 4 heads, d_model 128, sine/cosine position signal,
masked mean pooling (src/train_neural.py).
Result — the weakest and least stable system in the project: 0.433 on hard; overall 0.465 ± 0.072, and it
collapsed outright on seed 2024 (0.364). The instability is flagged by the code (collapse_warning()), not hidden.
This is the control experiment. Compare it with BanglaBERT: same family of architecture, differing only in whether it has ever read Bengali before.
Idea. Start from a Transformer that has already read enormous amounts of text, then fine-tune it on our task. It arrives already knowing Bengali grammar and a great deal about the world.
flowchart BT
IN["Input: CLS, passage, SEP, question, answer, SEP"] --> EMB["Word + position embeddings"]
EMB --> ATT["Multi-head self-attention<br/>every word looks at every other word"]
ATT --> FFN["Feed-forward + LayerNorm"]
FFN -.->|"repeated 12 times"| ATT
FFN --> POOL["Take the CLS vector h"]
POOL --> HEAD["One linear layer"]
HEAD --> OUT["Sigmoid: P(faithful)"]
style OUT fill:#0ca30c,color:#fff,stroke:none
Each layer applies self-attention, letting every word decide which other words matter:
$$\mathrm{Attention}(Q,K,V)=\mathrm{softmax}!\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V$$
That is exactly the ability this task needs — to connect "১৯৬৮" to the denomination rather than to the
Edinburgh congregation. The classification head is a single linear layer over the [CLS] summary vector,
trained with binary cross-entropy:
class FaithfulnessClassifier(nn.Module):
"""Pretrained encoder + one linear layer -> a single score."""
def __init__(self, encoder, hidden_size, dropout=0.1):
super().__init__()
self.encoder = encoder
self.dropout = nn.Dropout(dropout)
self.head = nn.Linear(hidden_size, 1)
def forward(self, input_ids, attention_mask):
out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
cls = out.last_hidden_state[:, 0] # the [CLS] summary vector
return self.head(self.dropout(cls)).squeeze(-1)
loss_fn = nn.BCEWithLogitsLoss() # sigmoid applied inside: more numerically stable
Three encoders compared — they differ mainly in how much of their pretraining was Bengali:
| Encoder | Pretrained on | Hard-subset macro-F₁ |
|---|---|---|
| BanglaBERT | Bengali only | 0.825 |
| MuRIL | 17 Indian languages | 0.771 |
| XLM-R (base) | 100 languages | 0.623 |
More languages did not help; a larger share of Bengali did. (BERT-style code for mBERT is also included but was not part of the reported runs.)
| Fine-tuning setting | Value |
|---|---|
| Optimiser | AdamW, lr 2e-5, weight decay 0.01, warm-up 0.1 |
| Batch / max epochs | 32 / 5 (best epoch was 3) |
| Max sequence length | 256 tokens |
| Early stopping | dev loss, patience 2 |
| Hardware | 1 × NVIDIA T4 (16 GB), ≈ 40 min per run |
A detail that is easy to get wrong. BanglaBERT was pretrained on text passed through a specific Bengali normaliser. Feeding it raw text costs accuracy silently — nothing crashes, the score is just lower. This code therefore refuses to run BanglaBERT unless the normaliser is installed:
pip install git+https://github.com/csebuetnlp/normalizer.
Code: src/train_transformer.py; GPU driver: notebooks/02_bert_gpu.ipynb (Colab + Drive).
For context, a 31-billion-parameter instruction-tuned model (Gemma-4-31B) was run through an
external pipeline (a Kaggle notebook: notebooks/final-notebook-bhibranti.ipynb;
outputs in results/gemma_reference/). It reaches 0.908 on the hard dev subset,
above BanglaBERT's 0.825. This is a reference point, not a comparison, for three reasons:
| Why it isn't comparable | Detail |
|---|---|
| Size | Roughly 280× larger than BanglaBERT |
| It was allowed to retrieve | It could search public question banks — exactly what our no-retrieval rule forbids our own systems. About 1,398 of 8,000 questions were found word-for-word in those banks, so part of its score is lookup, not judgement. |
| Hand-written rules ran first | 1,863 items were resolved by rules before the model was consulted at all |
On the 160 hard dev items its retriever could not match, it scores 0.894 — the more meaningful figure, and still not directly comparable. The comparison shows what a very large model with outside access can reach on this data. It does not show our approach is worse under equal conditions, because the conditions are not equal.
Every system — from the two-line string rule to BanglaBERT — is scored by one shared file
(src/evaluate.py) that was written before the first model, precisely so that no model can
be judged on its own terms.
Macro-F₁ computes an F₁ score (a blend of precision and recall) for each class separately, then averages the two classes equally:
$$P_k=\frac{TP_k}{TP_k+FP_k},\quad R_k=\frac{TP_k}{TP_k+FN_k},\quad F_1^{(k)}=\frac{2P_kR_k}{P_k+R_k},\quad \text{macro-}F_1=\tfrac12\big(F_1^{(0)}+F_1^{(1)}\big)$$
Why not plain accuracy? Macro-averaging refuses to reward laziness. Take a "system" that ignores its input and always answers faithful. On our perfectly balanced corpus:
| Class | Precision | Recall | F₁ |
|---|---|---|---|
| Faithful (1) | 0.50 | 1.00 | 0.667 |
| Hallucinated (0) | — | 0.00 | 0.000 |
| Macro average | 0.333 |
Plain accuracy would flatter it at 0.50 even though it never catches a single hallucination. 0.333 is the floor every system must clear before it has demonstrated anything. (BLEU/ROUGE are absent on purpose: they measure text overlap for generation tasks; ours makes one binary decision per item.)
A single blended number is never reported alone. Every model is broken down by:
| Slice | Why |
|---|---|
| With passage / without passage | Two different tasks; one average hides both |
| Easy / hard | Hard is the number the project is judged on |
| By subject | Law/science/BCS need outside knowledge — a collapse there is a finding, not a defect |
| By error type | Reports a detection rate (a type slice holds only wrong answers, so macro-F₁ is undefined there) |
The string baselines are recomputed on exactly the records being scored, never copied from another table, and slices under 60 records are marked "small".
results/experiment_log.csv,
the single source of truth. The demo's scoreboard is read live from it, so it can never drift from the paper.Two scores that differ by 0.01 may just be noise. We use McNemar's test, which looks only at the items where the two models disagree and asks whether one wins those disagreements far more often than chance (50/50).
| Comparison (218 hard dev items) | They disagree on | Winner wins | p-value | Verdict |
|---|---|---|---|---|
| TF-IDF + LogReg vs. TF-IDF + SVM | 28 | 14 each | 1.000 | genuine tie |
| BanglaBERT vs. the fuzzy rule | 81 | BanglaBERT 66, rule 15 | < 0.001 | real difference |
Confidence intervals are computed by resampling pairs, not items — the two answers to one question share a passage and are not independent observations, so treating them as independent would give an interval that is too narrow and an unjustified claim of significance.
All numbers: macro-F₁ on the development split. "Hard" = items with a passage where string matching fails.
| Family | System | Overall | Hard | Seeds |
|---|---|---|---|---|
| No learning | Exact string match | — | 0.487 | — |
| No learning | Fuzzy string match | — | 0.591 ← the bar | — |
| Counting words | TF-IDF + LogReg | 0.549 | 0.610 | 1 |
| Counting words | TF-IDF + SVM | 0.548 | 0.610 | 1 |
| Counting words | Character n-gram LM | 0.529 | 0.605 | 1 |
| Counting words | Bag of words + Naive Bayes | 0.529 | 0.596 | 1 |
| Counting words | TF-IDF + Naive Bayes | 0.527 | 0.592 | 1 |
| Counting words | Bag of words + LogReg | 0.559 | 0.591 | 1 |
| Counting words | Bag of words + SVM (unreliable) | 0.542 | 0.559 | 1 |
| Word vectors | Skip-gram (averaged) + XGBoost | 0.503 ± 0.012 | 0.529 | 3 |
| Word vectors | Skip-gram (IDF-weighted) + XGBoost | 0.521 ± 0.007 | 0.518 | 3 |
| Word vectors | Skip-gram (IDF-weighted) + LogReg | 0.506 ± 0.003 | 0.514 | 3 |
| Word vectors | Skip-gram (averaged) + LogReg | 0.508 ± 0.002 | 0.500 | 3 |
| Word vectors | Similarity features + LogReg | 0.730 | 0.490 (worse than guessing) | 1 |
| Recurrent | RNN | 0.560 ± 0.009 | 0.605 | 3 |
| Recurrent | BiLSTM + attention | 0.551 ± 0.003 | 0.598 | 3 |
| Recurrent | BiLSTM (2 layers) | 0.551 ± 0.004 | 0.585 | 3 |
| Recurrent | BiRNN | 0.553 ± 0.001 | 0.583 | 3 |
| From scratch | Transformer, no pretraining | 0.465 ± 0.072 | 0.433 | 3 |
| Pretrained | XLM-R (base) | 0.594 | 0.623 | 1 |
| Pretrained | MuRIL | 0.779 | 0.771 | 1 |
| Pretrained | BanglaBERT | 0.820 | 0.825 | 1 |
| Reference (not comparable) | Gemma-4-31B, with retrieval | 0.898 | 0.908 | — |
1. Only BanglaBERT clearly beats the rules — and it isn't luck. 0.825 vs. 0.591 on hard; McNemar p < 0.001 (see §6.4).
2. Without pretraining, nothing gets close. The best system that never saw outside text reaches 0.610 — barely above the 0.591 rule. Counting words, custom word vectors and recurrent networks all land in the same narrow 0.49–0.61 band. With 6,134 training items that is the expected outcome, not an implementation failure: there simply isn't enough data to learn Bengali and the task at once.
3. Monolingual pretraining beats multilingual pretraining. BanglaBERT (0.825) > MuRIL (0.771) > XLM-R (0.623).
4. What pretraining is worth. The from-scratch Transformer (0.433 hard; 0.465 overall) and BanglaBERT (0.825; 0.820) are the same kind of network. The +0.355 overall gap is a direct measurement of what pretraining contributes on this task.
5. A model that looks good and isn't. Similarity features: best overall (0.730), near-worst hard (0.490). Never report an unsplit score.
The pattern is consistent and explainable. Reading comprehension scores 0.963 because the evidence is in the passage — the model only has to check it. Civil-service (BCS) questions score 0.517, barely above guessing, because answering them requires knowing a fact that appears nowhere in the input. A closed-book model cannot verify what it was never told. We report this rather than hide it, because it shows precisely where the approach reaches its limit.
Contradictions and wrong numbers are caught almost perfectly — both leave a visible trace in the text (a negation word; a digit that doesn't match). Fabrication is caught least often (0.711) and is also the most common error type (42.3% of all wrong answers) — the largest remaining source of error, and the one category that cannot be resolved by comparing against a passage, because a fabricated answer has no counterpart there to compare against.
python src/serving.py --build # once: fits every model and caches it (CPU only; the 12 classical
# models take ~6 min, the neural models a few more)
python src/app.py # then open http://127.0.0.1:8000
Type (or click) a question, an answer and an optional passage. Every model votes, and the page shows the evidence behind each vote next to the two no-learning rules — because a model that agrees with a string matcher has not demonstrated anything. Models are listed by past performance on hard questions, not by confidence today.

Page through real train and dev records — context, question, answer, label 1/0 — as plain selectable text to copy into the checker, or click check it to prefill it. (The test split is deliberately not browsable.)

The full scoreboard, ranked by hard-subset score, with the baselines to beat and plain-language notes on how to read
it. Numbers are read live from results/experiment_log.csv.

What is and isn't served. The demo loads 12 classical + 5 neural models, plus BanglaBERT if its weights are in
models/banglabert_s42/and the normaliser is installed. Weights are large and git-ignored: produce them withnotebooks/02_bert_gpu.ipynbon a GPU. MuRIL and XLM-R scores appear in the experiment log and paper but are not wired into the demo.
requirements.txt.python -m venv .venv && source .venv/Scripts/activate # Windows / Git Bash
pip install -r requirements.txt
# Only for BanglaBERT (it refuses to run without this):
pip install git+https://github.com/csebuetnlp/normalizer
python src/audit.py --data data/splits --probe metadata # 1. the data gate passes (0.514 < 0.60)
python src/evaluate.py --baselines --table # 2. the no-learning rules reproduce
python src/train_classical.py --model tfidf_logreg # 3. one real model, full report
python src/serving.py --build && python src/app.py # 4. the demo
Or open notebooks/00_full_walkthrough.ipynb — the whole pipeline end to end
with charts (~9 minutes) — or notebooks/INFERENCE.ipynb.
# --- Data (deterministic, seed 42) --------------------------------------------------
python src/build_bn_pool.py # raw -> data/interim/bn_pool.jsonl
python src/build_corpus.py # interim -> data/corpus/ + data/splits/
python src/merge_annotation.py # RE-RUN after any corpus rebuild: restores `excluded` + error types
python src/audit.py --data data/splits --probe metadata # THE GATE (exits non-zero if it fails)
# --- Annotation checks ---------------------------------------------------------------
python src/score_agreement.py --a <sheetA.csv> --b <sheetB.csv> # pilot kappa
python src/score_test_agreement.py --a-dir data/annotated/round1/test/tawhid \
--b-dir data/annotated/round1/test/shejan # kappa = 0.865
# --- Text tools & features -------------------------------------------------------------
python src/text_bn.py --check # 9 safety rules on the real data
python src/features.py --check # feature sanity rules
python src/features.py --baseline # tune + score the fuzzy rule
python src/preprocess.py --check # input-format guarantees
# --- Models ------------------------------------------------------------------------------
python src/skipgram.py --train # ~3 min: word vectors from our own text
python src/train_classical.py --all --seeds --log # every classical model, 3 seeds, recorded
python src/train_classical.py --ablation # the preprocessing experiment
python src/train_neural.py --all --seeds --log # RNN family + Transformer, 3 seeds
python src/train_neural.py --check # prove the three fixes to naive implementations
python src/train_transformer.py --model csebuetnlp/banglabert --seed 42 # GPU
# --- Scoring -----------------------------------------------------------------------------
python src/evaluate.py --baselines --table # rule baselines on dev -> results/tables/
python src/evaluate.py --demo # what the numbers mean, on made-up models
# Every trained model is scored through evaluate.py inside its own training script
# (add --log to record the run in results/experiment_log.csv).
# The test split is opened EXACTLY ONCE, for the final system, and is guarded:
# --split test refuses to run without --final.
# --- Demo --------------------------------------------------------------------------------
python src/serving.py --build && python src/app.py
python -m pytest tests/ -q
Roughly 280 tests, one file per src/ module, each written to fail loudly if a documented rule is broken (the
neural-model tests are mutation-tested: removing any of the three fixes makes a named test fail). The tests need
no GPU and no downloads. Known issue: on Windows, one subprocess test in test_train_transformer.py currently
fails with a console-encoding (cp1252) decoding error in the test harness itself; it is unrelated to the code
under test and is present on the untouched code as well.
results/experiment_log.csv records every run: model, input format, preprocessing, seed, learning rate, batch,
epochs, dev/test score, and notes.Bhibranti/
├── src/ The pipeline: data building, text tools, features, every model, scoring, demo server
├── tests/ One test file per src/ module
├── notebooks/ Runnable walkthroughs (full pipeline, GPU training drivers, inference demo, LLM reference)
├── web/ Templates + stylesheet for the demo (served by src/app.py)
├── data/ Corpus, splits, annotation records, source register (load ONLY via src/splits.py)
├── configs/ Frozen record schema + Bengali word lists (stop words, protected words, suffixes)
├── results/ experiment_log.csv (every run), scored tables, the external Gemma reference run
├── docs/ Requirements, implementation guide, annotation guidelines, the paper, README charts
├── models/ (git-ignored) fine-tuned checkpoints
└── requirements.txt, LICENSE
src/)| Module | What it does |
|---|---|
| Data building | |
build_bn_pool.py | Cleans and de-duplicates the raw pool |
build_corpus.py | Builds pairs, filters, and splits by pair |
build_agreement_test.py, score_agreement.py | The 100-item blind pilot and its κ |
build_annotation_sheets.py, validate_annotation.py | Builds the annotation sheets; checks filled ones for the mistakes that actually happen |
score_test_agreement.py | Cohen's κ on the full test split |
merge_annotation.py | Human sheets → error types + excluded flags (never changes a label) |
llm_annotate.py | LLM second-opinion quality control (can flag, can never write a label) |
audit.py | Shortcut probes + structural checks — the gate |
splits.py | load_split("train") — the only sanctioned way to load data; drops excluded pairs |
| Text & features | |
text_bn.py | Bengali cleaning, tokeniser, stop words, stemmer, protected words |
preprocess.py | The three input formats F1/F2/F3; cuts the passage only |
features.py | Character n-gram LM, edit distance, similarity features, the fuzzy baseline |
| Models | |
train_classical.py | BoW/TF-IDF × NB/LogReg/SVM, character LM, Skip-gram × LogReg/XGBoost, similarity features |
skipgram.py | Skip-gram with negative sampling, written from scratch (NumPy) |
train_neural.py | RNN, BiRNN, BiLSTM, BiLSTM + attention, Transformer from scratch (PyTorch, CPU) |
train_transformer.py | Fine-tunes BanglaBERT / MuRIL / XLM-R / mBERT (refuses BanglaBERT without the normaliser) |
colab_setup.py | Mounts Drive and makes src/ importable from a Colab notebook |
| Scoring & demo | |
evaluate.py | The one scorer: metrics, slices, baselines, McNemar, pair-level bootstrap |
serving.py | Fits every model once and caches it; loads them for the demo |
app.py | The Flask demo (Check an answer / Browse data / Model scores) |
| Notebook | Purpose |
|---|---|
00_full_walkthrough.ipynb | The whole pipeline end to end with charts (~9 min, CPU) |
INFERENCE.ipynb | Every step run live, ending in a "type a question" demo |
01_neural_gpu.ipynb | GPU driver for the from-scratch neural models |
02_bert_gpu.ipynb | Fine-tunes the pretrained encoders on Colab (with Drive) |
final-notebook-bhibranti.ipynb | The external Gemma-4-31B reference pipeline (Kaggle) |
llm_annotation.ipynb | LLM-assisted quality-control pass over the annotations |
Notebooks are deliberately thin: they import the tested modules from src/ rather than re-implementing them, so a
notebook can never drift from the code the numbers came from.
| Path | What it is |
|---|---|
data/corpus/bn_v1/corpus.jsonl | The full corpus (4,480 pairs / 8,960 records) |
data/splits/{train,dev,test}.jsonl | The splits — but load them via src/splits.py, never directly |
data/annotated/ | Annotation sheets, agreement report, adjudication, label disputes |
data/raw/, data/interim/ | Source pool and cleaned pool (raw has duplicates and no schema — don't load it) |
data/SOURCES.md | Where every source came from and its licence status |
data/DATASET_AUDIT.md | Audit of the source pool |
from splits import load_split # run from src/, or add src/ to sys.path
train = load_split("train") # excluded pairs already removed
dev = load_split("dev")
# Do NOT load the test split until the single sanctioned final evaluation.
Reading the .jsonl files directly would silently train and score on the 180 pairs that humans found unusable.
| File | Contents |
|---|---|
docs/paper/main.tex | The paper (compile with XeLaTeX — it needs a Bengali font) |
docs/PRD.md | Requirements, gates and scope — the authority on what the project must and must not do |
docs/IMPLEMENTATION_GUIDE.md | Recipes, hyperparameters and code |
docs/ANNOTATION_GUIDELINES.md | The labelling rules (read before annotating) |
docs/PROJECT_WALKTHROUGH.md | Beginner-friendly, step-by-step story of the project |
docs/READING_ORDER.md | Suggested order to read the repo |
docs/proposal/ | The original project proposal |
CLAUDE.md, AGENT.md | Working notes and standing instructions for AI coding assistants; not needed to use the project |
No. A two-line string rule already scores 0.823 there. Look at the hard subset, where that rule scores 0.454 (exact) / 0.591 (fuzzy). Beating 0.591 there is what shows a model is checking, not matching text.
Treat it as leakage until proven otherwise. Check for the same question appearing in two splits (splits must be by
pair), run the answer-only probe, and make sure you loaded data through src/splits.py.
excluded field — run src/merge_annotation.py first"A corpus rebuild resets that field. Run python src/merge_annotation.py, then re-check the gate with
python src/audit.py --data data/splits --probe metadata.
Install the normaliser: pip install git+https://github.com/csebuetnlp/normalizer. The code refuses rather than
silently scoring lower. For the demo, the weights must also be in models/banglabert_s42/.
/browse is a 404 (Windows)You are almost certainly talking to a stale server process. On Windows a second server can bind the same port
without an error, and the old one keeps answering. Find and kill whatever holds port 8000
(netstat -ano | findstr :8000, then taskkill /PID <pid> /T /F) and start it again. src/app.py auto-reloads on
code edits (--no-reload disables that).
gensim used for Skip-gram?It has no build for Python 3.14. Skip-gram is small enough to write by hand, so src/skipgram.py does it in NumPy.
No. Subjects like civil service (BCS) require outside knowledge that appears nowhere in the input, so a closed-book model cannot verify them. Per-subject collapse there is an expected finding, not a defect.
No. It is a support tool, not an authority — see §12.
data/SOURCES.md.@misc{banghallu2026,
title = {BangHallu: A Bengali Corpus for Hallucination Detection
in Large Language Models},
author = {Hasan, Tawhidul and Shejan, Md. Saif Ahmed},
year = {2026},
note = {Final Project Report, Khulna University of Engineering \& Technology}
}
Code is released under the MIT licence. Data under data/ is not covered by that licence: it is
derived from Bengali Wikipedia (CC BY-SA 4.0 — attribution and share-alike apply) and from question banks whose
redistribution terms are still being confirmed. See data/SOURCES.md before reusing any data file.
Related work we build on and compare against: BenHalluEval (Bengali generation-side hallucination), Poly-FEVER
(multilingual fact verification), BanglaQuAD and BEnQA (Bengali QA resources), and the encoders BanglaBERT, MuRIL and
XLM-R. Full references are in docs/paper/main.tex.
Authors: Tawhidul Hasan and Md. Saif Ahmed Shejan, Department of Computer Science and Engineering, Khulna University of Engineering & Technology (KUET). Supervised by Dr. K. M. Azharul Hasan and Md. Nazirulhasan Shawon.
Bengali-English code-mixed hallucination detection dataset and benchmark. We will create and manually annotate a small corpus of LLM-generated responses, classify different types of hallucinations, and compare N-gram, Skip-gram, RNN, LSTM, and BERT-based approaches.
Jupyter Notebook
0
17 commits
updated Sep 20, 2026
A corpus of Bengali question–answer pairs, a ladder of 20 detection systems from a two-line rule up to a fine-tuned BanglaBERT, and one honest evaluation procedure to compare them all.
Two names, one project.
BangHalluis the name used in the paper and in the demo.Bhibranti(ভ্রান্তি, Bengali for "delusion") is the repository's codename. They are the same thing.
Language models — the programs behind chatbots — sometimes give an answer that sounds completely natural but is simply untrue. That is called a hallucination. It is dangerous because nothing in the wording warns you: a fluent wrong answer looks exactly like a fluent right one, and a student asking a question usually cannot check it, because they are asking precisely because they don't know.
This project asks one narrow question, in Bengali:
Given a question, a proposed answer, and sometimes a passage of supporting text — is the answer trustworthy, or made up?
Think of a teacher marking a student's answer. Sometimes the teacher has the textbook page open (a passage); sometimes they must mark from memory. We build software that plays that teacher, and — the part that matters — we measure how much of its skill is real understanding and how much is a cheap trick.
What we built
What we found, in four lines
Label convention — the reverse of most papers.
1= faithful / correct,0= hallucinated / made up. The positive class is the truthful one, everywhere in this repository.
Skip this section if you already work in NLP.
When a language model doesn't know something, it doesn't say so — it produces the most plausible-sounding text. Ask it what year a mosque was founded and it will give you a year, fluently and confidently, whether or not it is correct. That confident falsehood is a hallucination.
For every item — one question plus one proposed answer — the system outputs one of two labels:
| Label | Name | Meaning |
|---|---|---|
1 | Faithful | The answer is correct (and supported by the passage, when there is one) |
0 | Hallucinated | The answer is made up |
Formally, with $q$ = question, $a$ = proposed answer, $c$ = supporting passage (which may be absent):
$$f_\theta(q,,a,,c);\longrightarrow;{0,,1}$$
Three real examples show the range of difficulty (English glosses in brackets):
| Example | Why it is that way | |
|---|---|---|
| Easy, with a passage |
Q: ২০১০ সালে ইংল্যান্ডের বাংলাদেশ সফরে টেস্টে সাকিব কত উইকেট নিয়ে সর্বোচ্চ উইকেট শিকারী হন? (How many wickets did Shakib take to become the top wicket-taker in the Tests of England's 2010 tour of Bangladesh?) Correct: ৯ উইকেট (9 wickets) Made up: ১০ উইকেট (10 wickets) | The passage says "৯ উইকেট". The wrong answer's text never appears in the passage, so a plain string search settles it. No understanding needed. |
| Hard, with a passage |
Q: মেট্রোপলিটান কমিউনিটি চার্চ অফ এডিনবার্গ কত সালে প্রতিষ্ঠিত হয়? (In which year was the Metropolitan Community Church of Edinburgh established?) Correct: ১৯৬৮ (1968) Made up: ১৯৯৫ (1995) | Both years appear in the passage. 1968 is when the denomination was founded; 1995 is when the Edinburgh congregation began worshipping independently. String matching finds both and cannot choose. Only a system that tracks which year belongs to which entity gets it right. |
| Hard, no passage |
Q: ‘উত্তর’ ও ‘উত্তর’ শব্দ দুটির সন্ধিতে কোন শব্দ গঠিত হয়? (Which word is formed by joining 'uttar' and 'uttar'?) Correct: উত্তরোত্তর Made up: উত্তরউত্তর | No passage to check against. The wrong answer is a plausible-looking but invalid form, made by naive concatenation. The system must know Bengali grammar. |
| Term | Plain-language meaning |
|---|---|
| Item | One question paired with one proposed answer. The unit the system judges. |
| Pair | One question with both of its answers — the correct one and the made-up one. One pair = two items. |
| Passage (context) | A short piece of Bengali text supplied with the question. Present for about 6 items in 10. |
| Has-context / no-context | Items with a passage vs. without. Two genuinely different tasks; we always report them separately. |
| Easy / hard | Measured, not guessed: an item is easy if the string-matching shortcut separates its two answers, hard if it doesn't. |
| Baseline | A simple reference any real system must beat. Ours are rules that involve no learning. |
| Macro-F₁ | Our score, 0 → 1, higher is better. Always answering "faithful" scores 0.333 — the floor. (§6) |
| Training / dev / test split | Train: what models learn from. Dev: what we tune and compare on. Test: locked away, opened once at the very end. |
| Seed | The starting number of a random-number generator. We run every random model with three (42, 1337, 2024) and report mean ± spread. |
| Pretraining | Teaching a model general language by reading enormous amounts of text before it ever sees our task. |
| Fine-tuning | Then continuing to train that pretrained model on our specific task. |
| BERT-family encoder | A pretrained Transformer network that turns text into numbers. BanglaBERT, MuRIL and XLM-R are three of them. |
| Cohen's κ (kappa) | How much two annotators agree beyond chance. 0 = chance, 1 = perfect. ≥ 0.6 is "substantial". |
| McNemar's test | A significance test for "is model A really better than model B?", using only the items they disagree on. (§6.4) |
| Retrieval / RAG | Looking things up (web, database, similar examples) while answering. Forbidden in this project. |
These constraints hold for the entire project and are what make the numbers meaningful:
| Rule | Why |
|---|---|
| No retrieval at inference. The system sees the question, the passage stored with that item (if any) and the candidate answer — nothing else. No web search, no database lookup, no fetching similar training examples. | The corpus is partly drawn from public question banks. A system allowed to search them could score well without judging anything. §5.8 shows exactly this happening. |
| Bengali only, question-answering only. No fill-in-the-blank items. | Fill-in-the-blank rewards copying a span out of the passage. A plain string matcher scored 0.929 on it, which would have inflated every result. |
| Split by pair, never by item. | Otherwise the correct answer could land in training and the wrong one in testing, and a model could score by memorising the question. |
| The test split is opened exactly once, for the final system. | Otherwise you slowly tune to it, and it stops being an honest estimate. |
| Every result reported next to its baselines, split by easy/hard. | A single averaged number hides everything that matters here. |
| Nothing is re-rolled until it looks good. Seeds are fixed (42, 1337, 2024). | Otherwise variance is being mistaken for progress. |
flowchart LR
RAW["Raw sources<br/>62,084 items"] --> CLEAN["Clean and<br/>de-duplicate<br/>56,480"]
CLEAN --> PAIRS["Build pairs<br/>4,480 pairs"]
PAIRS --> LABEL["Human labelling<br/>kappa = 0.865"]
LABEL --> SPLIT["Split by pair<br/>train / dev / test"]
SPLIT --> GATE{"Shortcut audit<br/>probe below 0.60?"}
GATE -->|"yes: 0.514"| LADDER["Train the ladder<br/>20 systems"]
LADDER --> SCORE["One shared scorer<br/>evaluate.py"]
SCORE --> DEMO["Demo and<br/>paper tables"]
style GATE fill:#fff3cd,stroke:#b58900,color:#000
style DEMO fill:#0ca30c,color:#fff,stroke:none
| Stage | What happens | Script | Produces |
|---|---|---|---|
| 1. Collect | Raw Bengali QA records from Wikipedia-grounded and question-bank sources | (done; see data/SOURCES.md) | data/raw/ |
| 2. Clean | Normalise, de-duplicate | src/build_bn_pool.py | data/interim/bn_pool.jsonl |
| 3. Build | Form pairs, filter, split by pair | src/build_corpus.py | data/corpus/, data/splits/ |
| 4. Annotate | Human labels, agreement, adjudication | src/build_annotation_sheets.py, src/score_test_agreement.py, src/merge_annotation.py | data/annotated/ |
| 5. Audit | Prove the label can't be guessed from surface features | src/audit.py | pass / fail gate |
| 6. Prepare text | Bengali cleaning, tokenising, input formats | src/text_bn.py, src/preprocess.py | model inputs |
| 7. Train | Every rung of the ladder | src/train_classical.py, src/train_neural.py, src/train_transformer.py | fitted models, results/experiment_log.csv |
| 8. Score | One scorer, sliced every way | src/evaluate.py | metrics, tables |
| 9. Serve | Interactive demo | src/serving.py, src/app.py | web app |
Every stage is a separate script you can run on its own (python src/<name>.py --help).
We want to be exact about this, because it affects how the corpus should be interpreted.
provenance field saying how its
text was produced.src/llm_annotate.py), never to write a
label. Letting an LLM write the labels would be circular: a detector trained to reproduce an LLM's blind
spots measures nothing. The script physically cannot overwrite a label — where it disagrees, the row is
flagged for a human.| Step | Count |
|---|---|
| Raw records (14 source files) | 62,084 |
| After cleaning and de-duplication | 56,480 |
| Corpus built — pairs (× 2 = items) | 4,480 (8,960) |
| Removed after human review (broken item, or stored label confirmed wrong) | −180 pairs |
| Usable for training and scoring | 4,300 pairs (8,600 items) |
The 180 removed pairs are kept in the files, marked excluded = true; src/splits.py drops them
automatically — which is why all data must be loaded through it (see §10.4).
{
"id": "bnp_004674_1", // unique item id (…_0 = correct, …_1 = wrong, for this pair)
"pair_id": "bnp_004674", // both answers to one question share this
"question": "২০১০ সালে ইংল্যান্ডের বাংলাদেশ সফরে টেস্টে সাকিব কত উইকেট নিয়ে …?",
"context": "২০১০-এর ফেব্রুয়ারি-মার্চে দুই টেস্ট ও তিন ওয়ানডে খেলতে …", // "" when there is no passage
"candidate_answer": "১০ উইকেট", // the answer being judged
"reference_answer": "৯ উইকেট", // the known-correct answer for this question
"label": 0, // 1 = faithful, 0 = hallucinated
"condition": "has_context", // or "no_context"
"difficulty": "easy", // measured: does string matching separate this pair?
"hallucination_type": "numeric",// what kind of mistake (only for label 0)
"subject": "history",
"excluded": false // true = removed after human review
}
hallucination_type, type_source, annotator_1, annotator_2 and adjudicated all reveal the label.
They exist for reporting ("how did the model do on numeric errors?") and are never model inputs. The record
schema is frozen in configs/schema.json — fields may be added but never renamed,
removed or repurposed.
| Train | Dev | Test | |
|---|---|---|---|
| Pairs | 3,067 | 619 | 614 |
| Items | 6,134 | 1,238 | 1,228 |
| With a passage | 59.8% | 57.0% | 58.5% |
| Hard subset | 28.9% | 29.7% | 28.7% |
| Faithful (label = 1) | 50.0% | 50.0% | 50.0% |
The classes are exactly balanced by construction (every question contributes one of each), so plain accuracy would be a misleading score — see §6.1.
13 subjects, drawn from school and civil-service exam material. Nothing was dropped for being hard — law, science and BCS questions that need outside knowledge stay in, and we report on them honestly.
| Subject | Share of corpus |
|---|---|
| Reading comprehension | 28.2% |
| History | 20.1% |
| Grammar | 8.3% |
| Law | 7.1% |
| The other nine: mathematics, science, literature, antonyms, idiom meaning, civil service (BCS), vocabulary, synonyms, others | 1.7% – 4.9% each |
Answers are short (median 16 characters — often one name, date or number); questions have a median of 55 characters; passages a median of 259, up to 3,132. That shape drives a design decision in §4.4: when input is too long, cut the passage, never the answer.
Every wrong answer is labelled with the kind of mistake, so we can ask not just how often a model is wrong but where:
| Type | Meaning | Where | Share* |
|---|---|---|---|
| Fabricated | Invented, unsupported content | no-context | 42.3% |
| Numeric | Wrong number, year or quantity | has-context | 26.4% |
| Entity | Wrong person, place or named thing | has-context | 15.0% |
| Relational | Right things, wrong relationship between them | has-context | 8.9% |
| Contradiction | States the opposite of the passage | has-context | 7.5% |
*Of the 1,233 wrong answers in dev + test. Types are annotated for dev, test and a 20% spot-check of train; per-type results are therefore reported on dev/test only.
The most important design decision in the project is how difficulty is defined. We never ask a human whether a question feels hard. We ask a mechanical question:
Does plain string matching separate the two answers of this pair?
If the correct answer appears in the passage and the wrong one does not, the pair is easy — a trivial rule solves it. If the shortcut can't tell them apart, the pair is hard. About 29% of the corpus is hard.
flowchart TD
P["A pair: correct answer + wrong answer,<br/>with a passage"] --> Q{"Does string matching<br/>separate them?"}
Q -->|"Yes: one appears in the passage, the other doesn't"| E["EASY<br/>a trivial rule solves it"]
Q -->|"No: both appear, or neither does"| H["HARD<br/>the system must actually do something more"]
style E fill:#dbeafe,stroke:#2a78d6,color:#000
style H fill:#fee2e2,stroke:#e34948,color:#000
The same no-learning rule, measured on each subset — this table is the reason the hard subset exists:
| Rule (applied where a passage exists) | Macro-F₁ |
|---|---|
| Exact string match, all items | 0.823 |
| Exact string match, easy subset | 0.987 ← near-perfect, and it demonstrates nothing |
| Exact string match, hard subset | 0.454 ← worse than always guessing one class |
| Fuzzy string match, hard subset (allows ~40% of characters to differ, to absorb spelling and inflection) | 0.591 ← the real bar every learned model must clear |
| Always answer "faithful" | 0.333 |
A model that scores 0.83 overall has not shown anything — it barely beats a string search. That is why this project's target is the hard subset.
docs/ANNOTATION_GUIDELINES.md).data/annotated/round1/label_disputes.csv)
— a finding about the corpus, not something silently "fixed".Before any model was trained, we checked that the label cannot be guessed from the answer's surface properties — its length, punctuation, digit share, script mix, capitalisation.
| Check | Result | Requirement |
|---|---|---|
| Metadata-only classifier, macro-F₁ (all records) | 0.534 | < 0.60 |
| Metadata-only classifier, macro-F₁ (usable records) | 0.514 | < 0.60 |
This is a gate: had it failed, the corpus itself would be broken and every later number meaningless. It
must pass before anything is trained (python src/audit.py --data data/splits --probe metadata; exits
non-zero on failure).
The MIT licence in this repository covers the code only. The text under data/ is derived from Bengali
Wikipedia (CC BY-SA 4.0 — attribution and share-alike apply) and from BCS/school question banks whose
redistribution terms have not been confirmed. The full register, including the open questions, is in
data/SOURCES.md. Please read it before reusing or redistributing any data file.
Standard NLP recipes are written for English, and applied to Bengali they silently destroy the data:
| English-oriented habit | What it does to Bengali |
|---|---|
re.sub(r'[^A-Za-z\s]', '', text) ("strip non-letters") | Deletes every Bengali character and every digit — the text becomes empty |
| English stop-word list, Porter stemmer, WordNet lemmatiser | Match nothing (or the wrong things) in Bengali |
| Dropping "small" words | Drops negation (না, নয়, নি, নেই, নাই), which is exactly what flips a claim into its opposite |
| Stripping digits | Destroys the very signal for numeric errors |
So this project has its own Bengali text tools (src/text_bn.py).
য় as
one code point vs. য + ়). Everything is normalised so identical text really is identical.configs/bn_protected_words.txt)
that is never removed: the negation words and number words.Nine safety rules are checked on the real data (python src/text_bn.py --check), and the module has its own
test file. Try it: python src/text_bn.py "কলেজের ছাত্ররা বইটি পড়েনি।" prints every cleaning step.
We tested five cleaning recipes plus one deliberately harmful one:
| Variant | What it does |
|---|---|
| V0 | Split on spaces only, no cleaning (lower bound) |
| V1 | Clean + tokenise — the default |
| V2 | V1 + remove stop words (protected words kept) |
| V3 | V1 + stemming (protected words kept) |
| V4 | V1 + stop words removed + stemming |
| V2-demo | V2 with no protection — negation deleted, to show the damage |
| Model | Variant | Overall | Hard | Contradiction | Numeric | Relational | Features |
|---|---|---|---|---|---|---|---|
| TF-IDF + LogReg | V0 | 0.542 | 0.592 | 0.742 | 0.558 | 0.586 | 259,454 |
| TF-IDF + LogReg | V1 | 0.549 | 0.610 | 0.712 | 0.561 | 0.581 | 215,601 |
| TF-IDF + LogReg | V2 | 0.544 | 0.596 | 0.710 | 0.548 | 0.581 | 209,650 |
| TF-IDF + LogReg | V3 | 0.551 | 0.610 | 0.742 | 0.573 | 0.571 | 207,385 |
| TF-IDF + LogReg | V4 | 0.549 | 0.610 | 0.726 | 0.555 | 0.586 | 201,504 |
| TF-IDF + LogReg | V2-demo | 0.530 | 0.609 | 0.528 | 0.549 | 0.570 | 209,054 |
| Skip-gram + XGBoost | V0 | 0.522 | 0.557 | 0.558 | 0.549 | 0.541 | 90.1% cov. |
| Skip-gram + XGBoost | V1 | 0.519 | 0.557 | 0.513 | 0.539 | 0.493 | 94.0% cov. |
| Skip-gram + XGBoost | V2 | 0.498 | 0.501 | 0.528 | 0.509 | 0.440 | 92.7% cov. |
Full table: results/tables/table6_preprocessing_ablation.csv.
Two findings:
contradiction errors collapses
from 0.712 to 0.528. The damage only appears when you slice by error type, which is the strongest
argument in the project for reporting sliced results. Stop-word removal also actively hurts Skip-gram
(0.501 on hard, its worst cell — its best variant is V0, no cleaning at all).Every model gets its input from src/preprocess.py in one of three formats:
| Format | Layout | Used for |
|---|---|---|
| F1 | question + answer | the only option when there is no passage |
| F2 | passage + question + answer, as one flat text | word-counting and from-scratch models |
| F3 | passage ‖ question + answer, as two segments | pretrained encoders — the way a BERT-style model expects "does this passage support this claim?" |
$$x = \texttt{[CLS]};\oplus;c;\oplus;\texttt{[SEP]};\oplus;q;\oplus;a;\oplus;\texttt{[SEP]}\qquad(c\text{ omitted when there is no passage})$$
Each part is labelled in Bengali (প্রসঙ্গ: context / প্রশ্ন: question / উত্তর: answer) so the model always knows where each begins.
Models cannot read unlimited text, so input is capped at 256 tokens. Two rules govern what gets cut:
In practice this rarely matters: at 256 tokens only 1.5% of passages need cutting at all, and measurable loss of supporting evidence affects 7 of 1,634 checkable pairs (0.4%). (At 128 tokens that would be 7.2% — which is why 256 was chosen.)
We do not present one model. We present a ladder: each rung adds one capability, so every gain (or lack of one) can be attributed to something specific. Progress is deliberately not assumed to be monotonic — and it isn't.
flowchart LR
R0["No learning<br/>string rules<br/><b>0.591</b>"] --> R1["Counting words<br/>TF-IDF, Naive Bayes,<br/>LogReg, SVM<br/><b>0.610</b>"]
R1 --> R2["Word vectors<br/>Skip-gram from<br/>our own text<br/><b>0.529</b>"]
R2 --> R3["Recurrent nets<br/>RNN, BiRNN, BiLSTM,<br/>+ attention<br/><b>0.605</b>"]
R3 --> R4["Transformer<br/>trained from scratch<br/><b>0.433</b>"]
R4 --> R5["Pretrained encoders<br/>BanglaBERT, MuRIL,<br/>XLM-R<br/><b>0.825</b>"]
style R0 fill:#f3f4f6,stroke:#6b7280,color:#000
style R4 fill:#fee2e2,stroke:#e34948,color:#000
style R5 fill:#0ca30c,color:#fff,stroke:none
Best hard-subset macro-F₁ per family, dev split.
Everything below the pretrained rung is trained only on our 6,134 training items, on a laptop CPU, in seconds to minutes. They answer one question: how far can you get using nothing but this data?
| Rule | How it works | Hard-subset score |
|---|---|---|
| Exact string match | Is the answer's text literally in the passage? | 0.487 (dev) |
| Fuzzy string match (V6) | Is it nearly in the passage — edit distance ≤ 40.5% of its length? | 0.591 (dev) |
These are the bars. The fuzzy rule was tuned on train (never dev) and is the number every learned model must
beat. Code: src/features.py (python src/features.py --baseline).
Idea in plain words. Count which words appear in an item, then learn which words tend to go with wrong answers. No understanding of order or meaning — a "bag" of words.
src/train_classical.py. Cross-checked against a from-scratch Naive Bayes
(--reference-check): 100% agreement on 200 dev records.Result: TF-IDF + LogReg and TF-IDF + SVM tie at 0.610 on hard (McNemar p = 1.000 — see §6.4). One model, BoW + SVM, is flagged unreliable: raw counts run up to 55 with no ceiling and the SVM never converges even after 20,000 iterations; TF-IDF rescales everything to ≤ 1 and settles in seconds. We report its score but mark it — why it fails shows exactly what TF-IDF's weighting is for.
Idea. Build one model of "how correct answers are written" and one of "how wrong ones are", each predicting the next character from the previous few. Then ask which model finds a new answer more likely.
Result: the answer-only character model scores 0.605 on hard (0.529 overall). Its answer-style classifier (M11b) reaches 0.529–0.546 macro-F₁ overall — essentially the same as the answer-only probe (0.542), i.e. there is no generator fingerprint in the answer's writing style that gives the label away.
Idea. Compare the answer with its own passage and question using seven hand-made numbers: whether the answer appears verbatim, token overlap, edit distance to the best-matching piece of the passage, how "passage-like" the answer looks to a language model, how much it repeats the question, answer length, and digit share. Feed them to Logistic Regression.
Rule: features are computed from a record and only its own question and passage — never by comparing it with other records. Anything else is retrieval.
Result — and why this model matters more than its score:
| Overall | Hard | |
|---|---|---|
| Similarity features + LogReg | 0.730 (best overall of any non-pretrained system) | 0.490 (worse than guessing) |
This model learned the string-match shortcut, not the task. Judged by the overall number it looks like the project's best result. Judged on the hard subset it's nearly the worst. A single averaged score would have presented it as a triumph; splitting by measured difficulty exposes it immediately.
Idea. Learn a list of 200 numbers per word by reading text and noticing which words keep similar company — so "similar" words get similar numbers. Average them to represent a whole answer.
gensim) has no build for Python 3.14, so Skip-gram is implemented from scratch in
NumPy (src/skipgram.py), using negative sampling (5 fake neighbours per real one) instead
of a full softmax over the vocabulary, which would be far too slow.python src/skipgram.py --check): neighbours are sensible — e.g. ১৯৭১ → মার্চ, জিয়াউর;
সরকার → মুজিবনগর, প্রবাসী.Result: 0.500–0.529 on hard — below simple word counting. Averaging word vectors throws away word order, and 6,134 items is too little text to learn good vectors.
Idea. Read the text in order, one word at a time, keeping a running "memory" — so unlike a bag of words these models can see sequence.
flowchart LR
T["Token ids<br/>passage, question, answer"] --> E["Embeddings 200-d<br/>initialised from our Skip-gram"]
E --> F["LSTM reading<br/>left to right"]
E --> B["LSTM reading<br/>right to left"]
F --> H["Hidden states<br/>one per word"]
B --> H
H --> A["Attention:<br/>weight each word<br/>by how much it matters"]
A --> D["Dropout 0.3"]
D --> L["Linear layer"]
L --> S["Sigmoid<br/>P(faithful)"]
style A fill:#dbeafe,stroke:#2a78d6,color:#000
style S fill:#0ca30c,color:#fff,stroke:none
Four variants, each adding one capability:
| Model | Adds |
|---|---|
| RNN | Reads left → right, classifies from the final state |
| BiRNN | Also reads right → left, joins both endings |
| BiLSTM (2 layers) | LSTM cells with a longer, gated memory |
| BiLSTM + attention | Can also look back at every word it read, weighting the important ones (--attention shows where it looked) |
Settings: hidden size 128, dropout 0.3, Adam lr 1e-3, batch 32, up to 20 epochs, early stopping on dev loss
(patience 3), gradient clipping at 5; all on CPU. Code: src/train_neural.py.
python src/train_neural.py --check demonstrates each)<PAD> steps and its "final" state is really the state after running on padding.
Here every batch is packed, so the final state is the one at the real last word.Each is guarded by a test that fails if the fix is removed (mutation-tested).
Result: the simplest model wins — RNN 0.605 on hard (overall 0.560 ± 0.009). Bidirectionality, stacking and attention each cost a little; none beats the best word-counting model (0.610). With so little data, extra capacity has nothing to learn from.
Idea. The same architecture as BanglaBERT (self-attention layers), but with no pretraining — random
starting weights, trained only on our data. 2 layers, 4 heads, d_model 128, sine/cosine position signal,
masked mean pooling (src/train_neural.py).
Result — the weakest and least stable system in the project: 0.433 on hard; overall 0.465 ± 0.072, and it
collapsed outright on seed 2024 (0.364). The instability is flagged by the code (collapse_warning()), not hidden.
This is the control experiment. Compare it with BanglaBERT: same family of architecture, differing only in whether it has ever read Bengali before.
Idea. Start from a Transformer that has already read enormous amounts of text, then fine-tune it on our task. It arrives already knowing Bengali grammar and a great deal about the world.
flowchart BT
IN["Input: CLS, passage, SEP, question, answer, SEP"] --> EMB["Word + position embeddings"]
EMB --> ATT["Multi-head self-attention<br/>every word looks at every other word"]
ATT --> FFN["Feed-forward + LayerNorm"]
FFN -.->|"repeated 12 times"| ATT
FFN --> POOL["Take the CLS vector h"]
POOL --> HEAD["One linear layer"]
HEAD --> OUT["Sigmoid: P(faithful)"]
style OUT fill:#0ca30c,color:#fff,stroke:none
Each layer applies self-attention, letting every word decide which other words matter:
$$\mathrm{Attention}(Q,K,V)=\mathrm{softmax}!\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V$$
That is exactly the ability this task needs — to connect "১৯৬৮" to the denomination rather than to the
Edinburgh congregation. The classification head is a single linear layer over the [CLS] summary vector,
trained with binary cross-entropy:
class FaithfulnessClassifier(nn.Module):
"""Pretrained encoder + one linear layer -> a single score."""
def __init__(self, encoder, hidden_size, dropout=0.1):
super().__init__()
self.encoder = encoder
self.dropout = nn.Dropout(dropout)
self.head = nn.Linear(hidden_size, 1)
def forward(self, input_ids, attention_mask):
out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
cls = out.last_hidden_state[:, 0] # the [CLS] summary vector
return self.head(self.dropout(cls)).squeeze(-1)
loss_fn = nn.BCEWithLogitsLoss() # sigmoid applied inside: more numerically stable
Three encoders compared — they differ mainly in how much of their pretraining was Bengali:
| Encoder | Pretrained on | Hard-subset macro-F₁ |
|---|---|---|
| BanglaBERT | Bengali only | 0.825 |
| MuRIL | 17 Indian languages | 0.771 |
| XLM-R (base) | 100 languages | 0.623 |
More languages did not help; a larger share of Bengali did. (BERT-style code for mBERT is also included but was not part of the reported runs.)
| Fine-tuning setting | Value |
|---|---|
| Optimiser | AdamW, lr 2e-5, weight decay 0.01, warm-up 0.1 |
| Batch / max epochs | 32 / 5 (best epoch was 3) |
| Max sequence length | 256 tokens |
| Early stopping | dev loss, patience 2 |
| Hardware | 1 × NVIDIA T4 (16 GB), ≈ 40 min per run |
A detail that is easy to get wrong. BanglaBERT was pretrained on text passed through a specific Bengali normaliser. Feeding it raw text costs accuracy silently — nothing crashes, the score is just lower. This code therefore refuses to run BanglaBERT unless the normaliser is installed:
pip install git+https://github.com/csebuetnlp/normalizer.
Code: src/train_transformer.py; GPU driver: notebooks/02_bert_gpu.ipynb (Colab + Drive).
For context, a 31-billion-parameter instruction-tuned model (Gemma-4-31B) was run through an
external pipeline (a Kaggle notebook: notebooks/final-notebook-bhibranti.ipynb;
outputs in results/gemma_reference/). It reaches 0.908 on the hard dev subset,
above BanglaBERT's 0.825. This is a reference point, not a comparison, for three reasons:
| Why it isn't comparable | Detail |
|---|---|
| Size | Roughly 280× larger than BanglaBERT |
| It was allowed to retrieve | It could search public question banks — exactly what our no-retrieval rule forbids our own systems. About 1,398 of 8,000 questions were found word-for-word in those banks, so part of its score is lookup, not judgement. |
| Hand-written rules ran first | 1,863 items were resolved by rules before the model was consulted at all |
On the 160 hard dev items its retriever could not match, it scores 0.894 — the more meaningful figure, and still not directly comparable. The comparison shows what a very large model with outside access can reach on this data. It does not show our approach is worse under equal conditions, because the conditions are not equal.
Every system — from the two-line string rule to BanglaBERT — is scored by one shared file
(src/evaluate.py) that was written before the first model, precisely so that no model can
be judged on its own terms.
Macro-F₁ computes an F₁ score (a blend of precision and recall) for each class separately, then averages the two classes equally:
$$P_k=\frac{TP_k}{TP_k+FP_k},\quad R_k=\frac{TP_k}{TP_k+FN_k},\quad F_1^{(k)}=\frac{2P_kR_k}{P_k+R_k},\quad \text{macro-}F_1=\tfrac12\big(F_1^{(0)}+F_1^{(1)}\big)$$
Why not plain accuracy? Macro-averaging refuses to reward laziness. Take a "system" that ignores its input and always answers faithful. On our perfectly balanced corpus:
| Class | Precision | Recall | F₁ |
|---|---|---|---|
| Faithful (1) | 0.50 | 1.00 | 0.667 |
| Hallucinated (0) | — | 0.00 | 0.000 |
| Macro average | 0.333 |
Plain accuracy would flatter it at 0.50 even though it never catches a single hallucination. 0.333 is the floor every system must clear before it has demonstrated anything. (BLEU/ROUGE are absent on purpose: they measure text overlap for generation tasks; ours makes one binary decision per item.)
A single blended number is never reported alone. Every model is broken down by:
| Slice | Why |
|---|---|
| With passage / without passage | Two different tasks; one average hides both |
| Easy / hard | Hard is the number the project is judged on |
| By subject | Law/science/BCS need outside knowledge — a collapse there is a finding, not a defect |
| By error type | Reports a detection rate (a type slice holds only wrong answers, so macro-F₁ is undefined there) |
The string baselines are recomputed on exactly the records being scored, never copied from another table, and slices under 60 records are marked "small".
results/experiment_log.csv,
the single source of truth. The demo's scoreboard is read live from it, so it can never drift from the paper.Two scores that differ by 0.01 may just be noise. We use McNemar's test, which looks only at the items where the two models disagree and asks whether one wins those disagreements far more often than chance (50/50).
| Comparison (218 hard dev items) | They disagree on | Winner wins | p-value | Verdict |
|---|---|---|---|---|
| TF-IDF + LogReg vs. TF-IDF + SVM | 28 | 14 each | 1.000 | genuine tie |
| BanglaBERT vs. the fuzzy rule | 81 | BanglaBERT 66, rule 15 | < 0.001 | real difference |
Confidence intervals are computed by resampling pairs, not items — the two answers to one question share a passage and are not independent observations, so treating them as independent would give an interval that is too narrow and an unjustified claim of significance.
All numbers: macro-F₁ on the development split. "Hard" = items with a passage where string matching fails.
| Family | System | Overall | Hard | Seeds |
|---|---|---|---|---|
| No learning | Exact string match | — | 0.487 | — |
| No learning | Fuzzy string match | — | 0.591 ← the bar | — |
| Counting words | TF-IDF + LogReg | 0.549 | 0.610 | 1 |
| Counting words | TF-IDF + SVM | 0.548 | 0.610 | 1 |
| Counting words | Character n-gram LM | 0.529 | 0.605 | 1 |
| Counting words | Bag of words + Naive Bayes | 0.529 | 0.596 | 1 |
| Counting words | TF-IDF + Naive Bayes | 0.527 | 0.592 | 1 |
| Counting words | Bag of words + LogReg | 0.559 | 0.591 | 1 |
| Counting words | Bag of words + SVM (unreliable) | 0.542 | 0.559 | 1 |
| Word vectors | Skip-gram (averaged) + XGBoost | 0.503 ± 0.012 | 0.529 | 3 |
| Word vectors | Skip-gram (IDF-weighted) + XGBoost | 0.521 ± 0.007 | 0.518 | 3 |
| Word vectors | Skip-gram (IDF-weighted) + LogReg | 0.506 ± 0.003 | 0.514 | 3 |
| Word vectors | Skip-gram (averaged) + LogReg | 0.508 ± 0.002 | 0.500 | 3 |
| Word vectors | Similarity features + LogReg | 0.730 | 0.490 (worse than guessing) | 1 |
| Recurrent | RNN | 0.560 ± 0.009 | 0.605 | 3 |
| Recurrent | BiLSTM + attention | 0.551 ± 0.003 | 0.598 | 3 |
| Recurrent | BiLSTM (2 layers) | 0.551 ± 0.004 | 0.585 | 3 |
| Recurrent | BiRNN | 0.553 ± 0.001 | 0.583 | 3 |
| From scratch | Transformer, no pretraining | 0.465 ± 0.072 | 0.433 | 3 |
| Pretrained | XLM-R (base) | 0.594 | 0.623 | 1 |
| Pretrained | MuRIL | 0.779 | 0.771 | 1 |
| Pretrained | BanglaBERT | 0.820 | 0.825 | 1 |
| Reference (not comparable) | Gemma-4-31B, with retrieval | 0.898 | 0.908 | — |
1. Only BanglaBERT clearly beats the rules — and it isn't luck. 0.825 vs. 0.591 on hard; McNemar p < 0.001 (see §6.4).
2. Without pretraining, nothing gets close. The best system that never saw outside text reaches 0.610 — barely above the 0.591 rule. Counting words, custom word vectors and recurrent networks all land in the same narrow 0.49–0.61 band. With 6,134 training items that is the expected outcome, not an implementation failure: there simply isn't enough data to learn Bengali and the task at once.
3. Monolingual pretraining beats multilingual pretraining. BanglaBERT (0.825) > MuRIL (0.771) > XLM-R (0.623).
4. What pretraining is worth. The from-scratch Transformer (0.433 hard; 0.465 overall) and BanglaBERT (0.825; 0.820) are the same kind of network. The +0.355 overall gap is a direct measurement of what pretraining contributes on this task.
5. A model that looks good and isn't. Similarity features: best overall (0.730), near-worst hard (0.490). Never report an unsplit score.
The pattern is consistent and explainable. Reading comprehension scores 0.963 because the evidence is in the passage — the model only has to check it. Civil-service (BCS) questions score 0.517, barely above guessing, because answering them requires knowing a fact that appears nowhere in the input. A closed-book model cannot verify what it was never told. We report this rather than hide it, because it shows precisely where the approach reaches its limit.
Contradictions and wrong numbers are caught almost perfectly — both leave a visible trace in the text (a negation word; a digit that doesn't match). Fabrication is caught least often (0.711) and is also the most common error type (42.3% of all wrong answers) — the largest remaining source of error, and the one category that cannot be resolved by comparing against a passage, because a fabricated answer has no counterpart there to compare against.
python src/serving.py --build # once: fits every model and caches it (CPU only; the 12 classical
# models take ~6 min, the neural models a few more)
python src/app.py # then open http://127.0.0.1:8000
Type (or click) a question, an answer and an optional passage. Every model votes, and the page shows the evidence behind each vote next to the two no-learning rules — because a model that agrees with a string matcher has not demonstrated anything. Models are listed by past performance on hard questions, not by confidence today.

Page through real train and dev records — context, question, answer, label 1/0 — as plain selectable text to copy into the checker, or click check it to prefill it. (The test split is deliberately not browsable.)

The full scoreboard, ranked by hard-subset score, with the baselines to beat and plain-language notes on how to read
it. Numbers are read live from results/experiment_log.csv.

What is and isn't served. The demo loads 12 classical + 5 neural models, plus BanglaBERT if its weights are in
models/banglabert_s42/and the normaliser is installed. Weights are large and git-ignored: produce them withnotebooks/02_bert_gpu.ipynbon a GPU. MuRIL and XLM-R scores appear in the experiment log and paper but are not wired into the demo.
requirements.txt.python -m venv .venv && source .venv/Scripts/activate # Windows / Git Bash
pip install -r requirements.txt
# Only for BanglaBERT (it refuses to run without this):
pip install git+https://github.com/csebuetnlp/normalizer
python src/audit.py --data data/splits --probe metadata # 1. the data gate passes (0.514 < 0.60)
python src/evaluate.py --baselines --table # 2. the no-learning rules reproduce
python src/train_classical.py --model tfidf_logreg # 3. one real model, full report
python src/serving.py --build && python src/app.py # 4. the demo
Or open notebooks/00_full_walkthrough.ipynb — the whole pipeline end to end
with charts (~9 minutes) — or notebooks/INFERENCE.ipynb.
# --- Data (deterministic, seed 42) --------------------------------------------------
python src/build_bn_pool.py # raw -> data/interim/bn_pool.jsonl
python src/build_corpus.py # interim -> data/corpus/ + data/splits/
python src/merge_annotation.py # RE-RUN after any corpus rebuild: restores `excluded` + error types
python src/audit.py --data data/splits --probe metadata # THE GATE (exits non-zero if it fails)
# --- Annotation checks ---------------------------------------------------------------
python src/score_agreement.py --a <sheetA.csv> --b <sheetB.csv> # pilot kappa
python src/score_test_agreement.py --a-dir data/annotated/round1/test/tawhid \
--b-dir data/annotated/round1/test/shejan # kappa = 0.865
# --- Text tools & features -------------------------------------------------------------
python src/text_bn.py --check # 9 safety rules on the real data
python src/features.py --check # feature sanity rules
python src/features.py --baseline # tune + score the fuzzy rule
python src/preprocess.py --check # input-format guarantees
# --- Models ------------------------------------------------------------------------------
python src/skipgram.py --train # ~3 min: word vectors from our own text
python src/train_classical.py --all --seeds --log # every classical model, 3 seeds, recorded
python src/train_classical.py --ablation # the preprocessing experiment
python src/train_neural.py --all --seeds --log # RNN family + Transformer, 3 seeds
python src/train_neural.py --check # prove the three fixes to naive implementations
python src/train_transformer.py --model csebuetnlp/banglabert --seed 42 # GPU
# --- Scoring -----------------------------------------------------------------------------
python src/evaluate.py --baselines --table # rule baselines on dev -> results/tables/
python src/evaluate.py --demo # what the numbers mean, on made-up models
# Every trained model is scored through evaluate.py inside its own training script
# (add --log to record the run in results/experiment_log.csv).
# The test split is opened EXACTLY ONCE, for the final system, and is guarded:
# --split test refuses to run without --final.
# --- Demo --------------------------------------------------------------------------------
python src/serving.py --build && python src/app.py
python -m pytest tests/ -q
Roughly 280 tests, one file per src/ module, each written to fail loudly if a documented rule is broken (the
neural-model tests are mutation-tested: removing any of the three fixes makes a named test fail). The tests need
no GPU and no downloads. Known issue: on Windows, one subprocess test in test_train_transformer.py currently
fails with a console-encoding (cp1252) decoding error in the test harness itself; it is unrelated to the code
under test and is present on the untouched code as well.
results/experiment_log.csv records every run: model, input format, preprocessing, seed, learning rate, batch,
epochs, dev/test score, and notes.Bhibranti/
├── src/ The pipeline: data building, text tools, features, every model, scoring, demo server
├── tests/ One test file per src/ module
├── notebooks/ Runnable walkthroughs (full pipeline, GPU training drivers, inference demo, LLM reference)
├── web/ Templates + stylesheet for the demo (served by src/app.py)
├── data/ Corpus, splits, annotation records, source register (load ONLY via src/splits.py)
├── configs/ Frozen record schema + Bengali word lists (stop words, protected words, suffixes)
├── results/ experiment_log.csv (every run), scored tables, the external Gemma reference run
├── docs/ Requirements, implementation guide, annotation guidelines, the paper, README charts
├── models/ (git-ignored) fine-tuned checkpoints
└── requirements.txt, LICENSE
src/)| Module | What it does |
|---|---|
| Data building | |
build_bn_pool.py | Cleans and de-duplicates the raw pool |
build_corpus.py | Builds pairs, filters, and splits by pair |
build_agreement_test.py, score_agreement.py | The 100-item blind pilot and its κ |
build_annotation_sheets.py, validate_annotation.py | Builds the annotation sheets; checks filled ones for the mistakes that actually happen |
score_test_agreement.py | Cohen's κ on the full test split |
merge_annotation.py | Human sheets → error types + excluded flags (never changes a label) |
llm_annotate.py | LLM second-opinion quality control (can flag, can never write a label) |
audit.py | Shortcut probes + structural checks — the gate |
splits.py | load_split("train") — the only sanctioned way to load data; drops excluded pairs |
| Text & features | |
text_bn.py | Bengali cleaning, tokeniser, stop words, stemmer, protected words |
preprocess.py | The three input formats F1/F2/F3; cuts the passage only |
features.py | Character n-gram LM, edit distance, similarity features, the fuzzy baseline |
| Models | |
train_classical.py | BoW/TF-IDF × NB/LogReg/SVM, character LM, Skip-gram × LogReg/XGBoost, similarity features |
skipgram.py | Skip-gram with negative sampling, written from scratch (NumPy) |
train_neural.py | RNN, BiRNN, BiLSTM, BiLSTM + attention, Transformer from scratch (PyTorch, CPU) |
train_transformer.py | Fine-tunes BanglaBERT / MuRIL / XLM-R / mBERT (refuses BanglaBERT without the normaliser) |
colab_setup.py | Mounts Drive and makes src/ importable from a Colab notebook |
| Scoring & demo | |
evaluate.py | The one scorer: metrics, slices, baselines, McNemar, pair-level bootstrap |
serving.py | Fits every model once and caches it; loads them for the demo |
app.py | The Flask demo (Check an answer / Browse data / Model scores) |
| Notebook | Purpose |
|---|---|
00_full_walkthrough.ipynb | The whole pipeline end to end with charts (~9 min, CPU) |
INFERENCE.ipynb | Every step run live, ending in a "type a question" demo |
01_neural_gpu.ipynb | GPU driver for the from-scratch neural models |
02_bert_gpu.ipynb | Fine-tunes the pretrained encoders on Colab (with Drive) |
final-notebook-bhibranti.ipynb | The external Gemma-4-31B reference pipeline (Kaggle) |
llm_annotation.ipynb | LLM-assisted quality-control pass over the annotations |
Notebooks are deliberately thin: they import the tested modules from src/ rather than re-implementing them, so a
notebook can never drift from the code the numbers came from.
| Path | What it is |
|---|---|
data/corpus/bn_v1/corpus.jsonl | The full corpus (4,480 pairs / 8,960 records) |
data/splits/{train,dev,test}.jsonl | The splits — but load them via src/splits.py, never directly |
data/annotated/ | Annotation sheets, agreement report, adjudication, label disputes |
data/raw/, data/interim/ | Source pool and cleaned pool (raw has duplicates and no schema — don't load it) |
data/SOURCES.md | Where every source came from and its licence status |
data/DATASET_AUDIT.md | Audit of the source pool |
from splits import load_split # run from src/, or add src/ to sys.path
train = load_split("train") # excluded pairs already removed
dev = load_split("dev")
# Do NOT load the test split until the single sanctioned final evaluation.
Reading the .jsonl files directly would silently train and score on the 180 pairs that humans found unusable.
| File | Contents |
|---|---|
docs/paper/main.tex | The paper (compile with XeLaTeX — it needs a Bengali font) |
docs/PRD.md | Requirements, gates and scope — the authority on what the project must and must not do |
docs/IMPLEMENTATION_GUIDE.md | Recipes, hyperparameters and code |
docs/ANNOTATION_GUIDELINES.md | The labelling rules (read before annotating) |
docs/PROJECT_WALKTHROUGH.md | Beginner-friendly, step-by-step story of the project |
docs/READING_ORDER.md | Suggested order to read the repo |
docs/proposal/ | The original project proposal |
CLAUDE.md, AGENT.md | Working notes and standing instructions for AI coding assistants; not needed to use the project |
No. A two-line string rule already scores 0.823 there. Look at the hard subset, where that rule scores 0.454 (exact) / 0.591 (fuzzy). Beating 0.591 there is what shows a model is checking, not matching text.
Treat it as leakage until proven otherwise. Check for the same question appearing in two splits (splits must be by
pair), run the answer-only probe, and make sure you loaded data through src/splits.py.
excluded field — run src/merge_annotation.py first"A corpus rebuild resets that field. Run python src/merge_annotation.py, then re-check the gate with
python src/audit.py --data data/splits --probe metadata.
Install the normaliser: pip install git+https://github.com/csebuetnlp/normalizer. The code refuses rather than
silently scoring lower. For the demo, the weights must also be in models/banglabert_s42/.
/browse is a 404 (Windows)You are almost certainly talking to a stale server process. On Windows a second server can bind the same port
without an error, and the old one keeps answering. Find and kill whatever holds port 8000
(netstat -ano | findstr :8000, then taskkill /PID <pid> /T /F) and start it again. src/app.py auto-reloads on
code edits (--no-reload disables that).
gensim used for Skip-gram?It has no build for Python 3.14. Skip-gram is small enough to write by hand, so src/skipgram.py does it in NumPy.
No. Subjects like civil service (BCS) require outside knowledge that appears nowhere in the input, so a closed-book model cannot verify them. Per-subject collapse there is an expected finding, not a defect.
No. It is a support tool, not an authority — see §12.
data/SOURCES.md.@misc{banghallu2026,
title = {BangHallu: A Bengali Corpus for Hallucination Detection
in Large Language Models},
author = {Hasan, Tawhidul and Shejan, Md. Saif Ahmed},
year = {2026},
note = {Final Project Report, Khulna University of Engineering \& Technology}
}
Code is released under the MIT licence. Data under data/ is not covered by that licence: it is
derived from Bengali Wikipedia (CC BY-SA 4.0 — attribution and share-alike apply) and from question banks whose
redistribution terms are still being confirmed. See data/SOURCES.md before reusing any data file.
Related work we build on and compare against: BenHalluEval (Bengali generation-side hallucination), Poly-FEVER
(multilingual fact verification), BanglaQuAD and BEnQA (Bengali QA resources), and the encoders BanglaBERT, MuRIL and
XLM-R. Full references are in docs/paper/main.tex.
Authors: Tawhidul Hasan and Md. Saif Ahmed Shejan, Department of Computer Science and Engineering, Khulna University of Engineering & Technology (KUET). Supervised by Dr. K. M. Azharul Hasan and Md. Nazirulhasan Shawon.