v2: 2M training, dropout 0.1, full-corpus tokenizer — chrF 48.93 (was 45.62)
Browse files- README.md +92 -69
- best_model.pt +1 -1
- config.json +27 -8
- src/training.py +46 -2
- 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:
|
| 33 |
name: chrF (greedy)
|
| 34 |
- type: chrf
|
| 35 |
-
value:
|
| 36 |
-
name: chrF (
|
| 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.
|
| 53 |
-
| **Parameters** | ~
|
| 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
|
|
|
|
|
|
|
| 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 ~
|
| 72 |
|
| 73 |
-
**Why dropout 0.
|
| 74 |
|
| 75 |
-
|
| 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:**
|
| 82 |
-
- **Split:**
|
| 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
|
| 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,
|
| 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 (
|
| 136 |
| Batch size | 128 |
|
| 137 |
| Max sequence length | 128 tokens |
|
| 138 |
-
| Epochs | 20 (early stopping patience=3
|
|
|
|
| 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 |
-
|
|
| 162 |
|---|---|
|
| 163 |
-
|
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
|
| 186 |
-
| 2 |
|
| 187 |
-
| 3 |
|
| 188 |
-
| 4 |
|
| 189 |
-
| 5 |
|
| 190 |
-
| 6 |
|
| 191 |
-
| 7 |
|
| 192 |
-
|
| 193 |
-
The model produces fluent, natural Malay that is often comparable or near-identical to the reference translations.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:**
|
| 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.
|
| 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=
|
| 234 |
-
print(result)
|
| 235 |
```
|
| 236 |
|
| 237 |
## Repository Structure
|
| 238 |
|
| 239 |
| File | Description |
|
| 240 |
|---|---|
|
| 241 |
-
| `best_model.pt` | Model weights (`state_dict` format
|
| 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. **
|
| 258 |
-
6. **
|
| 259 |
|
| 260 |
-
## Limitations
|
| 261 |
|
| 262 |
-
|
| 263 |
-
- **
|
| 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:
|
| 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.
|
| 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":
|
| 20 |
"val_size": 5000,
|
| 21 |
"test_size": 5000,
|
| 22 |
-
"epochs_trained":
|
| 23 |
"batch_size": 128,
|
| 24 |
"lr": 0.0005,
|
| 25 |
-
"warmup_steps":
|
| 26 |
"optimizer": "AdamW",
|
| 27 |
"scheduler": "linear warmup + cosine decay",
|
| 28 |
-
"amp": true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
},
|
| 30 |
"evaluation": {
|
| 31 |
-
"chrf_greedy":
|
| 32 |
-
"
|
|
|
|
| 33 |
"test_set": "5K in-distribution OpenSubtitles",
|
| 34 |
-
"note": "chrF with post-processing
|
| 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
|
|
|