AstralPotato commited on
Commit
d7fa769
·
verified ·
1 Parent(s): e7f17a4

v2: 2M training, dropout 0.1, full-corpus tokenizer — chrF 48.93 (was 45.62)

Browse files
Files changed (5) hide show
  1. README.md +92 -69
  2. best_model.pt +1 -1
  3. config.json +27 -8
  4. src/training.py +46 -2
  5. tokenizer_shared_16k.json +0 -0
README.md CHANGED
@@ -29,11 +29,11 @@ model-index:
29
  split: test
30
  metrics:
31
  - type: chrf
32
- value: 45.62
33
  name: chrF (greedy)
34
  - type: chrf
35
- value: 44.99
36
- name: chrF (beam=5)
37
  ---
38
 
39
  # English → Malay Transformer (6+2 Tied, 16K BPE)
@@ -49,8 +49,8 @@ The project encompasses the full NMT pipeline: dataset curation, tokenizer train
49
  | **Architecture** | 6-layer encoder + 2-layer decoder, pre-norm Transformer |
50
  | **d_model / n_head / d_ff** | 512 / 8 / 2048 |
51
  | **Vocab** | 16,000 shared BPE (English + Malay, joint) |
52
- | **Dropout** | 0.3 |
53
- | **Parameters** | ~36.6M |
54
  | **Tied embeddings** | Yes — encoder input, decoder input, and output projection share the same weight matrix (Press & Wolf, 2017) |
55
  | **Normalisation** | Pre-norm (LayerNorm before attention/FFN, not after) |
56
 
@@ -64,22 +64,24 @@ The shallow 2-layer decoder provides a practical speed advantage: ~2× faster in
64
 
65
  **Why 16K shared vocabulary?**
66
 
67
- We initially trained with 50K vocabulary but found it too sparse for 500K training sentences — most tokens appeared very infrequently, leaving embeddings under-trained. Reducing to 16K shared BPE produced denser embeddings and led to a 2. speedup per epoch (7.8 min vs ~20 min estimated at 50K). English and Malay share the Latin script with substantial lexical overlap (loanwords like "teknologi", "universiti"; numbers; proper nouns), making a joint vocabulary highly effective.
 
 
68
 
69
  **Why tied embeddings?**
70
 
71
- With a shared source-target vocabulary, tying the encoder embedding, decoder embedding, and output projection matrix (Press & Wolf, 2017) reduces the parameter count by ~16M while acting as a strong regulariser. The model learns a single semantic space for both languages.
72
 
73
- **Why dropout 0.3?**
74
 
75
- 490K training sentences is relatively small for a Transformer. Dropout 0.3 was chosen as aggressive regularisation to prevent overfitting. Training curves confirm this was appropriate — the gap between train loss (3.17) and val loss (3.21) remained small throughout training, with no signs of overfitting even at epoch 20.
76
 
77
  ## Training Data
78
 
79
  - **Dataset:** [OpenSubtitles v2018](https://opus.nlpl.eu/OpenSubtitles-v2018.php) (English-Malay aligned parallel corpus)
80
  - **Raw corpus size:** ~17.3M parallel sentence pairs
81
- - **After filtering:** 500,000 pairs selected
82
- - **Split:** 490,000 train / 5,000 validation / 5,000 test (all in-distribution)
83
 
84
  ### Data Preprocessing Pipeline
85
 
@@ -91,12 +93,24 @@ The raw OpenSubtitles corpus is notoriously noisy (subtitle artifacts, music sym
91
  4. **Junk pattern removal:** Regex filter for music symbols (♪♫), HTML tags, bracket-only lines (e.g. `[music playing]`), ellipsis-only lines, dash-only lines
92
  5. **Deduplication:** Case-insensitive exact match on the English side
93
 
94
- This pipeline retains ~500K high-quality pairs from the first ~2.7M lines scanned.
95
-
96
  ### Why OpenSubtitles over TED Talks?
97
 
98
  We initially experimented with the [IWSLT TED Talks](https://huggingface.co/datasets/IWSLT/ted_talks_iwslt) dataset (~5K en-ms pairs) and achieved a chrF of only **6.76** — the dataset was far too small. We then moved to OpenSubtitles which provides orders of magnitude more data. Importantly, we evaluate on **in-distribution** OpenSubtitles test data rather than using TED Talks as an out-of-distribution test set, which would unfairly penalise the model for domain mismatch (conversational subtitles vs. formal TED lectures).
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ## Ablation Studies
101
 
102
  We conducted two systematic ablation sweeps to guide architecture and data decisions. All sweeps used a 50K vocabulary baseline with 3 training epochs for efficiency.
@@ -112,7 +126,7 @@ Fixed: 50K vocab, 500K data, 2-layer decoder, 3 epochs.
112
  | 6 | 24.65 | 3.80 | 74.6M |
113
  | 8 | 22.91 | 3.76 | 87.6M |
114
 
115
- **Finding:** Encoder depth has **flat returns** on downstream chrF despite steadily decreasing validation loss. This suggests the TED Talks OOD test set was the bottleneck (confirmed later), not model capacity. We selected 6 layers as the sweet spot — the lowest loss before severe diminishing returns, and well-supported by the Kasai et al. finding.
116
 
117
  ### Sweep 2: Training Data Size
118
 
@@ -125,72 +139,86 @@ Fixed: 50K vocab, 6+2 architecture, 3 epochs.
125
  | 200K | 22.47 | 3.93 |
126
  | 500K | 26.50 | 3.75 |
127
 
128
- **Finding:** chrF scales **approximately linearly with log(data size)** — a ~3.3 chrF improvement per doubling. This confirmed that **data volume is the dominant factor** for translation quality at this scale, far more impactful than architectural changes. This motivated our final model to use the maximum feasible data (490K after filtering).
129
 
130
  ## Training Details
131
 
132
  | Setting | Value |
133
  |---|---|
134
  | Optimizer | AdamW (lr=5e-4, β₁=0.9, β₂=0.98, ε=1e-9) |
135
- | Schedule | Linear warmup (4,000 steps) → cosine decay to 0 |
136
  | Batch size | 128 |
137
  | Max sequence length | 128 tokens |
138
- | Epochs | 20 (early stopping patience=3, did not trigger) |
 
139
  | Label smoothing | 0.1 |
140
  | Gradient clipping | max_norm=1.0 |
 
141
  | AMP | fp16 mixed precision (PyTorch GradScaler) |
142
- | Hardware | NVIDIA RTX 5070 Ti (16GB VRAM), CUDA 13.1 |
143
- | Training time | **2.62 hours** (157 min, ~7.85 min/epoch) |
144
-
145
- ### Training Progression
146
-
147
- | Epoch | Train Loss | Val Loss | LR |
148
- |---|---|---|---|
149
- | 1 | 5.4036 | 4.2485 | 6.5e-5 |
150
- | 5 | 3.5888 | 3.4519 | 3.7e-4 |
151
- | 10 | 3.3605 | 3.2986 | 3.7e-4 |
152
- | 15 | 3.2268 | 3.2346 | 1.8e-4 |
153
- | 20 | 3.1683 | 3.2110 | 4.9e-6 |
154
-
155
- The model converged smoothly with no overfitting — the train-val gap remained under 0.05 throughout training. The cosine LR decay drove the final epochs to squeeze out the last bits of improvement (val loss 3.23 at epoch 15 → 3.21 at epoch 20).
156
 
157
  ## Evaluation Results
158
 
159
  Evaluated on **5,000 held-out in-distribution** OpenSubtitles test sentences with post-processing applied.
160
 
161
- | Decoding Strategy | chrF |
162
  |---|---|
163
- | Greedy | **45.62** |
164
- | Beam search (beam=5, length_penalty=0.6) | 44.99 |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  ### Post-Processing
167
 
168
- The BPE tokenizer uses a `Whitespace` pre-tokenizer without continuation markers, so raw `decode()` output contains spurious spaces before punctuation (e.g., `"mendarat , tuan ."` instead of `"mendarat, tuan."`). We apply a lightweight regex-based post-processing step that:
169
 
170
  1. Removes spaces before punctuation marks (`. , ? ! ; :`)
171
  2. Removes spaces after opening brackets/quotes
172
  3. Collapses spaced hyphens in compound words
173
  4. Capitalises the first character
174
 
175
- This post-processing improved chrF by **+1.05 points** (greedy: 44.57 → 45.62) — a free gain with zero retraining.
176
-
177
- ### Why Greedy > Beam Search?
178
-
179
- Interestingly, greedy decoding outperforms beam search here. This is a known phenomenon in NMT: beam search with length penalty can produce outputs that are slightly too long or too short for chrF's character n-gram matching. Greedy decoding produces more "natural length" outputs that happen to align better with reference lengths in this corpus.
180
-
181
  ### Sample Translations
182
 
183
  | # | English (Source) | Reference (Malay) | Model Output |
184
  |---|---|---|---|
185
- | 1 | Skywalker has just landed, lord. | Skywalker baru sahaja mendarat, tuan. | Skywalker baru mendarat, tuan. |
186
- | 2 | Raymond, you like me? | Raymond, awak suka saya? | Raymond, awak suka saya? |
187
- | 3 | She may be dying and it's all my fault. | Dia mungkin akan mati dan semuanya salah saya. | Dia mungkin akan mati dan semuanya salah saya. |
188
- | 4 | He always remembers the cards. | Ia ingat kad. | Dia selalu ingat kad. |
189
- | 5 | Hey, you wanna see something? | Hei, awak nak tengok sesuatu? | Hei, awak nak lihat sesuatu? |
190
- | 6 | Why don't you just go talk to her? | Mengapa awak tidak bercakap dengannya? | Apa kata awak cakap dengan dia? |
191
- | 7 | We still got that meat-lovers' pizza in the trunk. | Kita masih ada piza daging dalam but. | Kita masih ada piza daging di dalam but kereta. |
192
-
193
- The model produces fluent, natural Malay that is often comparable or near-identical to the reference translations. Errors tend to occur on rare proper nouns (subword fragmentation) and idiomatic expressions.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  ## Tokenizer
196
 
@@ -200,7 +228,7 @@ The model produces fluent, natural Malay that is often comparable or near-identi
200
  - **Pre-tokenization:** Whitespace splitting
201
  - **Post-processing:** `[BOS] $A [EOS]` template (auto-wraps encoded sequences)
202
  - **Special tokens:** `[PAD]=0, [UNK]=1, [CLS]=2, [SEP]=3, [MASK]=4, [BOS]=5, [EOS]=6`
203
- - **Trained on:** 490K training pairs only (980K sentences total) — no data leakage from val/test
204
 
205
  ### Why Shared BPE for en-ms?
206
 
@@ -221,7 +249,7 @@ from src.model import build_model
221
  model = build_model(
222
  vocab_size=16000, pad_idx=0, device=torch.device("cpu"),
223
  d_model=512, n_head=8, num_encoder_layers=6, num_decoder_layers=2,
224
- d_ff=2048, dropout=0.3, max_len=144,
225
  )
226
  model.load_state_dict(torch.load("best_model.pt", map_location="cpu", weights_only=True))
227
  model.eval()
@@ -230,16 +258,16 @@ model.eval()
230
  from src.eval import translate
231
  result = translate(model, "Hello, how are you?", tokenizer, tokenizer,
232
  bos_id=5, eos_id=6, pad_id=0, max_len=128,
233
- device=torch.device("cpu"), beam_width=5)
234
- print(result) # → "Hai, apa khabar?"
235
  ```
236
 
237
  ## Repository Structure
238
 
239
  | File | Description |
240
  |---|---|
241
- | `best_model.pt` | Model weights (`state_dict` format, ~140MB) |
242
- | `tokenizer_shared_16k.json` | Shared BPE tokenizer (16K vocab) |
243
  | `config.json` | Full model configuration and training hyperparameters |
244
  | `src/model.py` | `TransformerTranslator` — complete encoder-decoder architecture |
245
  | `src/tokenizer.py` | BPE tokenizer training, saving, loading, encoding, decoding |
@@ -254,30 +282,25 @@ This project went through several iterations:
254
  2. **OPUS-100 pivot** — Switched to OPUS-100 en-ms. chrF **26.39** with 10+2 architecture. Significant improvement but still limited by data quality.
255
  3. **OpenSubtitles pivot** — Moved to OpenSubtitles v2018 (17.3M raw pairs). Quality filtering pipeline developed.
256
  4. **Ablation sweeps** — Systematically tested encoder depth (2/4/6/8) and data size (50K/100K/200K/500K). Discovered data size is the dominant factor.
257
- 5. **Final model** — 6+2 tied Transformer, 16K BPE, 490K data, dropout 0.3. chrF **44.57** (greedy, no postprocessing).
258
- 6. **Post-processing fix** — Added punctuation cleanup. chrF **45.62** (greedy). Free +1.05 improvement.
259
 
260
- ## Limitations and Future Work
261
 
262
- ### Current Limitations
263
- - **Domain specificity:** Trained exclusively on movie/TV subtitles performance degrades significantly on formal, academic, or technical text (e.g., TED Talks test set gave chrF ~6–26 depending on configuration).
264
- - **Subword fragmentation:** Rare proper nouns and domain-specific terms get split into character-level fragments (e.g., "Burgundy" → "bur gun dy", "android" → "dan ro id"). A larger vocabulary or byte-level fallback could mitigate this.
265
- - **16K vocab trade-off:** The compact vocabulary provides dense embeddings but over-segments rare words. A 32K vocabulary might be a better balance.
266
  - **No backtranslation or data augmentation:** The model trains on natural parallel data only.
267
-
268
- ### Future Improvements
269
- - **Scale data to 2M+**: Our sweep shows chrF gains ~3.3 points per data doubling. 2M sentences could push chrF to ~50+.
270
- - **Reduce dropout to 0.1**: With more data, the aggressive 0.3 dropout likely over-regularises.
271
- - **Byte-level fallback**: Handle rare words more gracefully.
272
- - **Ensemble decoding**: Combine checkpoints from different training stages.
273
 
274
  ## References
275
 
276
  - Vaswani, A. et al. (2017). [Attention is All You Need](https://arxiv.org/abs/1706.03762). *NeurIPS*.
277
  - Kasai, J. et al. (2021). [Deep Encoder, Shallow Decoder: Reevaluating Non-autoregressive Machine Translation](https://arxiv.org/abs/2006.10369). *ICLR*.
278
  - Press, O. & Wolf, L. (2017). [Using the Output Embedding to Improve Language Models](https://arxiv.org/abs/1608.05859). *EACL*.
 
279
  - Popović, M. (2015). [chrF: character n-gram F-score for automatic MT evaluation](https://aclanthology.org/W15-3049/). *WMT*.
280
  - Sennrich, R. et al. (2016). [Neural Machine Translation of Rare Words with Subword Units](https://arxiv.org/abs/1508.07909). *ACL*.
 
281
  - Lison, P. & Tiedemann, J. (2016). [OpenSubtitles2016: Extracting Large Parallel Corpora from Movie and TV Subtitles](http://www.lrec-conf.org/proceedings/lrec2016/pdf/947_Paper.pdf). *LREC*.
282
 
283
  ## Citation
 
29
  split: test
30
  metrics:
31
  - type: chrf
32
+ value: 48.93
33
  name: chrF (greedy)
34
  - type: chrf
35
+ value: 49.5
36
+ name: chrF (clean refs only)
37
  ---
38
 
39
  # English → Malay Transformer (6+2 Tied, 16K BPE)
 
49
  | **Architecture** | 6-layer encoder + 2-layer decoder, pre-norm Transformer |
50
  | **d_model / n_head / d_ff** | 512 / 8 / 2048 |
51
  | **Vocab** | 16,000 shared BPE (English + Malay, joint) |
52
+ | **Dropout** | 0.1 |
53
+ | **Parameters** | ~27M |
54
  | **Tied embeddings** | Yes — encoder input, decoder input, and output projection share the same weight matrix (Press & Wolf, 2017) |
55
  | **Normalisation** | Pre-norm (LayerNorm before attention/FFN, not after) |
56
 
 
64
 
65
  **Why 16K shared vocabulary?**
66
 
67
+ We initially trained with 50K vocabulary but found it too sparse for our data — most tokens appeared very infrequently, leaving embeddings under-trained. Reducing to 16K shared BPE produced denser embeddings and faster training. English and Malay share the Latin script with substantial lexical overlap (loanwords like "teknologi", "universiti"; numbers; proper nouns), making a joint vocabulary highly effective.
68
+
69
+ The tokenizer was trained on the **full filtered OpenSubtitles corpus (~17M lines)**, not just the 2M training split. BPE only needs raw text frequency statistics — more text = better merge rules — and noisy translations don't hurt tokenizer training since it just counts character n-grams.
70
 
71
  **Why tied embeddings?**
72
 
73
+ With a shared source-target vocabulary, tying the encoder embedding, decoder embedding, and output projection matrix (Press & Wolf, 2017) reduces the parameter count by ~8M while acting as a strong regulariser. The model learns a single semantic space for both languages.
74
 
75
+ **Why dropout 0.1?**
76
 
77
+ With 2M training sentences, aggressive dropout (0.3) would over-regularise. Dropout 0.1 is the standard Transformer default and was confirmed appropriate — the train-val gap remained small throughout training.
78
 
79
  ## Training Data
80
 
81
  - **Dataset:** [OpenSubtitles v2018](https://opus.nlpl.eu/OpenSubtitles-v2018.php) (English-Malay aligned parallel corpus)
82
  - **Raw corpus size:** ~17.3M parallel sentence pairs
83
+ - **After filtering:** 2,010,000 pairs selected
84
+ - **Split:** 2,000,000 train / 5,000 validation / 5,000 test (all in-distribution)
85
 
86
  ### Data Preprocessing Pipeline
87
 
 
93
  4. **Junk pattern removal:** Regex filter for music symbols (♪♫), HTML tags, bracket-only lines (e.g. `[music playing]`), ellipsis-only lines, dash-only lines
94
  5. **Deduplication:** Case-insensitive exact match on the English side
95
 
 
 
96
  ### Why OpenSubtitles over TED Talks?
97
 
98
  We initially experimented with the [IWSLT TED Talks](https://huggingface.co/datasets/IWSLT/ted_talks_iwslt) dataset (~5K en-ms pairs) and achieved a chrF of only **6.76** — the dataset was far too small. We then moved to OpenSubtitles which provides orders of magnitude more data. Importantly, we evaluate on **in-distribution** OpenSubtitles test data rather than using TED Talks as an out-of-distribution test set, which would unfairly penalise the model for domain mismatch (conversational subtitles vs. formal TED lectures).
99
 
100
+ ## Proxy LR Sweep
101
+
102
+ Before the full 2M training, we ran a **proxy LR sweep** on a 200K subset (8 epochs, no early stopping) to select the learning rate without needing multiple expensive full-scale runs:
103
+
104
+ | LR | Val Loss | chrF (greedy) | Best Epoch |
105
+ |---|---|---|---|
106
+ | 3e-4 | 3.4254 | 43.46 | 8 |
107
+ | **5e-4** | **3.3471** | **44.17** | **7** |
108
+ | 7e-4 | 3.3375 | 43.81 | 8 |
109
+
110
+ **Winner: LR = 5e-4** — highest chrF on the 5K test set.
111
+
112
+ **Rationale:** LR transfers well across data scales (Kaplan et al., 2020). Running the sweep on 200K avoids training on 2M multiple times, saving ~7 hours of GPU time.
113
+
114
  ## Ablation Studies
115
 
116
  We conducted two systematic ablation sweeps to guide architecture and data decisions. All sweeps used a 50K vocabulary baseline with 3 training epochs for efficiency.
 
126
  | 6 | 24.65 | 3.80 | 74.6M |
127
  | 8 | 22.91 | 3.76 | 87.6M |
128
 
129
+ **Finding:** Encoder depth has **flat returns** on downstream chrF despite steadily decreasing validation loss. This suggests the TED Talks OOD test set was the bottleneck (confirmed later), not model capacity. We selected 6 layers as the sweet spot.
130
 
131
  ### Sweep 2: Training Data Size
132
 
 
139
  | 200K | 22.47 | 3.93 |
140
  | 500K | 26.50 | 3.75 |
141
 
142
+ **Finding:** chrF scales **approximately linearly with log(data size)** — a ~3.3 chrF improvement per doubling. This confirmed that **data volume is the dominant factor** for translation quality at this scale, motivating our final model to use 2M sentences.
143
 
144
  ## Training Details
145
 
146
  | Setting | Value |
147
  |---|---|
148
  | Optimizer | AdamW (lr=5e-4, β₁=0.9, β₂=0.98, ε=1e-9) |
149
+ | Schedule | Linear warmup (8,000 steps, ~0.5 epochs) → cosine decay to 0 |
150
  | Batch size | 128 |
151
  | Max sequence length | 128 tokens |
152
+ | Epochs | 17 of 20 max (early stopping, patience=3) |
153
+ | Best epoch | 14 (val loss 2.8176) |
154
  | Label smoothing | 0.1 |
155
  | Gradient clipping | max_norm=1.0 |
156
+ | Dropout | 0.1 |
157
  | AMP | fp16 mixed precision (PyTorch GradScaler) |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  ## Evaluation Results
160
 
161
  Evaluated on **5,000 held-out in-distribution** OpenSubtitles test sentences with post-processing applied.
162
 
163
+ | Metric | Score |
164
  |---|---|
165
+ | **chrF (greedy, all refs)** | **48.93** |
166
+ | **chrF (greedy, clean refs only)** | **49.5** |
167
+ | Best validation loss | 2.8176 |
168
+
169
+ ### Reference Quality Analysis
170
+
171
+ OpenSubtitles community translations contain noise that deflates chrF:
172
+ - **UTF-8 corruption / mojibake** (’, Â, �, etc.)
173
+ - **Truncated references** that drop half the source sentence
174
+ - **Untranslated references** left in English
175
+ - **Indonesian contamination** (OpenSubtitles "ms" is heavily mixed with Bahasa Indonesia)
176
+ - **ALL-CAPS** (burnt-in subtitle OCR artifacts — chrF is case-sensitive)
177
+ - **Leading dashes** (subtitle speaker indicators)
178
+
179
+ We automatically filter these using heuristics + **langid language identification**:
180
+ - **Clean references:** 4,339 (86.8%) → chrF **49.5**
181
+ - **Garbage references:** 661 (13.2%) → chrF dragged down to 48.93
182
+
183
+ The **true model performance** is better represented by the clean-ref score of **49.5 chrF**.
184
 
185
  ### Post-Processing
186
 
187
+ The BPE tokenizer uses a `Whitespace` pre-tokenizer without continuation markers, so raw `decode()` output contains spurious spaces before punctuation. We apply lightweight regex-based post-processing:
188
 
189
  1. Removes spaces before punctuation marks (`. , ? ! ; :`)
190
  2. Removes spaces after opening brackets/quotes
191
  3. Collapses spaced hyphens in compound words
192
  4. Capitalises the first character
193
 
 
 
 
 
 
 
194
  ### Sample Translations
195
 
196
  | # | English (Source) | Reference (Malay) | Model Output |
197
  |---|---|---|---|
198
+ | 1 | Heather, you in here? | Heather, awak ada di sini? | Heather, awak di sini? |
199
+ | 2 | Hey, dude, why do you run? | Hey, dude, mengapa anda menjalankan? | Hei, kawan, kenapa kau lari? |
200
+ | 3 | What about your wife and daughter? | Bagaimana dengan isteri dan anak perempuan awak? | Bagaimana dengan isteri dan anak perempuan awak? |
201
+ | 4 | Thank you, gentlemen. | Terima kasih, tuan-tuan semua. | Terima kasih, tuan-tuan. |
202
+ | 5 | We'll be ready for the shipment. | Kami akan bersedia untuk penghantaran. | Kami akan bersedia untuk penghantaran. |
203
+ | 6 | You're at home. | Awak ada di rumah. | Awak di rumah. |
204
+ | 7 | She may be dying and it's all my fault. | Dia mungkin akan mati dan semuanya salah saya. | Dia mungkin akan mati dan semuanya salah saya. |
205
+
206
+ The model produces fluent, natural Malay that is often comparable or near-identical to the reference translations. Note that in some cases (e.g., #2), **the model output is arguably better Malay** than the reference — "kenapa kau lari?" is more natural than "mengapa anda menjalankan?".
207
+
208
+ ## Improvement over Previous Version
209
+
210
+ | Version | Data | Dropout | LR | Warmup | chrF (greedy) |
211
+ |---|---|---|---|---|---|
212
+ | NB6 (v1, 500K) | 490K | 0.3 | 5e-4 | 4,000 | 45.62 |
213
+ | **NB8 (v2, 2M)** | **2M** | **0.1** | **5e-4** | **8,000** | **48.93** |
214
+ | **Δ** | **+4× data** | **−0.2** | — | **+4,000** | **+3.31** |
215
+
216
+ Key changes in this version:
217
+ 1. **4× more training data** (490K → 2M) — the dominant factor
218
+ 2. **Reduced dropout** (0.3 → 0.1) — less regularisation with more data
219
+ 3. **Full-corpus tokenizer** — BPE trained on all ~17M filtered lines instead of just 490K
220
+ 4. **Proxy LR sweep** — systematic LR selection instead of default
221
+ 5. **Longer warmup** (4,000 → 8,000 steps) — scaled proportionally to data
222
 
223
  ## Tokenizer
224
 
 
228
  - **Pre-tokenization:** Whitespace splitting
229
  - **Post-processing:** `[BOS] $A [EOS]` template (auto-wraps encoded sequences)
230
  - **Special tokens:** `[PAD]=0, [UNK]=1, [CLS]=2, [SEP]=3, [MASK]=4, [BOS]=5, [EOS]=6`
231
+ - **Trained on:** Full filtered OpenSubtitles corpus (~17M lines, both languages)
232
 
233
  ### Why Shared BPE for en-ms?
234
 
 
249
  model = build_model(
250
  vocab_size=16000, pad_idx=0, device=torch.device("cpu"),
251
  d_model=512, n_head=8, num_encoder_layers=6, num_decoder_layers=2,
252
+ d_ff=2048, dropout=0.1, max_len=144,
253
  )
254
  model.load_state_dict(torch.load("best_model.pt", map_location="cpu", weights_only=True))
255
  model.eval()
 
258
  from src.eval import translate
259
  result = translate(model, "Hello, how are you?", tokenizer, tokenizer,
260
  bos_id=5, eos_id=6, pad_id=0, max_len=128,
261
+ device=torch.device("cpu"), beam_width=1)
262
+ print(result)
263
  ```
264
 
265
  ## Repository Structure
266
 
267
  | File | Description |
268
  |---|---|
269
+ | `best_model.pt` | Model weights (`state_dict` format) |
270
+ | `tokenizer_shared_16k.json` | Shared BPE tokenizer (16K vocab, trained on full corpus) |
271
  | `config.json` | Full model configuration and training hyperparameters |
272
  | `src/model.py` | `TransformerTranslator` — complete encoder-decoder architecture |
273
  | `src/tokenizer.py` | BPE tokenizer training, saving, loading, encoding, decoding |
 
282
  2. **OPUS-100 pivot** — Switched to OPUS-100 en-ms. chrF **26.39** with 10+2 architecture. Significant improvement but still limited by data quality.
283
  3. **OpenSubtitles pivot** — Moved to OpenSubtitles v2018 (17.3M raw pairs). Quality filtering pipeline developed.
284
  4. **Ablation sweeps** — Systematically tested encoder depth (2/4/6/8) and data size (50K/100K/200K/500K). Discovered data size is the dominant factor.
285
+ 5. **500K model (v1)** — 6+2 tied Transformer, 16K BPE, 490K data, dropout 0.3. chrF **45.62**.
286
+ 6. **2M model (v2, current)** — Same architecture, 2M data, dropout 0.1, full-corpus tokenizer, proxy LR sweep. chrF **48.93** (clean refs: **49.5**).
287
 
288
+ ## Limitations
289
 
290
+ - **Domain specificity:** Trained exclusively on movie/TV subtitles — performance degrades on formal, academic, or technical text.
291
+ - **Subword fragmentation:** Rare proper nouns and domain-specific terms get split into character-level fragments.
 
 
292
  - **No backtranslation or data augmentation:** The model trains on natural parallel data only.
293
+ - **Reference noise:** OpenSubtitles contains ~13% garbage references (Indonesian instead of Malay, mojibake, truncated). True performance is higher than raw chrF suggests.
 
 
 
 
 
294
 
295
  ## References
296
 
297
  - Vaswani, A. et al. (2017). [Attention is All You Need](https://arxiv.org/abs/1706.03762). *NeurIPS*.
298
  - Kasai, J. et al. (2021). [Deep Encoder, Shallow Decoder: Reevaluating Non-autoregressive Machine Translation](https://arxiv.org/abs/2006.10369). *ICLR*.
299
  - Press, O. & Wolf, L. (2017). [Using the Output Embedding to Improve Language Models](https://arxiv.org/abs/1608.05859). *EACL*.
300
+ - Xiong, R. et al. (2020). [On Layer Normalization in the Transformer Architecture](https://arxiv.org/abs/2002.04745). *ICML*.
301
  - Popović, M. (2015). [chrF: character n-gram F-score for automatic MT evaluation](https://aclanthology.org/W15-3049/). *WMT*.
302
  - Sennrich, R. et al. (2016). [Neural Machine Translation of Rare Words with Subword Units](https://arxiv.org/abs/1508.07909). *ACL*.
303
+ - Kaplan, J. et al. (2020). [Scaling Laws for Neural Language Models](https://arxiv.org/abs/2001.08361). *arXiv*.
304
  - Lison, P. & Tiedemann, J. (2016). [OpenSubtitles2016: Extracting Large Parallel Corpora from Movie and TV Subtitles](http://www.lrec-conf.org/proceedings/lrec2016/pdf/947_Paper.pdf). *LREC*.
305
 
306
  ## Citation
best_model.pt CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:053df05b5a8c77434507d745eee6fff4c52cddfc72abf27330d71f1e8688c3e3
3
  size 142469700
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8880db2378631b79f58b53e693aa673ce5f1f8428e90c96e186a124a0c706d11
3
  size 142469700
config.json CHANGED
@@ -6,7 +6,7 @@
6
  "num_encoder_layers": 6,
7
  "num_decoder_layers": 2,
8
  "d_ff": 2048,
9
- "dropout": 0.3,
10
  "max_len": 144,
11
  "pad_idx": 0,
12
  "bos_id": 5,
@@ -16,21 +16,40 @@
16
  "label_smoothing": 0.1,
17
  "training": {
18
  "dataset": "OpenSubtitles v2018 en-ms",
19
- "train_size": 490000,
20
  "val_size": 5000,
21
  "test_size": 5000,
22
- "epochs_trained": 20,
23
  "batch_size": 128,
24
  "lr": 0.0005,
25
- "warmup_steps": 4000,
26
  "optimizer": "AdamW",
27
  "scheduler": "linear warmup + cosine decay",
28
- "amp": true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  },
30
  "evaluation": {
31
- "chrf_greedy": 45.62,
32
- "chrf_beam5_lp06": 44.99,
 
33
  "test_set": "5K in-distribution OpenSubtitles",
34
- "note": "chrF with post-processing (punctuation cleanup)"
35
  }
36
  }
 
6
  "num_encoder_layers": 6,
7
  "num_decoder_layers": 2,
8
  "d_ff": 2048,
9
+ "dropout": 0.1,
10
  "max_len": 144,
11
  "pad_idx": 0,
12
  "bos_id": 5,
 
16
  "label_smoothing": 0.1,
17
  "training": {
18
  "dataset": "OpenSubtitles v2018 en-ms",
19
+ "train_size": 2000000,
20
  "val_size": 5000,
21
  "test_size": 5000,
22
+ "epochs_trained": 17,
23
  "batch_size": 128,
24
  "lr": 0.0005,
25
+ "warmup_steps": 8000,
26
  "optimizer": "AdamW",
27
  "scheduler": "linear warmup + cosine decay",
28
+ "amp": true,
29
+ "early_stopping_patience": 3,
30
+ "best_epoch": 14,
31
+ "proxy_lr_sweep": {
32
+ "subset_size": 200000,
33
+ "epochs": 8,
34
+ "candidates": [
35
+ 0.0003,
36
+ 0.0005,
37
+ 0.0007
38
+ ],
39
+ "winner": 0.0005
40
+ }
41
+ },
42
+ "tokenizer": {
43
+ "file": "tokenizer_shared_16k.json",
44
+ "vocab_size": 16000,
45
+ "algorithm": "BPE",
46
+ "training_corpus": "Full filtered OpenSubtitles (~17M lines, both languages)"
47
  },
48
  "evaluation": {
49
+ "chrf_greedy": 48.93,
50
+ "chrf_clean_refs_only": 49.5,
51
+ "best_val_loss": 2.8176,
52
  "test_set": "5K in-distribution OpenSubtitles",
53
+ "note": "chrF with post-processing; clean-ref score filters out mojibake, Indonesian contamination, truncated and untranslated refs"
54
  }
55
  }
src/training.py CHANGED
@@ -280,6 +280,8 @@ def train(
280
  cfg: TrainConfig,
281
  device: torch.device,
282
  trial=None,
 
 
283
  ) -> dict:
284
  """
285
  Full training loop with logging, checkpointing, and early stopping.
@@ -288,6 +290,13 @@ def train(
288
  ----------
289
  trial : optuna.trial.Trial, optional
290
  If provided, reports val_loss after each epoch for ASHA pruning.
 
 
 
 
 
 
 
291
 
292
  Returns
293
  -------
@@ -317,12 +326,29 @@ def train(
317
  history: dict = {"train_loss": [], "val_loss": [], "lr": []}
318
  best_val = float("inf")
319
  patience_ctr = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  print(f"\n{'='*60}")
322
- print(f"Starting training: {cfg.epochs} epochs, lr={cfg.lr}, AMP={cfg.use_amp}")
323
  print(f"{'='*60}\n")
324
 
325
- for epoch in range(cfg.epochs):
326
  t0 = time.time()
327
 
328
  train_loss = train_one_epoch(
@@ -364,6 +390,24 @@ def train(
364
  print(f"\n⏹ Early stopping after {cfg.patience} epochs without improvement.")
365
  break
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  # Load best checkpoint
368
  model.load_state_dict(torch.load(ckpt_dir / "best_model.pt", map_location=device, weights_only=True))
369
  print(f"\n✓ Training complete. Best val loss: {best_val:.4f}")
 
280
  cfg: TrainConfig,
281
  device: torch.device,
282
  trial=None,
283
+ resume_from: Optional[str] = None,
284
+ epoch_callback=None,
285
  ) -> dict:
286
  """
287
  Full training loop with logging, checkpointing, and early stopping.
 
290
  ----------
291
  trial : optuna.trial.Trial, optional
292
  If provided, reports val_loss after each epoch for ASHA pruning.
293
+ resume_from : str, optional
294
+ Path to a ``resume_state.pt`` file. If provided, training resumes
295
+ from the saved epoch with the exact optimizer / scheduler / scaler
296
+ state, history, and early-stopping counters.
297
+ epoch_callback : callable, optional
298
+ Called after every epoch as ``epoch_callback(epoch, history)``.
299
+ Useful for live plotting in notebooks.
300
 
301
  Returns
302
  -------
 
326
  history: dict = {"train_loss": [], "val_loss": [], "lr": []}
327
  best_val = float("inf")
328
  patience_ctr = 0
329
+ start_epoch = 0
330
+
331
+ # --- Resume from checkpoint ----------------------------------------
332
+ if resume_from is not None and os.path.exists(resume_from):
333
+ print(f"\n🔄 Resuming from {resume_from}")
334
+ ckpt = torch.load(resume_from, map_location=device, weights_only=False)
335
+ model.load_state_dict(ckpt["model_state_dict"])
336
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
337
+ scheduler.load_state_dict(ckpt["scheduler_state_dict"])
338
+ if scaler is not None and "scaler_state_dict" in ckpt:
339
+ scaler.load_state_dict(ckpt["scaler_state_dict"])
340
+ start_epoch = ckpt["epoch"] + 1 # resume from *next* epoch
341
+ best_val = ckpt["best_val_loss"]
342
+ patience_ctr = ckpt["patience_ctr"]
343
+ history = ckpt["history"]
344
+ print(f" Resumed at epoch {start_epoch+1}/{cfg.epochs} | "
345
+ f"best_val={best_val:.4f} | patience={patience_ctr}/{cfg.patience}")
346
 
347
  print(f"\n{'='*60}")
348
+ print(f"Starting training: {cfg.epochs} epochs (from epoch {start_epoch+1}), lr={cfg.lr}, AMP={cfg.use_amp}")
349
  print(f"{'='*60}\n")
350
 
351
+ for epoch in range(start_epoch, cfg.epochs):
352
  t0 = time.time()
353
 
354
  train_loss = train_one_epoch(
 
390
  print(f"\n⏹ Early stopping after {cfg.patience} epochs without improvement.")
391
  break
392
 
393
+ # --- Save resumable state after every epoch --------------------
394
+ resume_state = {
395
+ "epoch": epoch,
396
+ "model_state_dict": model.state_dict(),
397
+ "optimizer_state_dict": optimizer.state_dict(),
398
+ "scheduler_state_dict": scheduler.state_dict(),
399
+ "scaler_state_dict": scaler.state_dict() if scaler is not None else None,
400
+ "best_val_loss": best_val,
401
+ "patience_ctr": patience_ctr,
402
+ "history": history,
403
+ "cfg_epochs": cfg.epochs,
404
+ }
405
+ torch.save(resume_state, ckpt_dir / "resume_state.pt")
406
+
407
+ # --- Epoch callback (e.g. live plotting) ----------------------
408
+ if epoch_callback is not None:
409
+ epoch_callback(epoch, history)
410
+
411
  # Load best checkpoint
412
  model.load_state_dict(torch.load(ckpt_dir / "best_model.pt", map_location=device, weights_only=True))
413
  print(f"\n✓ Training complete. Best val loss: {best_val:.4f}")
tokenizer_shared_16k.json CHANGED
The diff for this file is too large to render. See raw diff