{
  "id": 699983,
  "title": "What Actually Moved the Needle (and What Didn't) _NLLB-200 +LORA",
  "url": "/competitions/deep-past-initiative-machine-translation/discussion/699983",
  "author_name": "Amritha S",
  "post_date": "2026-05-16T00:26:29.104000",
  "votes": -1,
  "comment_count": 0,
  "views": 0,
  "content": "<h1>Deep Past Challenge — NLLB Post-Mortem and Debugging Notes</h1>\n<p>Final scores: <strong>Public 24.87 / Private 24.41</strong> (NLLB-200 1.3B)</p>\n<p>This write-up focuses primarily on debugging, preprocessing, and decoding diagnostics rather than leaderboard optimization alone.</p>\n<p>Not a top finish, but I learned a lot about what breaks silently in low-resource MT pipelines. Sharing the diagnostics because I think some of these findings are useful regardless of approach.</p>\n<hr>\n<h2>The Setup</h2>\n<ul>\n<li>Base model: NLLB-200 distilled 600M → later 1.3B</li>\n<li>Fine-tuning: LoRA (<code>r=32</code>, <code>alpha=64</code>)</li>\n<li>Target modules: <code>q_proj</code>, <code>k_proj</code>, <code>v_proj</code>, <code>out_proj</code>, <code>fc1</code>, <code>fc2</code></li>\n<li>Training pairs: started at 1,561 → ended at ~5,700 after data expansion</li>\n<li>Hardware: P100 (600M), TPU v5e-8 (1.3B)</li>\n<li>Framework: <code>transformers==4.38.2</code> (pinned — more on why below)</li>\n</ul>\n<hr>\n<h1>Finding 1: The <code>&lt;gap&gt;</code> Token Was Being Silently Deleted During Decode</h1>\n<p>This turned out to be the most impactful bug in the pipeline.</p>\n<p>The <code>&lt;gap&gt;</code> token appears in ~50% of validation references. My model was predicting effectively zero gaps for weeks. I assumed the model simply had not learned the token yet.</p>\n<p>Then I ran this diagnostic:</p>\n<pre><code>gap_id = tokenizer.convert_tokens_to_ids('&lt;gap&gt;')\nprint('Is special token:', gap_id in tokenizer.all_special_ids)\n\n# Encode a test sentence with &lt;gap&gt;\ntest = \"1 mina of silver &lt;gap&gt; Puzur-Aššur &lt;gap&gt; has paid\"\n\nenc = tokenizer(test, return_tensors='pt')\n\ndec_skip = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=True\n)\n\ndec_keep = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=False\n)\n\nprint('With skip_special_tokens=True :', dec_skip)\nprint('With skip_special_tokens=False:', dec_keep)\n</code></pre>\n<p>Output:</p>\n<pre><code>Is special token: True\n\nWith skip_special_tokens=True :\n1 mina of silver Puzur-Aššur has paid\n\nWith skip_special_tokens=False:\n1 mina of silver &lt;gap&gt; Puzur-Aššur &lt;gap&gt; has paid\n</code></pre>\n<p>The model <strong>was</strong> predicting gaps correctly during training.</p>\n<p>But because <code>&lt;gap&gt;</code> had been registered as an <code>additional_special_token</code>, every predicted gap was being removed during decoding with:</p>\n<pre><code>skip_special_tokens=True\n</code></pre>\n<p>The evaluation pipeline never saw a single gap token.</p>\n<hr>\n<h2>The Fix</h2>\n<pre><code># BROKEN\n# &lt;gap&gt; added as special token -&gt; stripped during decode\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG, '&lt;gap&gt;']\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED\n# &lt;gap&gt; added as regular token -&gt; survives decode\n\ntokenizer.add_tokens(['&lt;gap&gt;'] + extra_tokens)\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG]\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED DECODE\n# manually strip only structural tokens\n\npred_texts = tokenizer.batch_decode(\n    outputs,\n    skip_special_tokens=False\n)\n\nfor tok in [\n    '&lt;/s&gt;',\n    '&lt;s&gt;',\n    '&lt;pad&gt;',\n    '&lt;unk&gt;',\n    'eng_Latn',\n    'akk_Latn'\n]:\n    pred_texts = [\n        p.replace(tok, '')\n        for p in pred_texts\n    ]\n</code></pre>\n<p>This fix alone appeared to improve the geometric mean by roughly +1.7 on validation and leaderboard experiments.</p>\n<p>No retraining required — only fixing the decode pipeline.</p>\n<hr>\n<h1>Finding 2: Even After the Fix, the Model Still Avoided Predicting Gaps</h1>\n<p>After fixing decoding, I expected <code>&lt;gap&gt;</code> predictions to appear naturally.</p>\n<p>They still mostly did not.</p>\n<p>I ran a gap alignment diagnostic on the merged training data:</p>\n<pre><code>src_gap = train_df['text_akk'].str.contains(\n    '&lt;gap&gt;',\n    na=False\n)\n\ntgt_gap = train_df['text_en'].str.contains(\n    '&lt;gap&gt;',\n    na=False\n)\n\nprint('Source has &lt;gap&gt;:', src_gap.sum())\nprint('Target has &lt;gap&gt;:', tgt_gap.sum())\n\nprint(\n    'Source ONLY (problem):',\n    (src_gap &amp; ~tgt_gap).sum()\n)\n</code></pre>\n<p>Output:</p>\n<pre><code>Source has &lt;gap&gt;: 920 rows\nTarget has &lt;gap&gt;: 686 rows\nSource ONLY (problem): 329 rows\n</code></pre>\n<p>329 rows contained <code>&lt;gap&gt;</code> in the source but not in the target.</p>\n<p>The model learned the statistically safer strategy:</p>\n<blockquote>\n  <p>never output a gap</p>\n</blockquote>\n<p>because a substantial fraction of source-gap examples had no target-side gap.</p>\n<hr>\n<h2>Postprocessing Band-Aid</h2>\n<pre><code>def postprocess_gap_alignment(\n    src_text,\n    pred_text\n):\n\n    src_gaps  = src_text.count('&lt;gap&gt;')\n    pred_gaps = pred_text.count('&lt;gap&gt;')\n\n    if src_gaps == 0:\n        return pred_text\n\n    if pred_gaps &gt;= src_gaps * 0.5:\n        return pred_text\n\n    missing = src_gaps - pred_gaps\n    words   = pred_text.split()\n\n    positions = [\n        max(\n            0,\n            int(len(words) * (i + 1) / (missing + 1))\n        )\n        for i in range(missing)\n    ]\n\n    for offset, pos in enumerate(positions):\n        words.insert(pos + offset, '&lt;gap&gt;')\n\n    return ' '.join(words)\n</code></pre>\n<p>I tested four postprocess strategies on identical raw predictions:</p>\n<table>\n<thead>\n<tr>\n<th>Strategy</th>\n<th>Gaps Injected</th>\n<th>Geo Mean</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>None</td>\n<td>0</td>\n<td>26.71</td>\n</tr>\n<tr>\n<td>Conservative</td>\n<td>82</td>\n<td>27.24</td>\n</tr>\n<tr>\n<td>Half</td>\n<td>~200</td>\n<td>27.89</td>\n</tr>\n<tr>\n<td><strong>Aggressive (0.5 threshold)</strong></td>\n<td><strong>420</strong></td>\n<td><strong>28.45</strong></td>\n</tr>\n</tbody>\n</table>\n<p>Aggressive injection improved chrF++ overlap enough to offset BLEU penalties from over-injection.</p>\n<h3>Real fix for future work</h3>\n<ul>\n<li>Retrain with <code>&lt;gap&gt;</code> as a regular token from the beginning</li>\n<li>Clean up source-only gap alignment rows</li>\n</ul>\n<hr>\n<h1>Finding 3: Group Beam Search Required Downgrading Transformers</h1>\n<p>In <code>transformers==5.x</code> (Kaggle default), Group Beam Search was moved out of core and requires dynamic loading:</p>\n<pre><code>ValueError:\nGroup Beam Search requires trust_remote_code=True\n</code></pre>\n<p>Offline Kaggle inference could not resolve the external repository.</p>\n<p>After multiple failed workarounds, the cleanest solution was:</p>\n<pre><code>transformers==4.38.2\n</code></pre>\n<p>where Group Beam Search still exists natively.</p>\n<hr>\n<h2>Offline Inference Install</h2>\n<pre><code>subprocess.run([\n    sys.executable,\n    '-m',\n    'pip',\n    'install',\n    '--no-index',\n    '--find-links=/kaggle/input/datasets/amritha18/transformers-4-38-offline-wheels',\n    '--force-reinstall',\n    '--no-deps',\n    '--quiet',\n    'transformers==4.38.2',\n    'tokenizers==0.15.2',\n    'peft==0.10.0',\n], check=True)\n</code></pre>\n<hr>\n<h2>Did Group Beam Search Help?</h2>\n<table>\n<thead>\n<tr>\n<th>Decoding</th>\n<th>Geo Mean</th>\n<th>Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Standard beam</td>\n<td>~22–23</td>\n<td>Short outputs</td>\n</tr>\n<tr>\n<td><strong>Group beam</strong></td>\n<td><strong>~24–25</strong></td>\n<td>Longer, more complete</td>\n</tr>\n</tbody>\n</table>\n<p>It helped noticeably on long tablet translations where standard beam tended to collapse into short generic phrases.</p>\n<hr>\n<h1>Finding 4: Document-Level Training vs Sentence-Level Test</h1>\n<p>The training set is largely document-level while the test set behaves more like sentence-level translation.</p>\n<p>This mismatch caused:</p>\n<ul>\n<li>truncation after formulaic openings</li>\n<li>hallucination loops</li>\n<li>brevity penalties</li>\n<li>incomplete generations</li>\n</ul>\n<p>The solution was reconstructing sentence-level pairs using:</p>\n<pre><code>Sentences_Oare_FirstWord_LinNum.csv\n</code></pre>\n<p>For tablets containing <code>um-ma</code>, I split around the epistolary opening.</p>\n<hr>\n<h2>Sentence Reconstruction</h2>\n<pre><code>def split_on_umma(\n    akk_text,\n    en_text\n):\n\n    akk_parts = re.split(\n        r'(?=\\bum-ma\\b)',\n        akk_text.strip()\n    )\n\n    akk_parts = [\n        p.strip()\n        for p in akk_parts\n        if len(p.strip()) &gt; 10\n    ]\n\n    en_parts = re.split(\n        r'(?&lt;=[.!?])\\s+(?=[A-Z])',\n        en_text.strip()\n    )\n\n    en_parts = [\n        p.strip()\n        for p in en_parts\n        if len(p.strip()) &gt; 10\n    ]\n\n    if (\n        len(akk_parts) == len(en_parts)\n        and len(akk_parts) &gt; 1\n    ):\n        return list(zip(akk_parts, en_parts))\n\n    return None\n</code></pre>\n<hr>\n<h2>Data Expansion</h2>\n<table>\n<thead>\n<tr>\n<th>Stage</th>\n<th>Pairs</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Raw train.csv</td>\n<td>1,561</td>\n</tr>\n<tr>\n<td>After sentence splitting</td>\n<td>~1,600</td>\n</tr>\n<tr>\n<td>+ published_texts.csv</td>\n<td>+3,522</td>\n</tr>\n<tr>\n<td>Final filtered dataset</td>\n<td><strong>5,009</strong></td>\n</tr>\n</tbody>\n</table>\n<hr>\n<h1>Finding 5: <code>min_new_tokens</code> Needed Careful Tuning</h1>\n<p>To reduce truncation after headers, I experimented with <code>min_new_tokens</code>.</p>\n<table>\n<thead>\n<tr>\n<th>Config</th>\n<th>Geo Mean</th>\n<th>Brevity Penalty</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>None</td>\n<td>27.24</td>\n<td>0.890</td>\n</tr>\n<tr>\n<td><code>min_new_tokens=50</code></td>\n<td>27.24</td>\n<td>0.890</td>\n</tr>\n<tr>\n<td><code>min_new_tokens=30</code></td>\n<td>28.24</td>\n<td>0.934</td>\n</tr>\n<tr>\n<td><code>+ aggressive gap postprocess</code></td>\n<td><strong>29.55</strong></td>\n<td><strong>0.998</strong></td>\n</tr>\n</tbody>\n</table>\n<p><code>min_new_tokens=50</code> surprisingly had almost no effect.</p>\n<p>Most headers are only ~8–12 tokens, so the model still stopped immediately after formulaic openings.</p>\n<p><code>min_new_tokens=30</code> pushed generation past the opening formulas into actual content.</p>\n<hr>\n<h1>What Did Not Help</h1>\n<h2>1. NLLB-200 1.3B vs 600M</h2>\n<p>Very small leaderboard movement despite much larger compute cost.</p>\n<p>The bottleneck appeared to be:</p>\n<ul>\n<li>data quality</li>\n<li>representation</li>\n<li>tokenization</li>\n</ul>\n<p>rather than model size.</p>\n<hr>\n<h2>2. ORACC External Data</h2>\n<p>Adding ~900 filtered ORACC pairs increased validation loss:</p>\n<pre><code>Baseline (no ORACC): val_loss = 1.575\nWith ORACC:          val_loss = 1.686\n</code></pre>\n<p>Likely causes:</p>\n<ul>\n<li>dialect mismatch</li>\n<li>Neo-Assyrian vs Old Assyrian</li>\n<li>inconsistent gap alignment</li>\n<li>different scribal conventions</li>\n</ul>\n<hr>\n<h2>3. Forced Proper-Noun Decoding</h2>\n<p>I built ~11k proper noun constraints from:</p>\n<pre><code>OA_Lexicon_eBL.csv\n</code></pre>\n<p>using:</p>\n<pre><code>force_words_ids\n</code></pre>\n<p>In practice, constrained decoding failed on most generations and frequently reverted to unconstrained fallback.</p>\n<p>N-best reranking by proper noun overlap helped slightly, but forced decoding itself was not useful.</p>\n<hr>\n<h1>chrF++ vs BLEU — The Diagnostic That Explained the Ceiling</h1>\n<p>The most interesting diagnostic was the divergence between chrF++ and BLEU.</p>\n<table>\n<thead>\n<tr>\n<th>Metric</th>\n<th>Validation</th>\n<th>Leaderboard</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>BLEU</td>\n<td>20.00</td>\n<td>~12</td>\n</tr>\n<tr>\n<td>chrF++</td>\n<td>43.66</td>\n<td>~21</td>\n</tr>\n</tbody>\n</table>\n<p>chrF++ rewards partial character overlap.</p>\n<p>BLEU rewards exact lexical matches.</p>\n<p>The model learned approximate transliteration structure but struggled with exact Akkadian word reconstruction.</p>\n<p>This appears closely related to subword tokenization fragmentation.</p>\n<p>Example:</p>\n<pre><code>iš-qú-ul\n</code></pre>\n<p>becomes fragmented into multiple unrelated subword pieces rather than a single coherent morphological unit.</p>\n<hr>\n<h1>Why ByT5 May Be Better</h1>\n<p>Byte-level architectures such as ByT5 avoid fixed vocabularies entirely.</p>\n<p>Potential advantages:</p>\n<ul>\n<li>no BPE fragmentation</li>\n<li>direct character modeling</li>\n<li>stronger handling of rare morphology</li>\n<li>better transliteration robustness</li>\n</ul>\n<p>Given the chrF++ / BLEU divergence pattern, byte-level models may ultimately be a better fit for Akkadian MT than subword-based architectures.</p>\n<hr>\n<h1>Things I Would Do Differently</h1>\n<ol>\n<li><p>Fix tokenizer diagnostics immediately instead of assuming the model failed to learn <code>&lt;gap&gt;</code></p></li>\n<li><p>Mine publication PDFs earlier for additional parallel data</p></li>\n<li><p>Test ByT5 much earlier as a baseline architecture</p></li>\n<li><p>Domain-check external corpora before merging datasets</p></li>\n<li><p>Focus more on alignment consistency than decoding tricks</p></li>\n</ol>\n<hr>\n<h2>Lessons Learned</h2>\n<ul>\n<li>Silent preprocessing and decoding bugs can matter more than model scale.</li>\n<li>Alignment consistency mattered more than aggressive augmentation.</li>\n<li>chrF++ and BLEU diverged significantly on Akkadian transliterations.</li>\n<li>Domain mismatch in external corpora can hurt more than small dataset size.</li>\n<li>Byte-level architectures such as ByT5 may ultimately be a better fit for Akkadian MT than subword-based models.</li>\n</ul>\n<hr>\n<p>Happy to discuss any of the diagnostics — especially the <code>&lt;gap&gt;</code> token issue since I suspect others may have encountered similar behavior without realizing it.</p>\n<p>*Full technical report: <a href=\"https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*\" target=\"_blank\">https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*</a></p>",
  "messages": [
    {
      "id": 3458490,
      "postDate": "2026-05-16T00:26:29.103Z",
      "content": "<h1>Deep Past Challenge — NLLB Post-Mortem and Debugging Notes</h1>\n<p>Final scores: <strong>Public 24.87 / Private 24.41</strong> (NLLB-200 1.3B)</p>\n<p>This write-up focuses primarily on debugging, preprocessing, and decoding diagnostics rather than leaderboard optimization alone.</p>\n<p>Not a top finish, but I learned a lot about what breaks silently in low-resource MT pipelines. Sharing the diagnostics because I think some of these findings are useful regardless of approach.</p>\n<hr>\n<h2>The Setup</h2>\n<ul>\n<li>Base model: NLLB-200 distilled 600M → later 1.3B</li>\n<li>Fine-tuning: LoRA (<code>r=32</code>, <code>alpha=64</code>)</li>\n<li>Target modules: <code>q_proj</code>, <code>k_proj</code>, <code>v_proj</code>, <code>out_proj</code>, <code>fc1</code>, <code>fc2</code></li>\n<li>Training pairs: started at 1,561 → ended at ~5,700 after data expansion</li>\n<li>Hardware: P100 (600M), TPU v5e-8 (1.3B)</li>\n<li>Framework: <code>transformers==4.38.2</code> (pinned — more on why below)</li>\n</ul>\n<hr>\n<h1>Finding 1: The <code>&lt;gap&gt;</code> Token Was Being Silently Deleted During Decode</h1>\n<p>This turned out to be the most impactful bug in the pipeline.</p>\n<p>The <code>&lt;gap&gt;</code> token appears in ~50% of validation references. My model was predicting effectively zero gaps for weeks. I assumed the model simply had not learned the token yet.</p>\n<p>Then I ran this diagnostic:</p>\n<pre><code>gap_id = tokenizer.convert_tokens_to_ids('&lt;gap&gt;')\nprint('Is special token:', gap_id in tokenizer.all_special_ids)\n\n# Encode a test sentence with &lt;gap&gt;\ntest = \"1 mina of silver &lt;gap&gt; Puzur-Aššur &lt;gap&gt; has paid\"\n\nenc = tokenizer(test, return_tensors='pt')\n\ndec_skip = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=True\n)\n\ndec_keep = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=False\n)\n\nprint('With skip_special_tokens=True :', dec_skip)\nprint('With skip_special_tokens=False:', dec_keep)\n</code></pre>\n<p>Output:</p>\n<pre><code>Is special token: True\n\nWith skip_special_tokens=True :\n1 mina of silver Puzur-Aššur has paid\n\nWith skip_special_tokens=False:\n1 mina of silver &lt;gap&gt; Puzur-Aššur &lt;gap&gt; has paid\n</code></pre>\n<p>The model <strong>was</strong> predicting gaps correctly during training.</p>\n<p>But because <code>&lt;gap&gt;</code> had been registered as an <code>additional_special_token</code>, every predicted gap was being removed during decoding with:</p>\n<pre><code>skip_special_tokens=True\n</code></pre>\n<p>The evaluation pipeline never saw a single gap token.</p>\n<hr>\n<h2>The Fix</h2>\n<pre><code># BROKEN\n# &lt;gap&gt; added as special token -&gt; stripped during decode\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG, '&lt;gap&gt;']\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED\n# &lt;gap&gt; added as regular token -&gt; survives decode\n\ntokenizer.add_tokens(['&lt;gap&gt;'] + extra_tokens)\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG]\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED DECODE\n# manually strip only structural tokens\n\npred_texts = tokenizer.batch_decode(\n    outputs,\n    skip_special_tokens=False\n)\n\nfor tok in [\n    '&lt;/s&gt;',\n    '&lt;s&gt;',\n    '&lt;pad&gt;',\n    '&lt;unk&gt;',\n    'eng_Latn',\n    'akk_Latn'\n]:\n    pred_texts = [\n        p.replace(tok, '')\n        for p in pred_texts\n    ]\n</code></pre>\n<p>This fix alone appeared to improve the geometric mean by roughly +1.7 on validation and leaderboard experiments.</p>\n<p>No retraining required — only fixing the decode pipeline.</p>\n<hr>\n<h1>Finding 2: Even After the Fix, the Model Still Avoided Predicting Gaps</h1>\n<p>After fixing decoding, I expected <code>&lt;gap&gt;</code> predictions to appear naturally.</p>\n<p>They still mostly did not.</p>\n<p>I ran a gap alignment diagnostic on the merged training data:</p>\n<pre><code>src_gap = train_df['text_akk'].str.contains(\n    '&lt;gap&gt;',\n    na=False\n)\n\ntgt_gap = train_df['text_en'].str.contains(\n    '&lt;gap&gt;',\n    na=False\n)\n\nprint('Source has &lt;gap&gt;:', src_gap.sum())\nprint('Target has &lt;gap&gt;:', tgt_gap.sum())\n\nprint(\n    'Source ONLY (problem):',\n    (src_gap &amp; ~tgt_gap).sum()\n)\n</code></pre>\n<p>Output:</p>\n<pre><code>Source has &lt;gap&gt;: 920 rows\nTarget has &lt;gap&gt;: 686 rows\nSource ONLY (problem): 329 rows\n</code></pre>\n<p>329 rows contained <code>&lt;gap&gt;</code> in the source but not in the target.</p>\n<p>The model learned the statistically safer strategy:</p>\n<blockquote>\n  <p>never output a gap</p>\n</blockquote>\n<p>because a substantial fraction of source-gap examples had no target-side gap.</p>\n<hr>\n<h2>Postprocessing Band-Aid</h2>\n<pre><code>def postprocess_gap_alignment(\n    src_text,\n    pred_text\n):\n\n    src_gaps  = src_text.count('&lt;gap&gt;')\n    pred_gaps = pred_text.count('&lt;gap&gt;')\n\n    if src_gaps == 0:\n        return pred_text\n\n    if pred_gaps &gt;= src_gaps * 0.5:\n        return pred_text\n\n    missing = src_gaps - pred_gaps\n    words   = pred_text.split()\n\n    positions = [\n        max(\n            0,\n            int(len(words) * (i + 1) / (missing + 1))\n        )\n        for i in range(missing)\n    ]\n\n    for offset, pos in enumerate(positions):\n        words.insert(pos + offset, '&lt;gap&gt;')\n\n    return ' '.join(words)\n</code></pre>\n<p>I tested four postprocess strategies on identical raw predictions:</p>\n<table>\n<thead>\n<tr>\n<th>Strategy</th>\n<th>Gaps Injected</th>\n<th>Geo Mean</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>None</td>\n<td>0</td>\n<td>26.71</td>\n</tr>\n<tr>\n<td>Conservative</td>\n<td>82</td>\n<td>27.24</td>\n</tr>\n<tr>\n<td>Half</td>\n<td>~200</td>\n<td>27.89</td>\n</tr>\n<tr>\n<td><strong>Aggressive (0.5 threshold)</strong></td>\n<td><strong>420</strong></td>\n<td><strong>28.45</strong></td>\n</tr>\n</tbody>\n</table>\n<p>Aggressive injection improved chrF++ overlap enough to offset BLEU penalties from over-injection.</p>\n<h3>Real fix for future work</h3>\n<ul>\n<li>Retrain with <code>&lt;gap&gt;</code> as a regular token from the beginning</li>\n<li>Clean up source-only gap alignment rows</li>\n</ul>\n<hr>\n<h1>Finding 3: Group Beam Search Required Downgrading Transformers</h1>\n<p>In <code>transformers==5.x</code> (Kaggle default), Group Beam Search was moved out of core and requires dynamic loading:</p>\n<pre><code>ValueError:\nGroup Beam Search requires trust_remote_code=True\n</code></pre>\n<p>Offline Kaggle inference could not resolve the external repository.</p>\n<p>After multiple failed workarounds, the cleanest solution was:</p>\n<pre><code>transformers==4.38.2\n</code></pre>\n<p>where Group Beam Search still exists natively.</p>\n<hr>\n<h2>Offline Inference Install</h2>\n<pre><code>subprocess.run([\n    sys.executable,\n    '-m',\n    'pip',\n    'install',\n    '--no-index',\n    '--find-links=/kaggle/input/datasets/amritha18/transformers-4-38-offline-wheels',\n    '--force-reinstall',\n    '--no-deps',\n    '--quiet',\n    'transformers==4.38.2',\n    'tokenizers==0.15.2',\n    'peft==0.10.0',\n], check=True)\n</code></pre>\n<hr>\n<h2>Did Group Beam Search Help?</h2>\n<table>\n<thead>\n<tr>\n<th>Decoding</th>\n<th>Geo Mean</th>\n<th>Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Standard beam</td>\n<td>~22–23</td>\n<td>Short outputs</td>\n</tr>\n<tr>\n<td><strong>Group beam</strong></td>\n<td><strong>~24–25</strong></td>\n<td>Longer, more complete</td>\n</tr>\n</tbody>\n</table>\n<p>It helped noticeably on long tablet translations where standard beam tended to collapse into short generic phrases.</p>\n<hr>\n<h1>Finding 4: Document-Level Training vs Sentence-Level Test</h1>\n<p>The training set is largely document-level while the test set behaves more like sentence-level translation.</p>\n<p>This mismatch caused:</p>\n<ul>\n<li>truncation after formulaic openings</li>\n<li>hallucination loops</li>\n<li>brevity penalties</li>\n<li>incomplete generations</li>\n</ul>\n<p>The solution was reconstructing sentence-level pairs using:</p>\n<pre><code>Sentences_Oare_FirstWord_LinNum.csv\n</code></pre>\n<p>For tablets containing <code>um-ma</code>, I split around the epistolary opening.</p>\n<hr>\n<h2>Sentence Reconstruction</h2>\n<pre><code>def split_on_umma(\n    akk_text,\n    en_text\n):\n\n    akk_parts = re.split(\n        r'(?=\\bum-ma\\b)',\n        akk_text.strip()\n    )\n\n    akk_parts = [\n        p.strip()\n        for p in akk_parts\n        if len(p.strip()) &gt; 10\n    ]\n\n    en_parts = re.split(\n        r'(?&lt;=[.!?])\\s+(?=[A-Z])',\n        en_text.strip()\n    )\n\n    en_parts = [\n        p.strip()\n        for p in en_parts\n        if len(p.strip()) &gt; 10\n    ]\n\n    if (\n        len(akk_parts) == len(en_parts)\n        and len(akk_parts) &gt; 1\n    ):\n        return list(zip(akk_parts, en_parts))\n\n    return None\n</code></pre>\n<hr>\n<h2>Data Expansion</h2>\n<table>\n<thead>\n<tr>\n<th>Stage</th>\n<th>Pairs</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Raw train.csv</td>\n<td>1,561</td>\n</tr>\n<tr>\n<td>After sentence splitting</td>\n<td>~1,600</td>\n</tr>\n<tr>\n<td>+ published_texts.csv</td>\n<td>+3,522</td>\n</tr>\n<tr>\n<td>Final filtered dataset</td>\n<td><strong>5,009</strong></td>\n</tr>\n</tbody>\n</table>\n<hr>\n<h1>Finding 5: <code>min_new_tokens</code> Needed Careful Tuning</h1>\n<p>To reduce truncation after headers, I experimented with <code>min_new_tokens</code>.</p>\n<table>\n<thead>\n<tr>\n<th>Config</th>\n<th>Geo Mean</th>\n<th>Brevity Penalty</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>None</td>\n<td>27.24</td>\n<td>0.890</td>\n</tr>\n<tr>\n<td><code>min_new_tokens=50</code></td>\n<td>27.24</td>\n<td>0.890</td>\n</tr>\n<tr>\n<td><code>min_new_tokens=30</code></td>\n<td>28.24</td>\n<td>0.934</td>\n</tr>\n<tr>\n<td><code>+ aggressive gap postprocess</code></td>\n<td><strong>29.55</strong></td>\n<td><strong>0.998</strong></td>\n</tr>\n</tbody>\n</table>\n<p><code>min_new_tokens=50</code> surprisingly had almost no effect.</p>\n<p>Most headers are only ~8–12 tokens, so the model still stopped immediately after formulaic openings.</p>\n<p><code>min_new_tokens=30</code> pushed generation past the opening formulas into actual content.</p>\n<hr>\n<h1>What Did Not Help</h1>\n<h2>1. NLLB-200 1.3B vs 600M</h2>\n<p>Very small leaderboard movement despite much larger compute cost.</p>\n<p>The bottleneck appeared to be:</p>\n<ul>\n<li>data quality</li>\n<li>representation</li>\n<li>tokenization</li>\n</ul>\n<p>rather than model size.</p>\n<hr>\n<h2>2. ORACC External Data</h2>\n<p>Adding ~900 filtered ORACC pairs increased validation loss:</p>\n<pre><code>Baseline (no ORACC): val_loss = 1.575\nWith ORACC:          val_loss = 1.686\n</code></pre>\n<p>Likely causes:</p>\n<ul>\n<li>dialect mismatch</li>\n<li>Neo-Assyrian vs Old Assyrian</li>\n<li>inconsistent gap alignment</li>\n<li>different scribal conventions</li>\n</ul>\n<hr>\n<h2>3. Forced Proper-Noun Decoding</h2>\n<p>I built ~11k proper noun constraints from:</p>\n<pre><code>OA_Lexicon_eBL.csv\n</code></pre>\n<p>using:</p>\n<pre><code>force_words_ids\n</code></pre>\n<p>In practice, constrained decoding failed on most generations and frequently reverted to unconstrained fallback.</p>\n<p>N-best reranking by proper noun overlap helped slightly, but forced decoding itself was not useful.</p>\n<hr>\n<h1>chrF++ vs BLEU — The Diagnostic That Explained the Ceiling</h1>\n<p>The most interesting diagnostic was the divergence between chrF++ and BLEU.</p>\n<table>\n<thead>\n<tr>\n<th>Metric</th>\n<th>Validation</th>\n<th>Leaderboard</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>BLEU</td>\n<td>20.00</td>\n<td>~12</td>\n</tr>\n<tr>\n<td>chrF++</td>\n<td>43.66</td>\n<td>~21</td>\n</tr>\n</tbody>\n</table>\n<p>chrF++ rewards partial character overlap.</p>\n<p>BLEU rewards exact lexical matches.</p>\n<p>The model learned approximate transliteration structure but struggled with exact Akkadian word reconstruction.</p>\n<p>This appears closely related to subword tokenization fragmentation.</p>\n<p>Example:</p>\n<pre><code>iš-qú-ul\n</code></pre>\n<p>becomes fragmented into multiple unrelated subword pieces rather than a single coherent morphological unit.</p>\n<hr>\n<h1>Why ByT5 May Be Better</h1>\n<p>Byte-level architectures such as ByT5 avoid fixed vocabularies entirely.</p>\n<p>Potential advantages:</p>\n<ul>\n<li>no BPE fragmentation</li>\n<li>direct character modeling</li>\n<li>stronger handling of rare morphology</li>\n<li>better transliteration robustness</li>\n</ul>\n<p>Given the chrF++ / BLEU divergence pattern, byte-level models may ultimately be a better fit for Akkadian MT than subword-based architectures.</p>\n<hr>\n<h1>Things I Would Do Differently</h1>\n<ol>\n<li><p>Fix tokenizer diagnostics immediately instead of assuming the model failed to learn <code>&lt;gap&gt;</code></p></li>\n<li><p>Mine publication PDFs earlier for additional parallel data</p></li>\n<li><p>Test ByT5 much earlier as a baseline architecture</p></li>\n<li><p>Domain-check external corpora before merging datasets</p></li>\n<li><p>Focus more on alignment consistency than decoding tricks</p></li>\n</ol>\n<hr>\n<h2>Lessons Learned</h2>\n<ul>\n<li>Silent preprocessing and decoding bugs can matter more than model scale.</li>\n<li>Alignment consistency mattered more than aggressive augmentation.</li>\n<li>chrF++ and BLEU diverged significantly on Akkadian transliterations.</li>\n<li>Domain mismatch in external corpora can hurt more than small dataset size.</li>\n<li>Byte-level architectures such as ByT5 may ultimately be a better fit for Akkadian MT than subword-based models.</li>\n</ul>\n<hr>\n<p>Happy to discuss any of the diagnostics — especially the <code>&lt;gap&gt;</code> token issue since I suspect others may have encountered similar behavior without realizing it.</p>\n<p>*Full technical report: <a href=\"https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*\" target=\"_blank\">https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*</a></p>",
      "rawMarkdown": "# Deep Past Challenge — NLLB Post-Mortem and Debugging Notes\n\nFinal scores: **Public 24.87 / Private 24.41** (NLLB-200 1.3B)\n\nThis write-up focuses primarily on debugging, preprocessing, and decoding diagnostics rather than leaderboard optimization alone.\n\nNot a top finish, but I learned a lot about what breaks silently in low-resource MT pipelines. Sharing the diagnostics because I think some of these findings are useful regardless of approach.\n\n---\n\n## The Setup\n\n- Base model: NLLB-200 distilled 600M → later 1.3B\n- Fine-tuning: LoRA (`r=32`, `alpha=64`)\n- Target modules: `q_proj`, `k_proj`, `v_proj`, `out_proj`, `fc1`, `fc2`\n- Training pairs: started at 1,561 → ended at ~5,700 after data expansion\n- Hardware: P100 (600M), TPU v5e-8 (1.3B)\n- Framework: `transformers==4.38.2` (pinned — more on why below)\n\n---\n\n# Finding 1: The `<gap>` Token Was Being Silently Deleted During Decode\n\nThis turned out to be the most impactful bug in the pipeline.\n\nThe `<gap>` token appears in ~50% of validation references. My model was predicting effectively zero gaps for weeks. I assumed the model simply had not learned the token yet.\n\nThen I ran this diagnostic:\n\n```python\ngap_id = tokenizer.convert_tokens_to_ids('<gap>')\nprint('Is special token:', gap_id in tokenizer.all_special_ids)\n\n# Encode a test sentence with <gap>\ntest = \"1 mina of silver <gap> Puzur-Aššur <gap> has paid\"\n\nenc = tokenizer(test, return_tensors='pt')\n\ndec_skip = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=True\n)\n\ndec_keep = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=False\n)\n\nprint('With skip_special_tokens=True :', dec_skip)\nprint('With skip_special_tokens=False:', dec_keep)\n```\n\nOutput:\n\n```text\nIs special token: True\n\nWith skip_special_tokens=True :\n1 mina of silver Puzur-Aššur has paid\n\nWith skip_special_tokens=False:\n1 mina of silver <gap> Puzur-Aššur <gap> has paid\n```\n\nThe model **was** predicting gaps correctly during training.\n\nBut because `<gap>` had been registered as an `additional_special_token`, every predicted gap was being removed during decoding with:\n\n```python\nskip_special_tokens=True\n```\n\nThe evaluation pipeline never saw a single gap token.\n\n---\n\n## The Fix\n\n```python\n# BROKEN\n# <gap> added as special token -> stripped during decode\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG, '<gap>']\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED\n# <gap> added as regular token -> survives decode\n\ntokenizer.add_tokens(['<gap>'] + extra_tokens)\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG]\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED DECODE\n# manually strip only structural tokens\n\npred_texts = tokenizer.batch_decode(\n    outputs,\n    skip_special_tokens=False\n)\n\nfor tok in [\n    '</s>',\n    '<s>',\n    '<pad>',\n    '<unk>',\n    'eng_Latn',\n    'akk_Latn'\n]:\n    pred_texts = [\n        p.replace(tok, '')\n        for p in pred_texts\n    ]\n```\n\nThis fix alone appeared to improve the geometric mean by roughly +1.7 on validation and leaderboard experiments.\n\nNo retraining required — only fixing the decode pipeline.\n\n---\n\n# Finding 2: Even After the Fix, the Model Still Avoided Predicting Gaps\n\nAfter fixing decoding, I expected `<gap>` predictions to appear naturally.\n\nThey still mostly did not.\n\nI ran a gap alignment diagnostic on the merged training data:\n\n```python\nsrc_gap = train_df['text_akk'].str.contains(\n    '<gap>',\n    na=False\n)\n\ntgt_gap = train_df['text_en'].str.contains(\n    '<gap>',\n    na=False\n)\n\nprint('Source has <gap>:', src_gap.sum())\nprint('Target has <gap>:', tgt_gap.sum())\n\nprint(\n    'Source ONLY (problem):',\n    (src_gap & ~tgt_gap).sum()\n)\n```\n\nOutput:\n\n```text\nSource has <gap>: 920 rows\nTarget has <gap>: 686 rows\nSource ONLY (problem): 329 rows\n```\n\n329 rows contained `<gap>` in the source but not in the target.\n\nThe model learned the statistically safer strategy:\n\n> never output a gap\n\nbecause a substantial fraction of source-gap examples had no target-side gap.\n\n---\n\n## Postprocessing Band-Aid\n\n```python\ndef postprocess_gap_alignment(\n    src_text,\n    pred_text\n):\n\n    src_gaps  = src_text.count('<gap>')\n    pred_gaps = pred_text.count('<gap>')\n\n    if src_gaps == 0:\n        return pred_text\n\n    if pred_gaps >= src_gaps * 0.5:\n        return pred_text\n\n    missing = src_gaps - pred_gaps\n    words   = pred_text.split()\n\n    positions = [\n        max(\n            0,\n            int(len(words) * (i + 1) / (missing + 1))\n        )\n        for i in range(missing)\n    ]\n\n    for offset, pos in enumerate(positions):\n        words.insert(pos + offset, '<gap>')\n\n    return ' '.join(words)\n```\n\nI tested four postprocess strategies on identical raw predictions:\n\n| Strategy | Gaps Injected | Geo Mean |\n|---|---|---|\n| None | 0 | 26.71 |\n| Conservative | 82 | 27.24 |\n| Half | ~200 | 27.89 |\n| **Aggressive (0.5 threshold)** | **420** | **28.45** |\n\nAggressive injection improved chrF++ overlap enough to offset BLEU penalties from over-injection.\n\n### Real fix for future work\n\n- Retrain with `<gap>` as a regular token from the beginning\n- Clean up source-only gap alignment rows\n\n---\n\n# Finding 3: Group Beam Search Required Downgrading Transformers\n\nIn `transformers==5.x` (Kaggle default), Group Beam Search was moved out of core and requires dynamic loading:\n\n```text\nValueError:\nGroup Beam Search requires trust_remote_code=True\n```\n\nOffline Kaggle inference could not resolve the external repository.\n\nAfter multiple failed workarounds, the cleanest solution was:\n\n```python\ntransformers==4.38.2\n```\n\nwhere Group Beam Search still exists natively.\n\n---\n\n## Offline Inference Install\n\n```python\nsubprocess.run([\n    sys.executable,\n    '-m',\n    'pip',\n    'install',\n    '--no-index',\n    '--find-links=/kaggle/input/datasets/amritha18/transformers-4-38-offline-wheels',\n    '--force-reinstall',\n    '--no-deps',\n    '--quiet',\n    'transformers==4.38.2',\n    'tokenizers==0.15.2',\n    'peft==0.10.0',\n], check=True)\n```\n\n---\n\n## Did Group Beam Search Help?\n\n| Decoding | Geo Mean | Notes |\n|---|---|---|\n| Standard beam | ~22–23 | Short outputs |\n| **Group beam** | **~24–25** | Longer, more complete |\n\nIt helped noticeably on long tablet translations where standard beam tended to collapse into short generic phrases.\n\n---\n\n# Finding 4: Document-Level Training vs Sentence-Level Test\n\nThe training set is largely document-level while the test set behaves more like sentence-level translation.\n\nThis mismatch caused:\n\n- truncation after formulaic openings\n- hallucination loops\n- brevity penalties\n- incomplete generations\n\nThe solution was reconstructing sentence-level pairs using:\n\n```text\nSentences_Oare_FirstWord_LinNum.csv\n```\n\nFor tablets containing `um-ma`, I split around the epistolary opening.\n\n---\n\n## Sentence Reconstruction\n\n```python\ndef split_on_umma(\n    akk_text,\n    en_text\n):\n\n    akk_parts = re.split(\n        r'(?=\\bum-ma\\b)',\n        akk_text.strip()\n    )\n\n    akk_parts = [\n        p.strip()\n        for p in akk_parts\n        if len(p.strip()) > 10\n    ]\n\n    en_parts = re.split(\n        r'(?<=[.!?])\\s+(?=[A-Z])',\n        en_text.strip()\n    )\n\n    en_parts = [\n        p.strip()\n        for p in en_parts\n        if len(p.strip()) > 10\n    ]\n\n    if (\n        len(akk_parts) == len(en_parts)\n        and len(akk_parts) > 1\n    ):\n        return list(zip(akk_parts, en_parts))\n\n    return None\n```\n\n---\n\n## Data Expansion\n\n| Stage | Pairs |\n|---|---|\n| Raw train.csv | 1,561 |\n| After sentence splitting | ~1,600 |\n| + published_texts.csv | +3,522 |\n| Final filtered dataset | **5,009** |\n\n---\n\n# Finding 5: `min_new_tokens` Needed Careful Tuning\n\nTo reduce truncation after headers, I experimented with `min_new_tokens`.\n\n| Config | Geo Mean | Brevity Penalty |\n|---|---|---|\n| None | 27.24 | 0.890 |\n| `min_new_tokens=50` | 27.24 | 0.890 |\n| `min_new_tokens=30` | 28.24 | 0.934 |\n| `+ aggressive gap postprocess` | **29.55** | **0.998** |\n\n`min_new_tokens=50` surprisingly had almost no effect.\n\nMost headers are only ~8–12 tokens, so the model still stopped immediately after formulaic openings.\n\n`min_new_tokens=30` pushed generation past the opening formulas into actual content.\n\n---\n\n# What Did Not Help\n\n## 1. NLLB-200 1.3B vs 600M\n\nVery small leaderboard movement despite much larger compute cost.\n\nThe bottleneck appeared to be:\n\n- data quality\n- representation\n- tokenization\n\nrather than model size.\n\n---\n\n## 2. ORACC External Data\n\nAdding ~900 filtered ORACC pairs increased validation loss:\n\n```text\nBaseline (no ORACC): val_loss = 1.575\nWith ORACC:          val_loss = 1.686\n```\n\nLikely causes:\n\n- dialect mismatch\n- Neo-Assyrian vs Old Assyrian\n- inconsistent gap alignment\n- different scribal conventions\n\n---\n\n## 3. Forced Proper-Noun Decoding\n\nI built ~11k proper noun constraints from:\n\n```text\nOA_Lexicon_eBL.csv\n```\n\nusing:\n\n```python\nforce_words_ids\n```\n\nIn practice, constrained decoding failed on most generations and frequently reverted to unconstrained fallback.\n\nN-best reranking by proper noun overlap helped slightly, but forced decoding itself was not useful.\n\n---\n\n# chrF++ vs BLEU — The Diagnostic That Explained the Ceiling\n\nThe most interesting diagnostic was the divergence between chrF++ and BLEU.\n\n| Metric | Validation | Leaderboard |\n|---|---|---|\n| BLEU | 20.00 | ~12 |\n| chrF++ | 43.66 | ~21 |\n\nchrF++ rewards partial character overlap.\n\nBLEU rewards exact lexical matches.\n\nThe model learned approximate transliteration structure but struggled with exact Akkadian word reconstruction.\n\nThis appears closely related to subword tokenization fragmentation.\n\nExample:\n\n```text\niš-qú-ul\n```\n\nbecomes fragmented into multiple unrelated subword pieces rather than a single coherent morphological unit.\n\n---\n\n# Why ByT5 May Be Better\n\nByte-level architectures such as ByT5 avoid fixed vocabularies entirely.\n\nPotential advantages:\n\n- no BPE fragmentation\n- direct character modeling\n- stronger handling of rare morphology\n- better transliteration robustness\n\nGiven the chrF++ / BLEU divergence pattern, byte-level models may ultimately be a better fit for Akkadian MT than subword-based architectures.\n\n---\n\n# Things I Would Do Differently\n\n1. Fix tokenizer diagnostics immediately instead of assuming the model failed to learn `<gap>`\n\n2. Mine publication PDFs earlier for additional parallel data\n\n3. Test ByT5 much earlier as a baseline architecture\n\n4. Domain-check external corpora before merging datasets\n\n5. Focus more on alignment consistency than decoding tricks\n\n---\n\n## Lessons Learned\n\n- Silent preprocessing and decoding bugs can matter more than model scale.\n- Alignment consistency mattered more than aggressive augmentation.\n- chrF++ and BLEU diverged significantly on Akkadian transliterations.\n- Domain mismatch in external corpora can hurt more than small dataset size.\n- Byte-level architectures such as ByT5 may ultimately be a better fit for Akkadian MT than subword-based models.\n\n---\n\nHappy to discuss any of the diagnostics — especially the `<gap>` token issue since I suspect others may have encountered similar behavior without realizing it.\n\n*Full technical report: https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*\n",
      "votes": -1
    }
  ],
  "comments": [],
  "raw_markdown_by_id": {
    "3458490": "# Deep Past Challenge — NLLB Post-Mortem and Debugging Notes\n\nFinal scores: **Public 24.87 / Private 24.41** (NLLB-200 1.3B)\n\nThis write-up focuses primarily on debugging, preprocessing, and decoding diagnostics rather than leaderboard optimization alone.\n\nNot a top finish, but I learned a lot about what breaks silently in low-resource MT pipelines. Sharing the diagnostics because I think some of these findings are useful regardless of approach.\n\n---\n\n## The Setup\n\n- Base model: NLLB-200 distilled 600M → later 1.3B\n- Fine-tuning: LoRA (`r=32`, `alpha=64`)\n- Target modules: `q_proj`, `k_proj`, `v_proj`, `out_proj`, `fc1`, `fc2`\n- Training pairs: started at 1,561 → ended at ~5,700 after data expansion\n- Hardware: P100 (600M), TPU v5e-8 (1.3B)\n- Framework: `transformers==4.38.2` (pinned — more on why below)\n\n---\n\n# Finding 1: The `<gap>` Token Was Being Silently Deleted During Decode\n\nThis turned out to be the most impactful bug in the pipeline.\n\nThe `<gap>` token appears in ~50% of validation references. My model was predicting effectively zero gaps for weeks. I assumed the model simply had not learned the token yet.\n\nThen I ran this diagnostic:\n\n```python\ngap_id = tokenizer.convert_tokens_to_ids('<gap>')\nprint('Is special token:', gap_id in tokenizer.all_special_ids)\n\n# Encode a test sentence with <gap>\ntest = \"1 mina of silver <gap> Puzur-Aššur <gap> has paid\"\n\nenc = tokenizer(test, return_tensors='pt')\n\ndec_skip = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=True\n)\n\ndec_keep = tokenizer.decode(\n    enc['input_ids'][0],\n    skip_special_tokens=False\n)\n\nprint('With skip_special_tokens=True :', dec_skip)\nprint('With skip_special_tokens=False:', dec_keep)\n```\n\nOutput:\n\n```text\nIs special token: True\n\nWith skip_special_tokens=True :\n1 mina of silver Puzur-Aššur has paid\n\nWith skip_special_tokens=False:\n1 mina of silver <gap> Puzur-Aššur <gap> has paid\n```\n\nThe model **was** predicting gaps correctly during training.\n\nBut because `<gap>` had been registered as an `additional_special_token`, every predicted gap was being removed during decoding with:\n\n```python\nskip_special_tokens=True\n```\n\nThe evaluation pipeline never saw a single gap token.\n\n---\n\n## The Fix\n\n```python\n# BROKEN\n# <gap> added as special token -> stripped during decode\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG, '<gap>']\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED\n# <gap> added as regular token -> survives decode\n\ntokenizer.add_tokens(['<gap>'] + extra_tokens)\n\nspecial_tokens = {\n    'additional_special_tokens': [SRC_LANG]\n}\n\ntokenizer.add_special_tokens(special_tokens)\n\n\n# FIXED DECODE\n# manually strip only structural tokens\n\npred_texts = tokenizer.batch_decode(\n    outputs,\n    skip_special_tokens=False\n)\n\nfor tok in [\n    '</s>',\n    '<s>',\n    '<pad>',\n    '<unk>',\n    'eng_Latn',\n    'akk_Latn'\n]:\n    pred_texts = [\n        p.replace(tok, '')\n        for p in pred_texts\n    ]\n```\n\nThis fix alone appeared to improve the geometric mean by roughly +1.7 on validation and leaderboard experiments.\n\nNo retraining required — only fixing the decode pipeline.\n\n---\n\n# Finding 2: Even After the Fix, the Model Still Avoided Predicting Gaps\n\nAfter fixing decoding, I expected `<gap>` predictions to appear naturally.\n\nThey still mostly did not.\n\nI ran a gap alignment diagnostic on the merged training data:\n\n```python\nsrc_gap = train_df['text_akk'].str.contains(\n    '<gap>',\n    na=False\n)\n\ntgt_gap = train_df['text_en'].str.contains(\n    '<gap>',\n    na=False\n)\n\nprint('Source has <gap>:', src_gap.sum())\nprint('Target has <gap>:', tgt_gap.sum())\n\nprint(\n    'Source ONLY (problem):',\n    (src_gap & ~tgt_gap).sum()\n)\n```\n\nOutput:\n\n```text\nSource has <gap>: 920 rows\nTarget has <gap>: 686 rows\nSource ONLY (problem): 329 rows\n```\n\n329 rows contained `<gap>` in the source but not in the target.\n\nThe model learned the statistically safer strategy:\n\n> never output a gap\n\nbecause a substantial fraction of source-gap examples had no target-side gap.\n\n---\n\n## Postprocessing Band-Aid\n\n```python\ndef postprocess_gap_alignment(\n    src_text,\n    pred_text\n):\n\n    src_gaps  = src_text.count('<gap>')\n    pred_gaps = pred_text.count('<gap>')\n\n    if src_gaps == 0:\n        return pred_text\n\n    if pred_gaps >= src_gaps * 0.5:\n        return pred_text\n\n    missing = src_gaps - pred_gaps\n    words   = pred_text.split()\n\n    positions = [\n        max(\n            0,\n            int(len(words) * (i + 1) / (missing + 1))\n        )\n        for i in range(missing)\n    ]\n\n    for offset, pos in enumerate(positions):\n        words.insert(pos + offset, '<gap>')\n\n    return ' '.join(words)\n```\n\nI tested four postprocess strategies on identical raw predictions:\n\n| Strategy | Gaps Injected | Geo Mean |\n|---|---|---|\n| None | 0 | 26.71 |\n| Conservative | 82 | 27.24 |\n| Half | ~200 | 27.89 |\n| **Aggressive (0.5 threshold)** | **420** | **28.45** |\n\nAggressive injection improved chrF++ overlap enough to offset BLEU penalties from over-injection.\n\n### Real fix for future work\n\n- Retrain with `<gap>` as a regular token from the beginning\n- Clean up source-only gap alignment rows\n\n---\n\n# Finding 3: Group Beam Search Required Downgrading Transformers\n\nIn `transformers==5.x` (Kaggle default), Group Beam Search was moved out of core and requires dynamic loading:\n\n```text\nValueError:\nGroup Beam Search requires trust_remote_code=True\n```\n\nOffline Kaggle inference could not resolve the external repository.\n\nAfter multiple failed workarounds, the cleanest solution was:\n\n```python\ntransformers==4.38.2\n```\n\nwhere Group Beam Search still exists natively.\n\n---\n\n## Offline Inference Install\n\n```python\nsubprocess.run([\n    sys.executable,\n    '-m',\n    'pip',\n    'install',\n    '--no-index',\n    '--find-links=/kaggle/input/datasets/amritha18/transformers-4-38-offline-wheels',\n    '--force-reinstall',\n    '--no-deps',\n    '--quiet',\n    'transformers==4.38.2',\n    'tokenizers==0.15.2',\n    'peft==0.10.0',\n], check=True)\n```\n\n---\n\n## Did Group Beam Search Help?\n\n| Decoding | Geo Mean | Notes |\n|---|---|---|\n| Standard beam | ~22–23 | Short outputs |\n| **Group beam** | **~24–25** | Longer, more complete |\n\nIt helped noticeably on long tablet translations where standard beam tended to collapse into short generic phrases.\n\n---\n\n# Finding 4: Document-Level Training vs Sentence-Level Test\n\nThe training set is largely document-level while the test set behaves more like sentence-level translation.\n\nThis mismatch caused:\n\n- truncation after formulaic openings\n- hallucination loops\n- brevity penalties\n- incomplete generations\n\nThe solution was reconstructing sentence-level pairs using:\n\n```text\nSentences_Oare_FirstWord_LinNum.csv\n```\n\nFor tablets containing `um-ma`, I split around the epistolary opening.\n\n---\n\n## Sentence Reconstruction\n\n```python\ndef split_on_umma(\n    akk_text,\n    en_text\n):\n\n    akk_parts = re.split(\n        r'(?=\\bum-ma\\b)',\n        akk_text.strip()\n    )\n\n    akk_parts = [\n        p.strip()\n        for p in akk_parts\n        if len(p.strip()) > 10\n    ]\n\n    en_parts = re.split(\n        r'(?<=[.!?])\\s+(?=[A-Z])',\n        en_text.strip()\n    )\n\n    en_parts = [\n        p.strip()\n        for p in en_parts\n        if len(p.strip()) > 10\n    ]\n\n    if (\n        len(akk_parts) == len(en_parts)\n        and len(akk_parts) > 1\n    ):\n        return list(zip(akk_parts, en_parts))\n\n    return None\n```\n\n---\n\n## Data Expansion\n\n| Stage | Pairs |\n|---|---|\n| Raw train.csv | 1,561 |\n| After sentence splitting | ~1,600 |\n| + published_texts.csv | +3,522 |\n| Final filtered dataset | **5,009** |\n\n---\n\n# Finding 5: `min_new_tokens` Needed Careful Tuning\n\nTo reduce truncation after headers, I experimented with `min_new_tokens`.\n\n| Config | Geo Mean | Brevity Penalty |\n|---|---|---|\n| None | 27.24 | 0.890 |\n| `min_new_tokens=50` | 27.24 | 0.890 |\n| `min_new_tokens=30` | 28.24 | 0.934 |\n| `+ aggressive gap postprocess` | **29.55** | **0.998** |\n\n`min_new_tokens=50` surprisingly had almost no effect.\n\nMost headers are only ~8–12 tokens, so the model still stopped immediately after formulaic openings.\n\n`min_new_tokens=30` pushed generation past the opening formulas into actual content.\n\n---\n\n# What Did Not Help\n\n## 1. NLLB-200 1.3B vs 600M\n\nVery small leaderboard movement despite much larger compute cost.\n\nThe bottleneck appeared to be:\n\n- data quality\n- representation\n- tokenization\n\nrather than model size.\n\n---\n\n## 2. ORACC External Data\n\nAdding ~900 filtered ORACC pairs increased validation loss:\n\n```text\nBaseline (no ORACC): val_loss = 1.575\nWith ORACC:          val_loss = 1.686\n```\n\nLikely causes:\n\n- dialect mismatch\n- Neo-Assyrian vs Old Assyrian\n- inconsistent gap alignment\n- different scribal conventions\n\n---\n\n## 3. Forced Proper-Noun Decoding\n\nI built ~11k proper noun constraints from:\n\n```text\nOA_Lexicon_eBL.csv\n```\n\nusing:\n\n```python\nforce_words_ids\n```\n\nIn practice, constrained decoding failed on most generations and frequently reverted to unconstrained fallback.\n\nN-best reranking by proper noun overlap helped slightly, but forced decoding itself was not useful.\n\n---\n\n# chrF++ vs BLEU — The Diagnostic That Explained the Ceiling\n\nThe most interesting diagnostic was the divergence between chrF++ and BLEU.\n\n| Metric | Validation | Leaderboard |\n|---|---|---|\n| BLEU | 20.00 | ~12 |\n| chrF++ | 43.66 | ~21 |\n\nchrF++ rewards partial character overlap.\n\nBLEU rewards exact lexical matches.\n\nThe model learned approximate transliteration structure but struggled with exact Akkadian word reconstruction.\n\nThis appears closely related to subword tokenization fragmentation.\n\nExample:\n\n```text\niš-qú-ul\n```\n\nbecomes fragmented into multiple unrelated subword pieces rather than a single coherent morphological unit.\n\n---\n\n# Why ByT5 May Be Better\n\nByte-level architectures such as ByT5 avoid fixed vocabularies entirely.\n\nPotential advantages:\n\n- no BPE fragmentation\n- direct character modeling\n- stronger handling of rare morphology\n- better transliteration robustness\n\nGiven the chrF++ / BLEU divergence pattern, byte-level models may ultimately be a better fit for Akkadian MT than subword-based architectures.\n\n---\n\n# Things I Would Do Differently\n\n1. Fix tokenizer diagnostics immediately instead of assuming the model failed to learn `<gap>`\n\n2. Mine publication PDFs earlier for additional parallel data\n\n3. Test ByT5 much earlier as a baseline architecture\n\n4. Domain-check external corpora before merging datasets\n\n5. Focus more on alignment consistency than decoding tricks\n\n---\n\n## Lessons Learned\n\n- Silent preprocessing and decoding bugs can matter more than model scale.\n- Alignment consistency mattered more than aggressive augmentation.\n- chrF++ and BLEU diverged significantly on Akkadian transliterations.\n- Domain mismatch in external corpora can hurt more than small dataset size.\n- Byte-level architectures such as ByT5 may ultimately be a better fit for Akkadian MT than subword-based models.\n\n---\n\nHappy to discuss any of the diagnostics — especially the `<gap>` token issue since I suspect others may have encountered similar behavior without realizing it.\n\n*Full technical report: https://github.com/Amrith182007/kaggle-deep-past-challenge-akkadian-mt*\n"
  }
}