docs: komentar inline + README argumen + bugfix & optimasi (7dd92fc)
Browse files- README.md +337 -39
- audit_kamus.py +58 -7
- eval.py +91 -12
- generate.py +79 -16
- indigo/bpe.py +117 -1
- indigo/common.py +135 -0
- indigo/model.py +185 -4
- indigo/tokenizer.py +61 -0
- pipeline.py +135 -31
- tests/test_core.py +65 -0
- train.py +182 -25
README.md
CHANGED
|
@@ -18,88 +18,386 @@ Model bahasa kecil GPT-style yang dibangun **dari nol** (tanpa library transform
|
|
| 18 |
|
| 19 |
## Arsitektur
|
| 20 |
|
| 21 |
-
| | Nilai default |
|
| 22 |
|---|---|
|
| 23 |
| Tipe | Decoder-only transformer (pre-LN, SDPA) |
|
| 24 |
-
| Parameter | ~0.81M |
|
| 25 |
| Layer / Head | 4 / 4 |
|
| 26 |
-
| Dimensi | 128 |
|
| 27 |
-
| Konteks |
|
| 28 |
-
| Tokenizer | karakter atau
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
##
|
| 31 |
|
| 32 |
```
|
| 33 |
-
indigo/
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
```
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
```bash
|
| 44 |
pip install -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
python train.py --data data/sample.txt --steps 2000
|
| 47 |
-
python train.py --data data/tekskamu.txt --tokenizer bpe --vocab-size 512
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
python train.py --init-from out/indigo_best.safetensors --steps 1000 # lanjutkan training
|
| 53 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
`hf.co/datasets/Deddy/Indonesia-dataset-2023`), lalu memilih kandidat generasi dengan
|
| 61 |
-
rasio kata dikenal tertinggi — menyaring ejaan tak-baku tanpa mengubah model:
|
| 62 |
|
| 63 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
python generate.py --prompt "kepekaan" --max-new 120 --guard data/kamus_id.txt
|
| 65 |
|
| 66 |
-
#
|
| 67 |
-
python
|
| 68 |
```
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
##
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
```bash
|
| 81 |
-
#
|
| 82 |
-
python pipeline.py --tag run01 --data data/
|
|
|
|
|
|
|
|
|
|
| 83 |
--tokenizer bpe --vocab-size 512 --steps 900 --device cpu
|
| 84 |
|
| 85 |
-
#
|
| 86 |
-
python pipeline.py --tag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
`eval.py`
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
python eval.py --ckpt out/indigo_best.safetensors runs/*/ckpt/indigo.safetensors \
|
| 96 |
-
--test data/sample.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
```
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
## Batasan
|
| 100 |
|
| 101 |
- Dilatih pada data sangat kecil → output belum koheren; cocok untuk edukasi bukan produksi.
|
| 102 |
- Gunakan checkpoint `indigo_best` (terpilih berdasarkan validasi), bukan checkpoint akhir.
|
|
|
|
| 103 |
|
| 104 |
## Keamanan
|
| 105 |
|
|
|
|
| 18 |
|
| 19 |
## Arsitektur
|
| 20 |
|
| 21 |
+
| Komponen | Nilai default |
|
| 22 |
|---|---|
|
| 23 |
| Tipe | Decoder-only transformer (pre-LN, SDPA) |
|
| 24 |
+
| Parameter | ~0.81M (default) |
|
| 25 |
| Layer / Head | 4 / 4 |
|
| 26 |
+
| Dimensi embedding | 128 |
|
| 27 |
+
| Konteks (block_size) | 128 token |
|
| 28 |
+
| Tokenizer | `char` (karakter) atau `bpe` (byte-pair encoding) |
|
| 29 |
+
| Weight tying | Ya (output head = token embedding) |
|
| 30 |
+
| Aktivasi | GELU (MLP) |
|
| 31 |
+
| Attention | Multi-head causal self-attention + KV-cache |
|
| 32 |
|
| 33 |
+
## Struktur file
|
| 34 |
|
| 35 |
```
|
| 36 |
+
indigo/
|
| 37 |
+
model.py Arsitektur GPT (CausalSelfAttention, MLP, Block, GPT, sampling)
|
| 38 |
+
common.py Utilitas bersama (clean_text, meta save/load, morphological analysis)
|
| 39 |
+
bpe.py Tokenizer BPE level-byte minimal
|
| 40 |
+
tokenizer.py Tokenizer karakter (char-level)
|
| 41 |
+
|
| 42 |
+
train.py Training loop (best-checkpoint, resume, split val, cosine LR schedule)
|
| 43 |
+
generate.py Generasi teks (top-k, top-p, repetition penalty, guard kamus)
|
| 44 |
+
pipeline.py Pipeline end-to-end (data collection → training → manifest → finalize/push)
|
| 45 |
+
eval.py Evaluasi batched antar-checkpoint (nats/token, nats/karakter)
|
| 46 |
+
audit_kamus.py Audit wordlist kamus terhadap KBBI daring
|
| 47 |
+
|
| 48 |
+
out/
|
| 49 |
+
indigo_best.safetensors Bobot terbaik (dipilih berdasarkan validasi)
|
| 50 |
+
indigo_best_meta.json Metadata terbaik
|
| 51 |
+
indigo.safetensors Bobot checkpoint akhir
|
| 52 |
+
indigo_meta.json Metadata akhir
|
| 53 |
+
indigo_optimizer.pt State optimizer (untuk resume)
|
| 54 |
+
run_info.json Info run terakhir yang di-finalize
|
| 55 |
+
|
| 56 |
+
data/
|
| 57 |
+
sample.txt Teks contoh Indonesia (training default)
|
| 58 |
+
kamus_id.txt Wordlist Indonesia (~70K kata, gitignored)
|
| 59 |
+
prefiks.txt Daftar prefiks Indonesia untuk morphological guard
|
| 60 |
+
sufixs.txt Daftar sufiks Indonesia untuk morphological guard
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Persyaratan
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
torch>=2.0
|
| 67 |
+
safetensors>=0.4
|
| 68 |
```
|
| 69 |
|
| 70 |
+
Install:
|
| 71 |
|
| 72 |
```bash
|
| 73 |
pip install -r requirements.txt
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## `train.py` — Training Model
|
| 79 |
+
|
| 80 |
+
Script utama untuk melatih model GPT dari nol.
|
| 81 |
|
| 82 |
+
### Argumen
|
| 83 |
+
|
| 84 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 85 |
+
|---|---|---|---|
|
| 86 |
+
| `--data` | str (nargs+) | `data/sample.txt` | Path file/folder teks untuk training. Bisa banyak, spasi-separated. Folder akan dicari rekursif untuk file `.txt`. |
|
| 87 |
+
| `--out` | str | `out` | Folder output checkpoint (`.safetensors` + `_meta.json` + `_optimizer.pt`). |
|
| 88 |
+
| `--steps` | int | `2000` | Jumlah total langkah training. |
|
| 89 |
+
| `--batch-size` | int | `32` | Jumlah sampel per batch. Batch lebih besar → gradient lebih stabil tapi butuh lebih banyak VRAM. |
|
| 90 |
+
| `--block-size` | int | `128` | Panjang konteks token per sampel (sequence length). Semakin besar → model bisa lihat lebih jauh tapi butuh lebih banyak memori. |
|
| 91 |
+
| `--lr` | float | `3e-4` | Learning rate maksimum. Cosine decay dari lr ini ke 10% lr selama training. |
|
| 92 |
+
| `--warmup` | int | `100` | Jumlah langkah warmup linear sebelum cosine decay dimulai. |
|
| 93 |
+
| `--weight-decay` | float | `0.1` | L2 regularization / weight decay untuk AdamW optimizer. |
|
| 94 |
+
| `--dropout` | float | `0.1` | Dropout rate (0.0 = nonaktif). Membantu mencegah overfitting pada data kecil. |
|
| 95 |
+
| `--n-layer` | int | `4` | Jumlah blok transformer bertumpuk. Lebih banyak layer → model lebih dalam tapi lebih lambat. |
|
| 96 |
+
| `--n-head` | int | `4` | Jumlah head per attention layer. Harus habis membagi `--n-embd`. |
|
| 97 |
+
| `--n-embd` | int | `128` | Dimensi embedding / hidden size. Parameter ≈ 12 × n_layer × n_embd². |
|
| 98 |
+
| `--tokenizer` | `char`/`bpe` | `char` | Jenis tokenizer. `char` = cepat, `bpe` = lebih efisien untuk teks panjang. |
|
| 99 |
+
| `--vocab-size` | int | `512` | Ukuran vocab untuk BPE (diabaikan jika `--tokenizer char`). |
|
| 100 |
+
| `--eval-interval` | int | `200` | Evaluasi validasi setiap N langkah. Set `0` untuk skip validasi. |
|
| 101 |
+
| `--eval-iters` | int | `20` | Jumlah batch untuk estimasi loss validasi. |
|
| 102 |
+
| `--seed` | int | `1337` | Seed random untuk reproduktibilitas. |
|
| 103 |
+
| `--val-fraction` | float | `0.1` | Proporsi file untuk validasi (split per-file, bukan per-karakter). |
|
| 104 |
+
| `--init-from` | str | `None` | Path checkpoint untuk melanjutkan training (resume). Muat model + optimizer + step. |
|
| 105 |
+
| `--device` | `auto`/`cpu`/`cuda` | `auto` | Device training. `auto` = CUDA jika tersedia, else CPU. |
|
| 106 |
+
|
| 107 |
+
### Contoh
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
# Training dasar
|
| 111 |
python train.py --data data/sample.txt --steps 2000
|
|
|
|
| 112 |
|
| 113 |
+
# Training dengan BPE tokenizer
|
| 114 |
+
python train.py --data data/teks.txt --tokenizer bpe --vocab-size 512
|
| 115 |
+
|
| 116 |
+
# Model lebih besar, training lebih lama
|
| 117 |
+
python train.py --data data/ --n-layer 6 --n-head 8 --n-embd 256 --steps 5000
|
| 118 |
+
|
| 119 |
+
# Resume dari checkpoint
|
| 120 |
+
python train.py --init-from out/indigo_best.safetensors --steps 1000
|
| 121 |
+
|
| 122 |
+
# Tanpa validasi (data hanya 1 file)
|
| 123 |
+
python train.py --data data/combined.txt --val-fraction 0 --eval-interval 0
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### Output
|
| 127 |
+
|
| 128 |
+
- `out/indigo_best.safetensors` + `out/indigo_best_meta.json` — checkpoint terbaik (val loss minimum)
|
| 129 |
+
- `out/indigo.safetensors` + `out/indigo_meta.json` — checkpoint akhir
|
| 130 |
+
- `out/indigo_optimizer.pt` — state optimizer (untuk resume)
|
| 131 |
+
|
| 132 |
+
### Learning Rate Schedule
|
| 133 |
|
|
|
|
| 134 |
```
|
| 135 |
+
Step 0 → warmup: linear dari 0 → lr_max
|
| 136 |
+
Step warmup → total: cosine decay dari lr_max → 0.1 × lr_max
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Formula cosine: `0.1 × lr + 0.45 × lr × (1 + cos(π × progress))`
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## `generate.py` — Generasi Teks
|
| 144 |
+
|
| 145 |
+
Generate teks dari checkpoint Indigo dengan berbagai opsi sampling.
|
| 146 |
|
| 147 |
+
### Argumen
|
| 148 |
|
| 149 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 150 |
+
|---|---|---|---|
|
| 151 |
+
| `--ckpt` | str | `out/indigo_best.safetensors` | Path ke file checkpoint model. |
|
| 152 |
+
| `--prompt` | str | `""` | Teks awal (prompt) untuk memulai generasi. |
|
| 153 |
+
| `--max-new` | int | `300` | Jumlah token baru yang akan dihasilkan (bukan panjang total). |
|
| 154 |
+
| `--temperature` | float | `0.8` | Skala randomness: `0.0` ≈ greedy, `0.8` ≈ standar, `>1.0` ≈ random. |
|
| 155 |
+
| `--top-k` | int | `40` | Batasi sampling ke k token teratas. `0` = nonaktif. |
|
| 156 |
+
| `--top-p` | float | `1.0` | Nucleus sampling: batasi kumulatif probabilitas. `1.0` = nonaktif. |
|
| 157 |
+
| `--repetition-penalty` | float | `1.0` | Penalti pengulangan token. `1.0` = nonaktif, `>1.0` = kurangi pengulangan. |
|
| 158 |
+
| `--seed` | int | `None` | Seed random. `None` = tidak ditentukan (random setiap kali). |
|
| 159 |
+
| `--device` | `auto`/`cpu`/`cuda` | `auto` | Device untuk inferensi. |
|
| 160 |
+
| `--guard` | str | `None` | Path file kamus (satu kata per baris). Generate beberapa kandidat → pilih yang rasio kata dikenal tertinggi. |
|
| 161 |
+
| `--guard-prefiks` | str | `None` | Path file prefiks Indonesia. Default: `data/prefiks.txt` bila ada. |
|
| 162 |
+
| `--guard-sufiks` | str | `None` | Path file sufiks Indonesia. Default: `data/sufiks.txt` bila ada. |
|
| 163 |
+
| `--guard-tries` | int | `5` | Jumlah kandidat generate saat `--guard` aktif. |
|
| 164 |
+
| `--guard-min` | float | `0.6` | Rasio kata dikenal minimum. Berhenti generate lebih awal jika tercapai. |
|
| 165 |
|
| 166 |
+
### Contoh
|
|
|
|
|
|
|
| 167 |
|
| 168 |
```bash
|
| 169 |
+
# Generasi dasar
|
| 170 |
+
python generate.py --prompt "Indigo" --max-new 300
|
| 171 |
+
|
| 172 |
+
# Sampling lebih random
|
| 173 |
+
python generate.py --prompt "hello" --temperature 1.0 --top-k 60 --top-p 0.9
|
| 174 |
+
|
| 175 |
+
# Dengan guard kamus (pilih output paling koheren)
|
| 176 |
python generate.py --prompt "kepekaan" --max-new 120 --guard data/kamus_id.txt
|
| 177 |
|
| 178 |
+
# Repetition penalty untuk mengurangi pengulangan
|
| 179 |
+
python generate.py --prompt "cerita" --repetition-penalty 1.2 --max-new 200
|
| 180 |
```
|
| 181 |
|
| 182 |
+
### Guard Kamus
|
| 183 |
+
|
| 184 |
+
Saat `--guard` aktif, generate.py akan:
|
| 185 |
+
1. Generate `--guard-tries` kandidat teks (seed berbeda tiap kandidat)
|
| 186 |
+
2. Untuk setiap kandidat, hitung rasio kata yang dikenal kamus
|
| 187 |
+
3. Pilih kandidat dengan rasio tertinggi
|
| 188 |
+
4. Berhenti lebih awal jika rasio >= `--guard-min`
|
| 189 |
+
|
| 190 |
+
Kata berimbuhan dicek lewat formula morfologi:
|
| 191 |
+
- Hapus sufiks → cek akar
|
| 192 |
+
- Hapus prefiks → cek akar (+ asimilasi: `meny-`→`s`, `pem-`→`p`)
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
|
| 196 |
+
## `pipeline.py` — Pipeline End-to-End
|
| 197 |
|
| 198 |
+
Rangkai semua tahap: kumpul data → training → evaluasi → manifest → finalize → push.
|
| 199 |
+
|
| 200 |
+
### Argumen
|
| 201 |
+
|
| 202 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 203 |
+
|---|---|---|---|
|
| 204 |
+
| `--tag` | str | **wajib** | Nama run. Semua artefak disimpan di `runs/<tag>/`. |
|
| 205 |
+
| `--data` | str (nargs*) | `[]` | File/folder teks lokal tambahan (banyak, spasi-separated). |
|
| 206 |
+
| `--hf-dataset` | str | `None` | Repo dataset HF untuk menarik file teks (mis. `adyoi/indigo`). |
|
| 207 |
+
| `--hf-patterns` | str (nargs*) | `["*.txt", "*.md"]` | Pola file yang diambil dari HF. |
|
| 208 |
+
| `--format-qa` | flag | `False` | Auto-convert Alpaca JSON (`instruction`/`output`) ke `.txt` sebelum training. |
|
| 209 |
+
| `--runs` | str | `runs` | Folder root untuk semua run. |
|
| 210 |
+
| `--device` | str | `None` | Device training (diteruskan ke `train.py`, default: auto). |
|
| 211 |
+
| `--finalize` | flag | `False` | Promosikan checkpoint terbaik ke folder `out/` kanonik. |
|
| 212 |
+
| `--push` | flag | `False` | Upload checkpoint terbaik ke repo HF. |
|
| 213 |
+
| `--repo` | str | `adyoi/indigo` | Repo HF tujuan upload. |
|
| 214 |
+
| `--guard` | str | `None` | File kamus untuk metrik rasio ejaan di manifest. |
|
| 215 |
+
| `--guard-max-new` | int | `120` | Jumlah token generate untuk evaluasi guard. |
|
| 216 |
+
| `--guard-prefiks` | str | `None` | File prefiks Indonesia. |
|
| 217 |
+
| `--guard-sufiks` | str | `None` | File sufiks Indonesia. |
|
| 218 |
+
|
| 219 |
+
**Hyperparameter training** (diteruskan ke `train.py`):
|
| 220 |
+
|
| 221 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 222 |
+
|---|---|---|---|
|
| 223 |
+
| `--steps` | int | `2000` | Jumlah langkah training. |
|
| 224 |
+
| `--batch-size` | int | `32` | Batch size. |
|
| 225 |
+
| `--block-size` | int | `128` | Panjang konteks. |
|
| 226 |
+
| `--n-layer` | int | `4` | Jumlah layer transformer. |
|
| 227 |
+
| `--n-head` | int | `4` | Jumlah head attention. |
|
| 228 |
+
| `--n-embd` | int | `128` | Dimensi embedding. |
|
| 229 |
+
| `--dropout` | float | `0.1` | Dropout rate. |
|
| 230 |
+
| `--lr` | float | `3e-4` | Learning rate. |
|
| 231 |
+
| `--warmup` | int | `100` | Langkah warmup. |
|
| 232 |
+
| `--weight-decay` | float | `0.1` | Weight decay. |
|
| 233 |
+
| `--eval-interval` | int | `200` | Evaluasi setiap N langkah. |
|
| 234 |
+
| `--eval-iters` | int | `20` | Jumlah batch evaluasi. |
|
| 235 |
+
| `--seed` | int | `1337` | Seed random. |
|
| 236 |
+
| `--init-from` | str | `None` | Checkpoint untuk resume. |
|
| 237 |
+
| `--tokenizer` | `char`/`bpe` | `char` | Jenis tokenizer. |
|
| 238 |
+
| `--vocab-size` | int | `512` | Vocab size untuk BPE. |
|
| 239 |
+
| `--val-fraction` | float | `0.1` | Proporsi file validasi. |
|
| 240 |
+
|
| 241 |
+
### Contoh
|
| 242 |
|
| 243 |
```bash
|
| 244 |
+
# Data lokal saja
|
| 245 |
+
python pipeline.py --tag run01 --data data/sample.txt --steps 2000
|
| 246 |
+
|
| 247 |
+
# Data dari HF + BPE tokenizer
|
| 248 |
+
python pipeline.py --tag run02 --data data/teks.txt --hf-dataset adyoi/indigo \
|
| 249 |
--tokenizer bpe --vocab-size 512 --steps 900 --device cpu
|
| 250 |
|
| 251 |
+
# Dataset Alpaca JSON dari HF
|
| 252 |
+
python pipeline.py --tag qa01 \
|
| 253 |
+
--hf-dataset rohanrdy/CS-Theory-QA-Dataset \
|
| 254 |
+
--format-qa --steps 3000 --n-layer 6 --n-embd 256
|
| 255 |
+
|
| 256 |
+
# Full pipeline: train + finalize + push
|
| 257 |
+
python pipeline.py --tag final01 --data data/ --steps 2000 --finalize --push
|
| 258 |
+
|
| 259 |
+
# Dengan guard kamus untuk metrik kualitas
|
| 260 |
+
python pipeline.py --tag guarded --data data/ --steps 2000 --guard data/kamus_id.txt
|
| 261 |
```
|
| 262 |
|
| 263 |
+
### Output
|
| 264 |
+
|
| 265 |
+
- `runs/<tag>/data/` — data yang digunakan (copy dari sumber)
|
| 266 |
+
- `runs/<tag>/ckpt/` — checkpoint training
|
| 267 |
+
- `runs/<tag>/manifest.json` — statistik + metadata run (termasuk `kamus_ratio` jika `--guard` aktif)
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
|
| 271 |
+
## `eval.py` — Evaluasi Batched
|
| 272 |
+
|
| 273 |
+
Bandingkan beberapa checkpoint pada set uji tetap. Metrik:
|
| 274 |
+
|
| 275 |
+
- **nats/token**: loss rata-rata per token (cross-entropy). Lebih rendah = lebih baik.
|
| 276 |
+
- **nats/karakter**: loss per karakter (dikoreksi dengan compression ratio). Memungkinkan perbandingan antar tokenizer.
|
| 277 |
+
|
| 278 |
+
### Argumen
|
| 279 |
+
|
| 280 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 281 |
+
|---|---|---|---|
|
| 282 |
+
| `--ckpt` | str (nargs+) | **wajib** | Path ke satu atau lebih file checkpoint (`.safetensors`). |
|
| 283 |
+
| `--test` | str | `data/sample.txt` | Path ke file teks uji. |
|
| 284 |
+
| `--device` | `cpu`/`cuda` | `cpu` | Device untuk evaluasi. |
|
| 285 |
+
| `--guard` | str | `None` | Path file kamus; generate teks → hitung rasio kata dikenal. |
|
| 286 |
+
| `--guard-max-new` | int | `120` | Jumlah token generate untuk evaluasi guard. |
|
| 287 |
+
| `--seed` | int | `42` | Seed untuk generate saat `--guard` aktif. |
|
| 288 |
+
| `--batch-size` | int | `32` | Batch size untuk evaluasi. |
|
| 289 |
+
|
| 290 |
+
### Contoh
|
| 291 |
|
| 292 |
```bash
|
| 293 |
+
# Evaluasi satu checkpoint
|
| 294 |
+
python eval.py --ckpt out/indigo_best.safetensors
|
| 295 |
+
|
| 296 |
+
# Bandingkan beberapa checkpoint
|
| 297 |
python eval.py --ckpt out/indigo_best.safetensors runs/*/ckpt/indigo.safetensors \
|
| 298 |
+
--test data/sample.txt
|
| 299 |
+
|
| 300 |
+
# Dengan guard kamus
|
| 301 |
+
python eval.py --ckpt out/indigo_best.safetensors --guard data/kampus_id.txt
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
### Contoh output
|
| 305 |
+
|
| 306 |
+
```
|
| 307 |
+
set uji: data/sample.txt (29,123 karakter)
|
| 308 |
+
checkpoint nats/tok nat/kar kamus
|
| 309 |
+
run/csqa500/ckpt/indigo_best.safetensors 3.962 3.962 32%
|
| 310 |
+
run/wiki500/ckpt/indigo_best.safetensors 3.018 3.018 25%
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## `audit_kamus.py` — Audit Wordlist Kamus
|
| 316 |
+
|
| 317 |
+
Verifikasi kata-kata dalam file kamus terhadap KBBI daring (sampling acak).
|
| 318 |
+
|
| 319 |
+
### Argumen
|
| 320 |
+
|
| 321 |
+
| Argumen | Tipe | Default | Deskripsi |
|
| 322 |
+
|---|---|---|---|
|
| 323 |
+
| `--kamus` | str | `data/kamus_id.txt` | Path ke file kamus (satu kata per baris). |
|
| 324 |
+
| `--n` | int | `200` | Jumlah kata sampel yang diambil untuk verifikasi. |
|
| 325 |
+
| `--seed` | int | `1337` | Seed random untuk sampling. |
|
| 326 |
+
| `--delay` | float | `0.8` | Jeda antar-permintaan web (detik). Hindari rate limiting. |
|
| 327 |
+
| `--backend` | `auto`/`pypi`/`web` | `auto` | Backend verifikasi. `pypi` = library kbbi, `web` = scraping kbbi.web.id. |
|
| 328 |
+
| `--apply` | flag | `False` | Hapus kata TIDAK-DIKENAL dari kamus (backup otomatis ke `.bak.txt`). |
|
| 329 |
+
| `--out` | str | `None` | Path output laporan JSON. Default: `runs/kamus_audit_<timestamp>.json`. |
|
| 330 |
+
|
| 331 |
+
### Contoh
|
| 332 |
+
|
| 333 |
+
```bash
|
| 334 |
+
# Audit dasar
|
| 335 |
+
python audit_kamus.py --kamus data/kamus_id.txt --n 200
|
| 336 |
+
|
| 337 |
+
# Hapus kata tak-valid (backup otomatis)
|
| 338 |
+
python audit_kamus.py --kamus data/kamus_id.txt --apply
|
| 339 |
+
|
| 340 |
+
# Backend web saja, delay lebih lama
|
| 341 |
+
python audit_kamus.py --backend web --delay 1.5 --n 100
|
| 342 |
```
|
| 343 |
|
| 344 |
+
### Output
|
| 345 |
+
|
| 346 |
+
Laporan JSON berisi:
|
| 347 |
+
- `ada`: jumlah kata yang ditemukan di KBBI
|
| 348 |
+
- `tidak_dikenal`: jumlah kata yang tidak ditemukan
|
| 349 |
+
- `gagal_ceks`: jumlah verifikasi yang gagal (timeout, dll)
|
| 350 |
+
- `estimasi_entri_tidak_valid`: proyeksi total entri tak-valid di seluruh kamus
|
| 351 |
+
- `kata_ditolak`: daftar kata yang tidak dikenal (akan dihapus jika `--apply`)
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## Guard Kamus (Sistem Kualitas Ejaan)
|
| 356 |
+
|
| 357 |
+
Sistem guard membantu menjaga kualitas output model tanpa mengubah bobot:
|
| 358 |
+
|
| 359 |
+
1. **Wordlist**: `data/kamus_id.txt` — ~70K kata Indonesia (satu kata per baris)
|
| 360 |
+
2. **Morfologi**: `data/prefiks.txt` + `data/sufiks.txt` — pendeteksi imbuhan
|
| 361 |
+
3. **Asimilasi**: `meny-`→`s`, `peny-`→`s`, `pem-`→`p` — menangani perubahan bunyi
|
| 362 |
+
|
| 363 |
+
Cara kerja:
|
| 364 |
+
- **generate.py**: generate beberapa kandidat → pilih yang rasio kata dikenal tertinggi
|
| 365 |
+
- **pipeline.py**: generate sekali → simpan `kamus_ratio` di manifest.json
|
| 366 |
+
- **eval.py**: generate sekali → tampilkan rasio di tabel perbandingan
|
| 367 |
+
|
| 368 |
+
Edit `data/prefiks.txt` dan `data/sufiks.txt` untuk memperluas cakupan tanpa menyentuh kode.
|
| 369 |
+
|
| 370 |
+
---
|
| 371 |
+
|
| 372 |
+
## Format Checkpoint
|
| 373 |
+
|
| 374 |
+
| File | Isi |
|
| 375 |
+
|---|---|
|
| 376 |
+
| `*.safetensors` | Bobot model (format aman, tanpa pickle) |
|
| 377 |
+
| `*_meta.json` | Metadata: config, vocab, step, val_loss, backend, tokenizer info |
|
| 378 |
+
| `*_optimizer.pt` | State optimizer AdamW (untuk resume training) |
|
| 379 |
+
|
| 380 |
+
Format `.safetensors` tidak mengeksekusi kode saat dimuat — lebih aman dari format `.pt` lama.
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
## Tips Training
|
| 385 |
+
|
| 386 |
+
| Skenario | Rekomendasi |
|
| 387 |
+
|---|---|
|
| 388 |
+
| Data < 100 KB | `--tokenizer char`, 4L/4H/128E, 2000 steps |
|
| 389 |
+
| Data 100 KB–1 MB | `--tokenizer char` atau `bpe` (vocab 512), 4L/8H/256E, 3000+ steps |
|
| 390 |
+
| Data > 1 MB | `--tokenizer char` (BPE training sangat lambat di CPU >1MB) |
|
| 391 |
+
| CPU-only | Model max ~1M params (4L/4H/128E), 5-8 detik/step |
|
| 392 |
+
| Resume training | `--init-from runs/xxx/ckpt/indigo_best.safetensors` |
|
| 393 |
+
|
| 394 |
+
---
|
| 395 |
+
|
| 396 |
## Batasan
|
| 397 |
|
| 398 |
- Dilatih pada data sangat kecil → output belum koheren; cocok untuk edukasi bukan produksi.
|
| 399 |
- Gunakan checkpoint `indigo_best` (terpilih berdasarkan validasi), bukan checkpoint akhir.
|
| 400 |
+
- BPE training sangat lambat di CPU untuk data > 1 MB — gunakan `--tokenizer char`.
|
| 401 |
|
| 402 |
## Keamanan
|
| 403 |
|
audit_kamus.py
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import argparse
|
| 2 |
import json
|
| 3 |
import os
|
|
@@ -11,6 +30,14 @@ UA = {"User-Agent": "Mozilla/5.0 (audit-kamus-indigo)"}
|
|
| 11 |
|
| 12 |
|
| 13 |
def cek_pypi(word):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
try:
|
| 15 |
from kbbi import KBBI, TidakDitemukan
|
| 16 |
except ImportError:
|
|
@@ -28,6 +55,21 @@ _SESI = None
|
|
| 28 |
|
| 29 |
|
| 30 |
def cek_web(word):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
global _SESI
|
| 32 |
import requests
|
| 33 |
|
|
@@ -53,23 +95,30 @@ def main():
|
|
| 53 |
ap = argparse.ArgumentParser(
|
| 54 |
description="Audit wordlist kamus terhadap KBBI daring (sampling acak)"
|
| 55 |
)
|
| 56 |
-
ap.add_argument("--kamus", default=str(ROOT / "data" / "kamus_id.txt")
|
| 57 |
-
|
| 58 |
-
ap.add_argument("--
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
ap.add_argument("--backend", choices=["auto", "pypi", "web"], default="auto",
|
| 61 |
-
help="
|
| 62 |
ap.add_argument("--apply", action="store_true",
|
| 63 |
-
help="hapus kata TIDAK-DIKENAL dari kamus (backup otomatis)")
|
| 64 |
-
ap.add_argument("--out", default=None,
|
|
|
|
| 65 |
args = ap.parse_args()
|
| 66 |
|
|
|
|
| 67 |
kamus_path = Path(args.kamus)
|
| 68 |
kata_semua = [w.strip() for w in kamus_path.read_text(encoding="utf-8").splitlines() if w.strip()]
|
| 69 |
rng = random.Random(args.seed)
|
| 70 |
sampel = sorted(rng.sample(kata_semua, min(args.n, len(kata_semua))))
|
| 71 |
print(f"kamus={kamus_path.name} ({len(kata_semua):,} kata) | sampel={len(sampel)} | seed={args.seed}")
|
| 72 |
|
|
|
|
| 73 |
hasil = {}
|
| 74 |
n_ada = n_tidak = n_gagal = 0
|
| 75 |
t0 = time.time()
|
|
@@ -90,6 +139,7 @@ def main():
|
|
| 90 |
print(f" {i}/{len(sampel)} | ada={n_ada} tidak={n_tidak} gagal={n_gagal} | {time.time() - t0:.0f}s")
|
| 91 |
time.sleep(args.delay)
|
| 92 |
|
|
|
|
| 93 |
ditolak = sorted(w for w, s in hasil.items() if s is False)
|
| 94 |
laporan = {
|
| 95 |
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
@@ -110,6 +160,7 @@ def main():
|
|
| 110 |
print(f"ringkasan: ada={n_ada} | tidak-dikenal={n_tidak} | gagal={n_gagal} "
|
| 111 |
f"| estimasi entri tak-valid di kamus: ~{laporan['estimasi_entri_tidak_valid']:,}")
|
| 112 |
|
|
|
|
| 113 |
if args.apply and ditolak:
|
| 114 |
backup = kamus_path.with_suffix(".bak.txt")
|
| 115 |
backup.write_text("\n".join(sorted(kata_semua)) + "\n", encoding="utf-8")
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script audit wordlist kamus terhadap KBBI daring.
|
| 3 |
+
|
| 4 |
+
Mengambil sampel random dari file kamus (satu kata per baris), lalu memverifikasi
|
| 5 |
+
setiap kata menggunakan library kbbi (pypi) atau scraping kbbi.web.id.
|
| 6 |
+
|
| 7 |
+
Fitur:
|
| 8 |
+
- Verifikasi via pypi package `kbbi` (lebih stabil)
|
| 9 |
+
- Fallback ke web scraping kbbi.web.id (heuristik, rapuh)
|
| 10 |
+
- Estimasi jumlah entri tak-valid di seluruh kamus
|
| 11 |
+
- Mode --apply: hapus kata tak-valid + backup otomatis
|
| 12 |
+
- Laporan JSON lengkap (timestamp, statistik, daftar kata ditolak)
|
| 13 |
+
|
| 14 |
+
Cara pakai:
|
| 15 |
+
python audit_kamus.py --kamus data/kamus_id.txt --n 200
|
| 16 |
+
python audit_kamus.py --kamus data/kamus_id.txt --apply
|
| 17 |
+
python audit_kamus.py --backend web --delay 1.0
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
import argparse
|
| 21 |
import json
|
| 22 |
import os
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def cek_pypi(word):
|
| 33 |
+
"""Verifikasi kata menggunakan library kbbi dari pypi.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
word: Kata yang akan diverifikasi.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
True jika kata ditemukan di KBBI, False jika tidak, None jika error.
|
| 40 |
+
"""
|
| 41 |
try:
|
| 42 |
from kbbi import KBBI, TidakDitemukan
|
| 43 |
except ImportError:
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def cek_web(word):
|
| 58 |
+
"""Verifikasi kata dengan scraping kbbi.web.id (fallback).
|
| 59 |
+
|
| 60 |
+
Metode heuristik: hitung jumlah marker "Maaf, tidak ditemukan" di halaman.
|
| 61 |
+
- 0 marker → kata ditemukan
|
| 62 |
+
- < 3 marker → kemungkinan ada (ambigu)
|
| 63 |
+
- >= 3 marker → tidak ditemukan
|
| 64 |
+
|
| 65 |
+
Peringatan: metode ini rapuh (bergantung pada struktur HTML kbbi.web.id).
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
word: Kata yang akan diverifikasi.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
True/False/None — sama seperti cek_pypi.
|
| 72 |
+
"""
|
| 73 |
global _SESI
|
| 74 |
import requests
|
| 75 |
|
|
|
|
| 95 |
ap = argparse.ArgumentParser(
|
| 96 |
description="Audit wordlist kamus terhadap KBBI daring (sampling acak)"
|
| 97 |
)
|
| 98 |
+
ap.add_argument("--kamus", default=str(ROOT / "data" / "kamus_id.txt"),
|
| 99 |
+
help="path ke file kamus (satu kata per baris, default: data/kamus_id.txt)")
|
| 100 |
+
ap.add_argument("--n", type=int, default=200,
|
| 101 |
+
help="jumlah kata sampel yang diambil (default: 200)")
|
| 102 |
+
ap.add_argument("--seed", type=int, default=1337,
|
| 103 |
+
help="seed random untuk sampling (default: 1337)")
|
| 104 |
+
ap.add_argument("--delay", type=float, default=0.8,
|
| 105 |
+
help="jeda antar-permintaan web dalam detik (default: 0.8)")
|
| 106 |
ap.add_argument("--backend", choices=["auto", "pypi", "web"], default="auto",
|
| 107 |
+
help="backend verifikasi: auto/pypi/web (default: auto)")
|
| 108 |
ap.add_argument("--apply", action="store_true",
|
| 109 |
+
help="hapus kata TIDAK-DIKENAL dari kamus (backup otomatis ke .bak.txt)")
|
| 110 |
+
ap.add_argument("--out", default=None,
|
| 111 |
+
help="path output laporan JSON (default: runs/kamus_audit_<timestamp>.json)")
|
| 112 |
args = ap.parse_args()
|
| 113 |
|
| 114 |
+
# --- Muat kamus ---
|
| 115 |
kamus_path = Path(args.kamus)
|
| 116 |
kata_semua = [w.strip() for w in kamus_path.read_text(encoding="utf-8").splitlines() if w.strip()]
|
| 117 |
rng = random.Random(args.seed)
|
| 118 |
sampel = sorted(rng.sample(kata_semua, min(args.n, len(kata_semua))))
|
| 119 |
print(f"kamus={kamus_path.name} ({len(kata_semua):,} kata) | sampel={len(sampel)} | seed={args.seed}")
|
| 120 |
|
| 121 |
+
# --- Verifikasi setiap kata sampel ---
|
| 122 |
hasil = {}
|
| 123 |
n_ada = n_tidak = n_gagal = 0
|
| 124 |
t0 = time.time()
|
|
|
|
| 139 |
print(f" {i}/{len(sampel)} | ada={n_ada} tidak={n_tidak} gagal={n_gagal} | {time.time() - t0:.0f}s")
|
| 140 |
time.sleep(args.delay)
|
| 141 |
|
| 142 |
+
# --- Buat laporan ---
|
| 143 |
ditolak = sorted(w for w, s in hasil.items() if s is False)
|
| 144 |
laporan = {
|
| 145 |
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
|
|
| 160 |
print(f"ringkasan: ada={n_ada} | tidak-dikenal={n_tidak} | gagal={n_gagal} "
|
| 161 |
f"| estimasi entri tak-valid di kamus: ~{laporan['estimasi_entri_tidak_valid']:,}")
|
| 162 |
|
| 163 |
+
# --- Hapus kata tak-valid (jika --apply) ---
|
| 164 |
if args.apply and ditolak:
|
| 165 |
backup = kamus_path.with_suffix(".bak.txt")
|
| 166 |
backup.write_text("\n".join(sorted(kata_semua)) + "\n", encoding="utf-8")
|
eval.py
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import argparse
|
| 2 |
from pathlib import Path
|
| 3 |
|
|
@@ -14,6 +35,14 @@ from indigo.model import GPT, GPTConfig
|
|
| 14 |
|
| 15 |
|
| 16 |
def muat(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from safetensors.torch import load_file
|
| 18 |
|
| 19 |
meta = load_meta(path)
|
|
@@ -28,30 +57,58 @@ def muat(path):
|
|
| 28 |
|
| 29 |
@torch.no_grad()
|
| 30 |
def nats_per_token(model, ids, block_size, device, batch_size=32):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
jendela = []
|
| 32 |
for i in range(0, max(0, len(ids) - 1), block_size):
|
| 33 |
-
potongan = ids[i : i + block_size + 1]
|
| 34 |
if len(potongan) >= 2:
|
| 35 |
jendela.append(potongan)
|
|
|
|
|
|
|
| 36 |
total_nll = 0.0
|
| 37 |
total_tok = 0
|
| 38 |
for k in range(0, len(jendela), batch_size):
|
| 39 |
kelompok = jendela[k : k + batch_size]
|
| 40 |
-
L = max(len(w) - 1 for w in kelompok)
|
|
|
|
|
|
|
| 41 |
x = torch.zeros(len(kelompok), L, dtype=torch.long)
|
| 42 |
y = torch.zeros(len(kelompok), L, dtype=torch.long)
|
| 43 |
mask = torch.zeros(len(kelompok), L, dtype=torch.bool)
|
| 44 |
for r, w in enumerate(kelompok):
|
| 45 |
n = len(w) - 1
|
| 46 |
-
x[r, :n] = torch.tensor(w[:-1], dtype=torch.long)
|
| 47 |
-
y[r, :n] = torch.tensor(w[1:], dtype=torch.long)
|
| 48 |
-
mask[r, :n] = True
|
|
|
|
| 49 |
x, y, mask = x.to(device), y.to(device), mask.to(device)
|
|
|
|
|
|
|
| 50 |
logits, _ = model(x)
|
| 51 |
logp = torch.log_softmax(logits.float(), dim=-1)
|
| 52 |
nll = -logp.gather(2, y.unsqueeze(2)).squeeze(2)
|
|
|
|
|
|
|
| 53 |
total_nll += float(nll[mask].sum())
|
| 54 |
total_tok += int(mask.sum())
|
|
|
|
| 55 |
return total_nll / max(1, total_tok), total_tok
|
| 56 |
|
| 57 |
|
|
@@ -59,16 +116,27 @@ def main():
|
|
| 59 |
ap = argparse.ArgumentParser(
|
| 60 |
description="Skor checkpoint pada set uji tetap agar antar-run dapat dibandingkan"
|
| 61 |
)
|
| 62 |
-
ap.add_argument("--ckpt", nargs="+", required=True
|
| 63 |
-
|
| 64 |
-
ap.add_argument("--
|
| 65 |
-
|
| 66 |
-
ap.add_argument("--
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
args = ap.parse_args()
|
| 69 |
|
|
|
|
| 70 |
teks = read_clean(args.test)
|
| 71 |
n_karakter = len(teks.encode("utf-8"))
|
|
|
|
|
|
|
| 72 |
wordset = prefiks = sufiks = None
|
| 73 |
if args.guard:
|
| 74 |
root = Path(__file__).resolve().parent
|
|
@@ -77,28 +145,39 @@ def main():
|
|
| 77 |
prefiks = load_wordlist(str(p)) if p.exists() else None
|
| 78 |
sufiks = load_wordlist(str(s)) if s.exists() else None
|
| 79 |
|
|
|
|
| 80 |
print(f"set uji: {args.test} ({n_karakter:,} karakter)")
|
| 81 |
print(f"{'checkpoint':44s} {'nats/tok':>9s} {'nat/kar':>8s} {'kamus':>7s}")
|
| 82 |
baris = []
|
|
|
|
|
|
|
| 83 |
for path in args.ckpt:
|
| 84 |
model, tokenizer, meta = muat(path)
|
|
|
|
|
|
|
| 85 |
ids = tokenizer.encode(teks)
|
| 86 |
npt, n_tok = nats_per_token(
|
| 87 |
model, ids, meta["config"]["block_size"], args.device, args.batch_size
|
| 88 |
)
|
|
|
|
|
|
|
| 89 |
kompresi = n_karakter / max(1, n_tok)
|
| 90 |
npc = npt / kompresi
|
|
|
|
|
|
|
| 91 |
rasio = ""
|
| 92 |
if wordset:
|
| 93 |
torch.manual_seed(args.seed)
|
| 94 |
out = model.generate(
|
| 95 |
torch.tensor([[0]], dtype=torch.long, device=args.device),
|
| 96 |
-
|
| 97 |
temperature=0.8,
|
| 98 |
top_k=40,
|
| 99 |
)
|
| 100 |
teks_out = tokenizer.decode(out[0].tolist())
|
| 101 |
rasio = f"{word_known_ratio(teks_out, wordset, prefiks, sufiks):6.0%}"
|
|
|
|
|
|
|
| 102 |
bagian = str(Path(path)).replace("\\", "/").split("/")
|
| 103 |
nama = "/".join(bagian[-3:-1] + [bagian[-1]]) if len(bagian) >= 3 else bagian[-1]
|
| 104 |
print(f"{nama:44s} {npt:9.3f} {npc:8.3f} {rasio:>7s}")
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script evaluasi batched untuk membandingkan checkpoint Indigo.
|
| 3 |
+
|
| 4 |
+
Menghitung metrik per-token (nats/token) dan per-karakter (nats/karakter) pada
|
| 5 |
+
set uji tetap, sehingga run dengan tokenizer berbeda (char vs BPE) tetap sebanding.
|
| 6 |
+
|
| 7 |
+
Alur kerja:
|
| 8 |
+
1. Muat teks uji (data/sample.txt atau custom)
|
| 9 |
+
2. Untuk setiap checkpoint:
|
| 10 |
+
a. Muat model + tokenizer
|
| 11 |
+
b. Encode teks uji → pecah menjadi jendela (block_size)
|
| 12 |
+
c. Hitung cross-entropy loss per-token secara batched
|
| 13 |
+
d. Konversi ke nats/karakter menggunakan compression ratio
|
| 14 |
+
3. (Opsional) Generate teks → hitung rasio kata dikenal (kamus guard)
|
| 15 |
+
|
| 16 |
+
Cara pakai:
|
| 17 |
+
python eval.py --ckpt out/indigo_best.safetensors
|
| 18 |
+
python eval.py --ckpt runs/*/ckpt/indigo.safetensors --test data/sample.txt
|
| 19 |
+
python eval.py --ckpt out/indigo_best.safetensors --guard data/kamus_id.txt
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
import argparse
|
| 23 |
from pathlib import Path
|
| 24 |
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def muat(path):
|
| 38 |
+
"""Muat model GPT + tokenizer dari checkpoint .safetensors.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
path: Path ke file .safetensors.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Tuple (model, tokenizer, meta).
|
| 45 |
+
"""
|
| 46 |
from safetensors.torch import load_file
|
| 47 |
|
| 48 |
meta = load_meta(path)
|
|
|
|
| 57 |
|
| 58 |
@torch.no_grad()
|
| 59 |
def nats_per_token(model, ids, block_size, device, batch_size=32):
|
| 60 |
+
"""Hitung loss rata-rata (nats per token) pada seluruh sequence.
|
| 61 |
+
|
| 62 |
+
Algoritma:
|
| 63 |
+
1. Pecah sequence panjang menjadi jendela-jendela sepanjang block_size
|
| 64 |
+
2. Pad jendela ke panjang yang sama dalam batch (zero-padding + mask)
|
| 65 |
+
3. Forward pass batched → hitung cross-entropy per token → rata-rata
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
model: Model GPT.
|
| 69 |
+
ids: List of int — token IDs dari teks uji.
|
| 70 |
+
block_size: Int — panjang konteks model.
|
| 71 |
+
device: Str — "cpu" atau "cuda".
|
| 72 |
+
batch_size: Int — jumlah jendela per batch (default: 32).
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Tuple (nats_per_token, total_tokens).
|
| 76 |
+
"""
|
| 77 |
+
# Pecah sequence menjadi jendela-jendela block_size
|
| 78 |
jendela = []
|
| 79 |
for i in range(0, max(0, len(ids) - 1), block_size):
|
| 80 |
+
potongan = ids[i : i + block_size + 1] # +1 untuk target
|
| 81 |
if len(potongan) >= 2:
|
| 82 |
jendela.append(potongan)
|
| 83 |
+
|
| 84 |
+
# Proses batched
|
| 85 |
total_nll = 0.0
|
| 86 |
total_tok = 0
|
| 87 |
for k in range(0, len(jendela), batch_size):
|
| 88 |
kelompok = jendela[k : k + batch_size]
|
| 89 |
+
L = max(len(w) - 1 for w in kelompok) # panjang terpanjang dalam batch
|
| 90 |
+
|
| 91 |
+
# Buat tensor x (input), y (target), dan mask (ignore padding)
|
| 92 |
x = torch.zeros(len(kelompok), L, dtype=torch.long)
|
| 93 |
y = torch.zeros(len(kelompok), L, dtype=torch.long)
|
| 94 |
mask = torch.zeros(len(kelompok), L, dtype=torch.bool)
|
| 95 |
for r, w in enumerate(kelompok):
|
| 96 |
n = len(w) - 1
|
| 97 |
+
x[r, :n] = torch.tensor(w[:-1], dtype=torch.long) # input: semua kecuali terakhir
|
| 98 |
+
y[r, :n] = torch.tensor(w[1:], dtype=torch.long) # target: semua kecuali pertama
|
| 99 |
+
mask[r, :n] = True # hanya hitung posisi yang ada isinya
|
| 100 |
+
|
| 101 |
x, y, mask = x.to(device), y.to(device), mask.to(device)
|
| 102 |
+
|
| 103 |
+
# Forward pass → log probability → negative log-likelihood
|
| 104 |
logits, _ = model(x)
|
| 105 |
logp = torch.log_softmax(logits.float(), dim=-1)
|
| 106 |
nll = -logp.gather(2, y.unsqueeze(2)).squeeze(2)
|
| 107 |
+
|
| 108 |
+
# Akumulasi (hanya hitung posisi yang dimask)
|
| 109 |
total_nll += float(nll[mask].sum())
|
| 110 |
total_tok += int(mask.sum())
|
| 111 |
+
|
| 112 |
return total_nll / max(1, total_tok), total_tok
|
| 113 |
|
| 114 |
|
|
|
|
| 116 |
ap = argparse.ArgumentParser(
|
| 117 |
description="Skor checkpoint pada set uji tetap agar antar-run dapat dibandingkan"
|
| 118 |
)
|
| 119 |
+
ap.add_argument("--ckpt", nargs="+", required=True,
|
| 120 |
+
help="path ke satu atau lebih file checkpoint (.safetensors)")
|
| 121 |
+
ap.add_argument("--test", default="data/sample.txt",
|
| 122 |
+
help="path ke file teks uji (default: data/sample.txt)")
|
| 123 |
+
ap.add_argument("--device", default="cpu", choices=["cpu", "cuda"],
|
| 124 |
+
help="device untuk evaluasi (default: cpu)")
|
| 125 |
+
ap.add_argument("--guard", default=None,
|
| 126 |
+
help="path ke file kamus; generate teks → hitung rasio kata dikenal")
|
| 127 |
+
ap.add_argument("--guard-max-new", type=int, default=120,
|
| 128 |
+
help="jumlah token generate untuk evaluasi guard (default: 120)")
|
| 129 |
+
ap.add_argument("--seed", type=int, default=42,
|
| 130 |
+
help="seed untuk generate saat --guard aktif (default: 42)")
|
| 131 |
+
ap.add_argument("--batch-size", type=int, default=32,
|
| 132 |
+
help="batch size untuk evaluasi (default: 32)")
|
| 133 |
args = ap.parse_args()
|
| 134 |
|
| 135 |
+
# --- Muat teks uji ---
|
| 136 |
teks = read_clean(args.test)
|
| 137 |
n_karakter = len(teks.encode("utf-8"))
|
| 138 |
+
|
| 139 |
+
# --- Muat kamus (jika --guard) ---
|
| 140 |
wordset = prefiks = sufiks = None
|
| 141 |
if args.guard:
|
| 142 |
root = Path(__file__).resolve().parent
|
|
|
|
| 145 |
prefiks = load_wordlist(str(p)) if p.exists() else None
|
| 146 |
sufiks = load_wordlist(str(s)) if s.exists() else None
|
| 147 |
|
| 148 |
+
# --- Header tabel ---
|
| 149 |
print(f"set uji: {args.test} ({n_karakter:,} karakter)")
|
| 150 |
print(f"{'checkpoint':44s} {'nats/tok':>9s} {'nat/kar':>8s} {'kamus':>7s}")
|
| 151 |
baris = []
|
| 152 |
+
|
| 153 |
+
# --- Evaluasi setiap checkpoint ---
|
| 154 |
for path in args.ckpt:
|
| 155 |
model, tokenizer, meta = muat(path)
|
| 156 |
+
|
| 157 |
+
# Encode teks uji → hitung nats per token
|
| 158 |
ids = tokenizer.encode(teks)
|
| 159 |
npt, n_tok = nats_per_token(
|
| 160 |
model, ids, meta["config"]["block_size"], args.device, args.batch_size
|
| 161 |
)
|
| 162 |
+
|
| 163 |
+
# Konversi: nats/token → nats/karakter (menggunakan compression ratio)
|
| 164 |
kompresi = n_karakter / max(1, n_tok)
|
| 165 |
npc = npt / kompresi
|
| 166 |
+
|
| 167 |
+
# (Opsional) hitung rasio kata dikenal via generate
|
| 168 |
rasio = ""
|
| 169 |
if wordset:
|
| 170 |
torch.manual_seed(args.seed)
|
| 171 |
out = model.generate(
|
| 172 |
torch.tensor([[0]], dtype=torch.long, device=args.device),
|
| 173 |
+
args.guard_max_new,
|
| 174 |
temperature=0.8,
|
| 175 |
top_k=40,
|
| 176 |
)
|
| 177 |
teks_out = tokenizer.decode(out[0].tolist())
|
| 178 |
rasio = f"{word_known_ratio(teks_out, wordset, prefiks, sufiks):6.0%}"
|
| 179 |
+
|
| 180 |
+
# Format nama checkpoint yang pendek (runs/xxx/ckpt/file.safetensors)
|
| 181 |
bagian = str(Path(path)).replace("\\", "/").split("/")
|
| 182 |
nama = "/".join(bagian[-3:-1] + [bagian[-1]]) if len(bagian) >= 3 else bagian[-1]
|
| 183 |
print(f"{nama:44s} {npt:9.3f} {npc:8.3f} {rasio:>7s}")
|
generate.py
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
import torch
|
| 3 |
import argparse
|
|
@@ -9,6 +24,18 @@ from indigo.model import GPT, GPTConfig
|
|
| 9 |
|
| 10 |
|
| 11 |
def load_model(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if path.endswith(".safetensors"):
|
| 13 |
from safetensors.torch import load_file
|
| 14 |
|
|
@@ -31,29 +58,55 @@ def load_model(path):
|
|
| 31 |
|
| 32 |
def main():
|
| 33 |
parser = argparse.ArgumentParser(description="Generate teks dari checkpoint Indigo")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
parser.add_argument("--
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
parser.add_argument("--
|
| 41 |
-
|
| 42 |
-
parser.add_argument("--
|
| 43 |
-
|
| 44 |
-
parser.add_argument("--
|
| 45 |
-
|
| 46 |
-
parser.add_argument("--
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
args = parser.parse_args()
|
| 49 |
|
|
|
|
| 50 |
if args.seed is not None:
|
| 51 |
torch.manual_seed(args.seed)
|
| 52 |
device = "cuda" if torch.cuda.is_available() else "cpu" if args.device == "auto" else args.device
|
| 53 |
|
|
|
|
| 54 |
model, tokenizer = load_model(args.ckpt)
|
| 55 |
model = model.to(device)
|
| 56 |
|
|
|
|
| 57 |
wordset = None
|
| 58 |
pref_set = suf_set = None
|
| 59 |
if args.guard:
|
|
@@ -63,10 +116,12 @@ def main():
|
|
| 63 |
|
| 64 |
wordset = load_wordlist(args.guard)
|
| 65 |
p_def, s_def = _Path("data/prefiks.txt"), _Path("data/sufiks.txt")
|
|
|
|
| 66 |
if args.guard_prefiks and _Path(args.guard_prefiks).exists():
|
| 67 |
pref_set = load_wordlist(args.guard_prefiks)
|
| 68 |
elif not args.guard_prefiks and p_def.exists():
|
| 69 |
pref_set = load_wordlist(str(p_def))
|
|
|
|
| 70 |
if args.guard_sufiks and _Path(args.guard_sufiks).exists():
|
| 71 |
suf_set = load_wordlist(args.guard_sufiks)
|
| 72 |
elif not args.guard_sufiks and s_def.exists():
|
|
@@ -74,10 +129,16 @@ def main():
|
|
| 74 |
mode = "dengan formula afiks" if pref_set and suf_set else "kata persis"
|
| 75 |
print(f"[guard] kamus: {len(wordset):,} kata ({mode}) | target rasio >= {args.guard_min:.0%}")
|
| 76 |
|
|
|
|
| 77 |
ids = tokenizer.encode(args.prompt) or [0]
|
| 78 |
idx = torch.tensor([ids], dtype=torch.long, device=device)
|
| 79 |
|
| 80 |
def sample():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
out = model.generate(
|
| 82 |
idx,
|
| 83 |
args.max_new,
|
|
@@ -90,20 +151,22 @@ def main():
|
|
| 90 |
ratio = word_known_ratio(text, wordset, pref_set, suf_set) if wordset else 1.0
|
| 91 |
return text, ratio
|
| 92 |
|
|
|
|
| 93 |
if wordset is None:
|
| 94 |
text, _ = sample()
|
| 95 |
print(text)
|
| 96 |
return
|
| 97 |
|
|
|
|
| 98 |
best_text, best_ratio = "", -1.0
|
| 99 |
for t in range(args.guard_tries):
|
| 100 |
-
torch.manual_seed((args.seed or 0) + t * 1013)
|
| 101 |
text, ratio = sample()
|
| 102 |
mark = f" [kandidat {t + 1}: {ratio:.0%}]"
|
| 103 |
if ratio > best_ratio:
|
| 104 |
best_text, best_ratio = text, ratio
|
| 105 |
if best_ratio >= args.guard_min:
|
| 106 |
-
break
|
| 107 |
print(best_text)
|
| 108 |
print(f"[guard] rasio kata dikenal: {best_ratio:.0%}")
|
| 109 |
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script inferensi / generasi teks dari checkpoint Indigo.
|
| 3 |
+
|
| 4 |
+
Mendukung:
|
| 5 |
+
- Top-k, top-p, temperature, repetition penalty sampling
|
| 6 |
+
- KV-cache untuk generasi cepat (token-by-token)
|
| 7 |
+
- Guard kamus: generate beberapa kandidat → pilih yang rasio kata dikenal tertinggi
|
| 8 |
+
- Guard morfologi: cek imbuhan Indonesia (prefiks + sufiks + asimilasi)
|
| 9 |
+
|
| 10 |
+
Cara pakai:
|
| 11 |
+
python generate.py --prompt "Indigo" --max-new 300
|
| 12 |
+
python generate.py --prompt "hello" --temperature 0.8 --top-k 40 --top-p 0.9
|
| 13 |
+
python generate.py --prompt "kepekaan" --guard data/kamus_id.txt --guard-min 0.6
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
import sys
|
| 17 |
import torch
|
| 18 |
import argparse
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def load_model(path):
|
| 27 |
+
"""Muat model GPT + tokenizer dari file checkpoint.
|
| 28 |
+
|
| 29 |
+
Mendukung dua format:
|
| 30 |
+
1. .safetensors: format utama Indigo
|
| 31 |
+
2. .pt: format PyTorch lama
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
path: Path ke file checkpoint.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Tuple (model, tokenizer).
|
| 38 |
+
"""
|
| 39 |
if path.endswith(".safetensors"):
|
| 40 |
from safetensors.torch import load_file
|
| 41 |
|
|
|
|
| 58 |
|
| 59 |
def main():
|
| 60 |
parser = argparse.ArgumentParser(description="Generate teks dari checkpoint Indigo")
|
| 61 |
+
|
| 62 |
+
# --- Checkpoint ---
|
| 63 |
+
parser.add_argument("--ckpt", default="out/indigo_best.safetensors",
|
| 64 |
+
help="path ke file checkpoint model (default: out/indigo_best.safetensors)")
|
| 65 |
+
|
| 66 |
+
# --- Prompt & Generasi ---
|
| 67 |
+
parser.add_argument("--prompt", default="",
|
| 68 |
+
help="teks awal (prompt) untuk memulai generasi (default: kosong)")
|
| 69 |
+
parser.add_argument("--max-new", type=int, default=300,
|
| 70 |
+
help="jumlah token baru yang akan dihasilkan (default: 300)")
|
| 71 |
+
parser.add_argument("--temperature", type=float, default=0.8,
|
| 72 |
+
help="skala randomness: 0.0 ≈ greedy, 0.8 ≈ standar, >1.0 ≈ random (default: 0.8)")
|
| 73 |
+
parser.add_argument("--top-k", type=int, default=40,
|
| 74 |
+
help="batasi sampling ke k token teratas (0 = nonaktif, default: 40)")
|
| 75 |
+
parser.add_argument("--top-p", type=float, default=1.0,
|
| 76 |
+
help="nucleus sampling: batasi kumulatif probabilitas (1.0 = nonaktif, default: 1.0)")
|
| 77 |
+
parser.add_argument("--repetition-penalty", type=float, default=1.0,
|
| 78 |
+
help="penalti pengulangan token (>1.0 = aktif, 1.0 = nonaktif, default: 1.0)")
|
| 79 |
+
parser.add_argument("--seed", type=int, default=None,
|
| 80 |
+
help="seed random (None = tidak ditentukan, default: None)")
|
| 81 |
+
|
| 82 |
+
# --- Device ---
|
| 83 |
+
parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"],
|
| 84 |
+
help="device: auto/cpu/cuda (default: auto)")
|
| 85 |
+
|
| 86 |
+
# --- Guard Kamus ---
|
| 87 |
+
parser.add_argument("--guard", default=None,
|
| 88 |
+
help="path file kamus (satu kata per baris); generate beberapa kandidat → pilih terbaik")
|
| 89 |
+
parser.add_argument("--guard-prefiks", default=None,
|
| 90 |
+
help="path file prefiks Indonesia (default: data/prefiks.txt bila ada)")
|
| 91 |
+
parser.add_argument("--guard-sufiks", default=None,
|
| 92 |
+
help="path file sufiks Indonesia (default: data/sufiks.txt bila ada)")
|
| 93 |
+
parser.add_argument("--guard-tries", type=int, default=5,
|
| 94 |
+
help="jumlah kandidat generate saat --guard aktif (default: 5)")
|
| 95 |
+
parser.add_argument("--guard-min", type=float, default=0.6,
|
| 96 |
+
help="rasio kata dikenal minimum — berhenti generate jika tercapai (default: 0.6)")
|
| 97 |
+
|
| 98 |
args = parser.parse_args()
|
| 99 |
|
| 100 |
+
# --- Setup seed & device ---
|
| 101 |
if args.seed is not None:
|
| 102 |
torch.manual_seed(args.seed)
|
| 103 |
device = "cuda" if torch.cuda.is_available() else "cpu" if args.device == "auto" else args.device
|
| 104 |
|
| 105 |
+
# --- Muat model ---
|
| 106 |
model, tokenizer = load_model(args.ckpt)
|
| 107 |
model = model.to(device)
|
| 108 |
|
| 109 |
+
# --- Muat kamus (jika --guard aktif) ---
|
| 110 |
wordset = None
|
| 111 |
pref_set = suf_set = None
|
| 112 |
if args.guard:
|
|
|
|
| 116 |
|
| 117 |
wordset = load_wordlist(args.guard)
|
| 118 |
p_def, s_def = _Path("data/prefiks.txt"), _Path("data/sufiks.txt")
|
| 119 |
+
# Muat prefiks: prioritaskan argumen CLI → default path
|
| 120 |
if args.guard_prefiks and _Path(args.guard_prefiks).exists():
|
| 121 |
pref_set = load_wordlist(args.guard_prefiks)
|
| 122 |
elif not args.guard_prefiks and p_def.exists():
|
| 123 |
pref_set = load_wordlist(str(p_def))
|
| 124 |
+
# Muat sufiks: prioritaskan argumen CLI → default path
|
| 125 |
if args.guard_sufiks and _Path(args.guard_sufiks).exists():
|
| 126 |
suf_set = load_wordlist(args.guard_sufiks)
|
| 127 |
elif not args.guard_sufiks and s_def.exists():
|
|
|
|
| 129 |
mode = "dengan formula afiks" if pref_set and suf_set else "kata persis"
|
| 130 |
print(f"[guard] kamus: {len(wordset):,} kata ({mode}) | target rasio >= {args.guard_min:.0%}")
|
| 131 |
|
| 132 |
+
# --- Encode prompt → token IDs ---
|
| 133 |
ids = tokenizer.encode(args.prompt) or [0]
|
| 134 |
idx = torch.tensor([ids], dtype=torch.long, device=device)
|
| 135 |
|
| 136 |
def sample():
|
| 137 |
+
"""Generate satu kandidat teks dari model.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Tuple (text, ratio) — teks hasil generate dan rasio kata dikenal.
|
| 141 |
+
"""
|
| 142 |
out = model.generate(
|
| 143 |
idx,
|
| 144 |
args.max_new,
|
|
|
|
| 151 |
ratio = word_known_ratio(text, wordset, pref_set, suf_set) if wordset else 1.0
|
| 152 |
return text, ratio
|
| 153 |
|
| 154 |
+
# --- Tanpa guard: langsung generate & print ---
|
| 155 |
if wordset is None:
|
| 156 |
text, _ = sample()
|
| 157 |
print(text)
|
| 158 |
return
|
| 159 |
|
| 160 |
+
# --- Dengan guard: generate beberapa kandidat → pilih yang terbaik ---
|
| 161 |
best_text, best_ratio = "", -1.0
|
| 162 |
for t in range(args.guard_tries):
|
| 163 |
+
torch.manual_seed((args.seed or 0) + t * 1013) # seed berbeda tiap kandidat
|
| 164 |
text, ratio = sample()
|
| 165 |
mark = f" [kandidat {t + 1}: {ratio:.0%}]"
|
| 166 |
if ratio > best_ratio:
|
| 167 |
best_text, best_ratio = text, ratio
|
| 168 |
if best_ratio >= args.guard_min:
|
| 169 |
+
break # sudah cukup bagus, tidak perlu generate lagi
|
| 170 |
print(best_text)
|
| 171 |
print(f"[guard] rasio kata dikenal: {best_ratio:.0%}")
|
| 172 |
|
indigo/bpe.py
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
def _get_stats(ids):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
stats = {}
|
| 3 |
for pair in zip(ids, ids[1:]):
|
| 4 |
stats[pair] = stats.get(pair, 0) + 1
|
|
@@ -6,6 +29,19 @@ def _get_stats(ids):
|
|
| 6 |
|
| 7 |
|
| 8 |
def _merge(ids, pair, idx):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
out = []
|
| 10 |
i = 0
|
| 11 |
while i < len(ids):
|
|
@@ -19,15 +55,50 @@ def _merge(ids, pair, idx):
|
|
| 19 |
|
| 20 |
|
| 21 |
class BPETokenizer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def __init__(self, merges=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
self.merges = [tuple(p) for p in (merges or [])]
|
| 24 |
self.ranks = {pair: i for i, pair in enumerate(self.merges)}
|
|
|
|
| 25 |
self.vocab = [bytes([i]) for i in range(256)]
|
|
|
|
| 26 |
for a, b in self.merges:
|
| 27 |
self.vocab.append(self.vocab[a] + self.vocab[b])
|
| 28 |
|
| 29 |
@classmethod
|
| 30 |
def train(cls, text, vocab_size):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
tok = cls()
|
| 32 |
ids = list(text.encode("utf-8"))
|
| 33 |
next_id = 256
|
|
@@ -35,7 +106,7 @@ class BPETokenizer:
|
|
| 35 |
stats = _get_stats(ids)
|
| 36 |
pair = max(stats, key=stats.get)
|
| 37 |
if stats[pair] < 2:
|
| 38 |
-
break
|
| 39 |
ids = _merge(ids, pair, next_id)
|
| 40 |
tok.ranks[pair] = len(tok.merges)
|
| 41 |
tok.merges.append(pair)
|
|
@@ -45,9 +116,23 @@ class BPETokenizer:
|
|
| 45 |
|
| 46 |
@property
|
| 47 |
def vocab_size(self):
|
|
|
|
| 48 |
return 256 + len(self.merges)
|
| 49 |
|
| 50 |
def _encode_chunk(self, ids):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
while len(ids) >= 2:
|
| 52 |
best = None
|
| 53 |
best_rank = None
|
|
@@ -56,20 +141,51 @@ class BPETokenizer:
|
|
| 56 |
if rank is not None and (best_rank is None or rank < best_rank):
|
| 57 |
best = pair
|
| 58 |
best_rank = rank
|
|
|
|
|
|
|
| 59 |
if best is None:
|
| 60 |
break
|
| 61 |
ids = _merge(ids, best, 256 + best_rank)
|
| 62 |
return ids
|
| 63 |
|
| 64 |
def encode(self, text):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return self._encode_chunk(list(text.encode("utf-8")))
|
| 66 |
|
| 67 |
def decode(self, ids):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")
|
| 69 |
|
| 70 |
def state(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return {"type": "bpe", "merges": [list(p) for p in self.merges]}
|
| 72 |
|
| 73 |
@classmethod
|
| 74 |
def from_state(cls, state):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return cls(state["merges"])
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tokenizer BPE (Byte Pair Encoding) level-byte untuk Indigo.
|
| 3 |
+
|
| 4 |
+
BPE adalah algoritma tokenisasi yang belajar merge pasangan byte yang paling sering muncul
|
| 5 |
+
secara berulang, menghasilkan subword units yang lebih efisien dari karakter tunggal.
|
| 6 |
+
|
| 7 |
+
Alur kerja:
|
| 8 |
+
1. Training: iteratively cari pasangan byte paling sering → merge → tambah ke vocabulary
|
| 9 |
+
2. Encoding: teks → daftar byte → terapkan merge berdasarkan rank (prioritas merge)
|
| 10 |
+
3. Decoding: daftar indeks → lookup byte sequences → gabungkan → dekode UTF-8
|
| 11 |
+
|
| 12 |
+
Referensi: Sennrich et al., 2016, "Neural Machine Translation of Rare Words with Subword Units"
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
def _get_stats(ids):
|
| 17 |
+
"""Hitung frekuensi setiap pasangan byte yang berdekatan dalam sequence.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
ids: Daftar integer (byte values) — representasi numerik dari teks.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Dict {(id_a, id_b): count} — frekuensi setiap pasangan.
|
| 24 |
+
"""
|
| 25 |
stats = {}
|
| 26 |
for pair in zip(ids, ids[1:]):
|
| 27 |
stats[pair] = stats.get(pair, 0) + 1
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def _merge(ids, pair, idx):
|
| 32 |
+
"""Gabungkan semua kemunculan pasangan (pair[0], pair[1]) menjadi idx tunggal.
|
| 33 |
+
|
| 34 |
+
Contoh: ids=[1,2,3,1,2], pair=(1,2), idx=256 → [256,3,256]
|
| 35 |
+
Algoritma: scan linear dari kiri ke kanan, merge saat pasangan cocok.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
ids: Daftar integer (token IDs).
|
| 39 |
+
pair: Tuple (id_a, id_b) — pasangan yang akan digabung.
|
| 40 |
+
idx: Integer — ID baru untuk hasil merge.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
Daftar integer baru dengan merge sudah diterapkan.
|
| 44 |
+
"""
|
| 45 |
out = []
|
| 46 |
i = 0
|
| 47 |
while i < len(ids):
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
class BPETokenizer:
|
| 58 |
+
"""Tokenizer BPE level-byte untuk teks UTF-8.
|
| 59 |
+
|
| 60 |
+
Vocabulary dasar: 256 token pertama = semua kemungkinan byte (0–255).
|
| 61 |
+
Token 256 ke atas = hasil merge pasangan byte selama training.
|
| 62 |
+
|
| 63 |
+
Atribut:
|
| 64 |
+
merges: Daftar tuple (id_a, id_b) — urutan merge yang dipelajari (indeks = rank).
|
| 65 |
+
ranks: Dict {(id_a, id_b): rank} — lookup cepat rank untuk pasangan.
|
| 66 |
+
vocab: Daftar bytes — indeks → representasi byte gabungan.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
def __init__(self, merges=None):
|
| 70 |
+
"""
|
| 71 |
+
Args:
|
| 72 |
+
merges: Daftar pasangan [(id_a, id_b), ...] atau None (kosong).
|
| 73 |
+
"""
|
| 74 |
self.merges = [tuple(p) for p in (merges or [])]
|
| 75 |
self.ranks = {pair: i for i, pair in enumerate(self.merges)}
|
| 76 |
+
# Vocabulary dasar: 256 byte individual (0x00–0xFF)
|
| 77 |
self.vocab = [bytes([i]) for i in range(256)]
|
| 78 |
+
# Tambahkan byte gabungan dari setiap merge
|
| 79 |
for a, b in self.merges:
|
| 80 |
self.vocab.append(self.vocab[a] + self.vocab[b])
|
| 81 |
|
| 82 |
@classmethod
|
| 83 |
def train(cls, text, vocab_size):
|
| 84 |
+
"""Latih tokenizer BPE dari teks.
|
| 85 |
+
|
| 86 |
+
Algoritma:
|
| 87 |
+
1. Konversi teks ke daftar byte (UTF-8)
|
| 88 |
+
2. Ulangi sampai vocab_size tercapai atau tidak ada pasangan yang layak:
|
| 89 |
+
a. Hitung frekuensi semua pasangan byte
|
| 90 |
+
b. Pilih pasangan paling sering
|
| 91 |
+
c. Jika frekuensi < 2, berhenti (pasangan terlalu jarang)
|
| 92 |
+
d. Merge pasangan tersebut → tambah ID baru ke vocabulary
|
| 93 |
+
e. Catat merge di daftar merges
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
text: Teks training (string UTF-8).
|
| 97 |
+
vocab_size: Target jumlah token di vocabulary (termasuk 256 byte dasar).
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
BPETokenizer yang sudah terlatih.
|
| 101 |
+
"""
|
| 102 |
tok = cls()
|
| 103 |
ids = list(text.encode("utf-8"))
|
| 104 |
next_id = 256
|
|
|
|
| 106 |
stats = _get_stats(ids)
|
| 107 |
pair = max(stats, key=stats.get)
|
| 108 |
if stats[pair] < 2:
|
| 109 |
+
break # Tidak ada pasangan yang cukup sering muncul
|
| 110 |
ids = _merge(ids, pair, next_id)
|
| 111 |
tok.ranks[pair] = len(tok.merges)
|
| 112 |
tok.merges.append(pair)
|
|
|
|
| 116 |
|
| 117 |
@property
|
| 118 |
def vocab_size(self):
|
| 119 |
+
"""Jumlah total token di vocabulary (256 byte dasar + jumlah merge)."""
|
| 120 |
return 256 + len(self.merges)
|
| 121 |
|
| 122 |
def _encode_chunk(self, ids):
|
| 123 |
+
"""Terapkan merge secara berulang sampai tidak ada lagi pasangan yang bisa di-merge.
|
| 124 |
+
|
| 125 |
+
Strategi: pada setiap iterasi, cari pasangan dengan rank terendah (prioritas merge
|
| 126 |
+
paling awal = paling penting), lalu merge. Ulangi sampai tidak ada pasangan lagi.
|
| 127 |
+
|
| 128 |
+
Optimasi: break lebih awal jika panjang sequence sudah sangat pendek.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
ids: Daftar integer (byte values) dari satu chunk teks.
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
Daftar integer (token IDs) setelah semua merge diterapkan.
|
| 135 |
+
"""
|
| 136 |
while len(ids) >= 2:
|
| 137 |
best = None
|
| 138 |
best_rank = None
|
|
|
|
| 141 |
if rank is not None and (best_rank is None or rank < best_rank):
|
| 142 |
best = pair
|
| 143 |
best_rank = rank
|
| 144 |
+
if best_rank == 0:
|
| 145 |
+
break # rank 0 = prioritas tertinggi, tidak perlu cari lagi
|
| 146 |
if best is None:
|
| 147 |
break
|
| 148 |
ids = _merge(ids, best, 256 + best_rank)
|
| 149 |
return ids
|
| 150 |
|
| 151 |
def encode(self, text):
|
| 152 |
+
"""Enkode teks UTF-8 menjadi daftar token ID.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
text: String teks.
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Daftar integer (token IDs).
|
| 159 |
+
"""
|
| 160 |
return self._encode_chunk(list(text.encode("utf-8")))
|
| 161 |
|
| 162 |
def decode(self, ids):
|
| 163 |
+
"""Dekode daftar token ID kembali menjadi teks UTF-8.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
ids: Daftar integer (token IDs).
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
String teks (error UTF-8 diganti dengan karakter replacement).
|
| 170 |
+
"""
|
| 171 |
return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")
|
| 172 |
|
| 173 |
def state(self):
|
| 174 |
+
"""Ekspor state tokenizer untuk disimpan ke metadata JSON.
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Dict {"type": "bpe", "merges": [[id_a, id_b], ...]}.
|
| 178 |
+
"""
|
| 179 |
return {"type": "bpe", "merges": [list(p) for p in self.merges]}
|
| 180 |
|
| 181 |
@classmethod
|
| 182 |
def from_state(cls, state):
|
| 183 |
+
"""Muat ulang tokenizer BPE dari state (metadata JSON).
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
state: Dict {"type": "bpe", "merges": [[id_a, id_b], ...]}.
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
BPETokenizer yang sudah dipulihkan.
|
| 190 |
+
"""
|
| 191 |
return cls(state["merges"])
|
indigo/common.py
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
|
|
|
|
|
|
|
| 5 |
DECOR_LINE = re.compile(r"^[\s=\-_~*#.]{4,}$")
|
| 6 |
|
| 7 |
|
| 8 |
def clean_text(text):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
lines = [ln for ln in text.splitlines() if not DECOR_LINE.match(ln)]
|
| 10 |
text = "\n".join(lines)
|
| 11 |
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
@@ -13,6 +39,17 @@ def clean_text(text):
|
|
| 13 |
|
| 14 |
|
| 15 |
def collect_text_files(paths):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
files = []
|
| 17 |
for p in paths:
|
| 18 |
if os.path.isdir(p):
|
|
@@ -24,11 +61,32 @@ def collect_text_files(paths):
|
|
| 24 |
|
| 25 |
|
| 26 |
def read_clean(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
with open(path, encoding="utf-8") as f:
|
| 28 |
return clean_text(f.read())
|
| 29 |
|
| 30 |
|
| 31 |
def save_meta(base_path, config, vocab, step, val_loss, backend, tokenizer=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
meta = {
|
| 33 |
"config": config,
|
| 34 |
"vocab": vocab,
|
|
@@ -42,11 +100,35 @@ def save_meta(base_path, config, vocab, step, val_loss, backend, tokenizer=None)
|
|
| 42 |
|
| 43 |
|
| 44 |
def load_meta(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
with open(os.path.splitext(path)[0] + "_meta.json", encoding="utf-8") as f:
|
| 46 |
return json.load(f)
|
| 47 |
|
| 48 |
|
| 49 |
def build_tokenizer(tokenizer_info, vocab):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if tokenizer_info.get("type") == "bpe":
|
| 51 |
from indigo.bpe import BPETokenizer
|
| 52 |
|
|
@@ -56,25 +138,60 @@ def build_tokenizer(tokenizer_info, vocab):
|
|
| 56 |
return CharTokenizer(vocab)
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
WORD_RE = re.compile(r"[a-zA-Z\xC0-\xFF][a-zA-Z\xC0-\xFF'-]+")
|
| 60 |
|
| 61 |
|
| 62 |
def load_wordlist(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
with open(path, encoding="utf-8") as f:
|
| 64 |
return frozenset(w.strip().lower() for w in f if w.strip())
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
| 67 |
PREFIKS_ASIMILASI = {"meny": "s", "peny": "s", "pem": "p"}
|
| 68 |
|
| 69 |
|
| 70 |
def _kandidat_akar(word, prefiks, sufiks):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
sisa = {word}
|
| 72 |
for _ in range(3):
|
| 73 |
lanjut = set()
|
| 74 |
for w in sisa:
|
|
|
|
| 75 |
for suf in sorted(sufiks, key=len, reverse=True):
|
| 76 |
if w.endswith(suf) and len(w) - len(suf) >= 3:
|
| 77 |
lanjut.add(w[: -len(suf)])
|
|
|
|
| 78 |
for pref in sorted(prefiks, key=len, reverse=True):
|
| 79 |
if w.startswith(pref) and len(w) - len(pref) >= 3:
|
| 80 |
akar = PREFIKS_ASIMILASI.get(pref, "") + w[len(pref):]
|
|
@@ -88,6 +205,24 @@ def _kandidat_akar(word, prefiks, sufiks):
|
|
| 88 |
|
| 89 |
|
| 90 |
def word_known_ratio(text, wordset, prefiks=None, sufiks=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
words = [m.group(0).strip("'-").lower() for m in WORD_RE.finditer(text)]
|
| 92 |
words = [w for w in words if len(w) >= 2 and w.isalpha()]
|
| 93 |
if not words:
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modul utilitas bersama untuk ekosistem Indigo.
|
| 3 |
+
|
| 4 |
+
Berisi fungsi-fungsi yang dipakai oleh train.py, generate.py, eval.py, dan pipeline.py:
|
| 5 |
+
- Pembersihan teks (menghapus dekorasi markdown)
|
| 6 |
+
- Pengumpulan file teks dari direktori secara rekursif
|
| 7 |
+
- Penyimpanan/pemuatan metadata checkpoint (JSON)
|
| 8 |
+
- Pembangunan tokenizer (char atau BPE) dari metadata
|
| 9 |
+
- Analisis morfologi Indonesia (imbuhan + akar kata) untuk guard kamus
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
import json
|
| 13 |
import os
|
| 14 |
import re
|
| 15 |
|
| 16 |
+
# Pola regex untuk mendeteksi baris dekorasi markdown (garis-garis ===, ---, ~~~, ***)
|
| 17 |
+
# Minimal 4 karakter berulang — ini bukan konten bermakna untuk training
|
| 18 |
DECOR_LINE = re.compile(r"^[\s=\-_~*#.]{4,}$")
|
| 19 |
|
| 20 |
|
| 21 |
def clean_text(text):
|
| 22 |
+
"""Bersihkan teks mentah agar lebih cocok untuk training.
|
| 23 |
+
|
| 24 |
+
Langkah:
|
| 25 |
+
1. Hapus baris dekorasi markdown (====, ----, ~~~~, dst.)
|
| 26 |
+
2. Gabungkan 3+ baris kosong berturut menjadi 2 baris kosong (satu paragraf kosong)
|
| 27 |
+
3. Strip spasi di awal/akhir, tambahkan newline di akhir
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
text: Teks mentah dari file.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Teks yang sudah dibersihkan.
|
| 34 |
+
"""
|
| 35 |
lines = [ln for ln in text.splitlines() if not DECOR_LINE.match(ln)]
|
| 36 |
text = "\n".join(lines)
|
| 37 |
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def collect_text_files(paths):
|
| 42 |
+
"""Kumpulkan semua file .txt dari daftar path (file atau direktori).
|
| 43 |
+
|
| 44 |
+
Jika path adalah direktori, cari rekursif semua file .txt di dalamnya.
|
| 45 |
+
Jika path adalah file, langsung tambahkan (tidak harus .txt).
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
paths: Daftar string path (file atau direktori).
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Daftar string path file yang sudah diurutkan.
|
| 52 |
+
"""
|
| 53 |
files = []
|
| 54 |
for p in paths:
|
| 55 |
if os.path.isdir(p):
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def read_clean(path):
|
| 64 |
+
"""Baca file teks dan bersihkan isinya dengan clean_text().
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
path: Path ke file teks (encoding UTF-8).
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Teks yang sudah dibersihkan.
|
| 71 |
+
"""
|
| 72 |
with open(path, encoding="utf-8") as f:
|
| 73 |
return clean_text(f.read())
|
| 74 |
|
| 75 |
|
| 76 |
def save_meta(base_path, config, vocab, step, val_loss, backend, tokenizer=None):
|
| 77 |
+
"""Simpan metadata checkpoint ke file JSON.
|
| 78 |
+
|
| 79 |
+
File yang dihasilkan: ganti suffix base_path menjadi _meta.json.
|
| 80 |
+
Contoh: out/indigo_best.safetensors → out/indigo_best_meta.json
|
| 81 |
+
|
| 82 |
+
Isi metadata:
|
| 83 |
+
- config: Dict hyperparameter model (vocab_size, n_layer, n_embd, dst.)
|
| 84 |
+
- vocab: Daftar karakter (untuk char tokenizer) atau None (untuk BPE)
|
| 85 |
+
- step: Nomor step training saat checkpoint disimpan
|
| 86 |
+
- val_loss: Loss validasi terbaik (atau None jika tidak ada validasi)
|
| 87 |
+
- backend: "pytorch" atau "tensorflow"
|
| 88 |
+
- tokenizer: Dict info tokenizer {"type": "char"} atau {"type": "bpe", "merges": [...]}
|
| 89 |
+
"""
|
| 90 |
meta = {
|
| 91 |
"config": config,
|
| 92 |
"vocab": vocab,
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
def load_meta(path):
|
| 103 |
+
"""Muat metadata checkpoint dari file _meta.json.
|
| 104 |
+
|
| 105 |
+
File yang dicari: ganti suffix path menjadi _meta.json.
|
| 106 |
+
Contoh: out/indigo_best.safetensors → cari out/indigo_best_meta.json
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
path: Path ke file checkpoint (.safetensors atau .pt).
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
Dict metadata berisi config, vocab, step, val_loss, backend, tokenizer.
|
| 113 |
+
"""
|
| 114 |
with open(os.path.splitext(path)[0] + "_meta.json", encoding="utf-8") as f:
|
| 115 |
return json.load(f)
|
| 116 |
|
| 117 |
|
| 118 |
def build_tokenizer(tokenizer_info, vocab):
|
| 119 |
+
"""Bangun objek tokenizer dari metadata checkpoint.
|
| 120 |
+
|
| 121 |
+
Dispatch berdasarkan type:
|
| 122 |
+
- "bpe": Muat BPETokenizer dari daftar merges
|
| 123 |
+
- lainnya: Muat CharTokenizer dari daftar karakter (vocab)
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
tokenizer_info: Dict {"type": "char"} atau {"type": "bpe", "merges": [...]}.
|
| 127 |
+
vocab: Daftar karakter (list of str) atau None.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Objek tokenizer (CharTokenizer atau BPETokenizer).
|
| 131 |
+
"""
|
| 132 |
if tokenizer_info.get("type") == "bpe":
|
| 133 |
from indigo.bpe import BPETokenizer
|
| 134 |
|
|
|
|
| 138 |
return CharTokenizer(vocab)
|
| 139 |
|
| 140 |
|
| 141 |
+
# --- Analisis morfologi Indonesia untuk guard kamus ---
|
| 142 |
+
|
| 143 |
+
# Pola regex untuk mengekstrak kata alfanumerik dari teks
|
| 144 |
+
# Mencakup karakter Latin + aksen (á, é, dst.) + tanda petik/strip tengah
|
| 145 |
WORD_RE = re.compile(r"[a-zA-Z\xC0-\xFF][a-zA-Z\xC0-\xFF'-]+")
|
| 146 |
|
| 147 |
|
| 148 |
def load_wordlist(path):
|
| 149 |
+
"""Muat daftar kata dari file (satu kata per baris).
|
| 150 |
+
|
| 151 |
+
Mengembalikan frozenset kata lowercase untuk pencarian O(1).
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
path: Path ke file teks (satu kata per baris, UTF-8).
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
frozenset of str (kata lowercase).
|
| 158 |
+
"""
|
| 159 |
with open(path, encoding="utf-8") as f:
|
| 160 |
return frozenset(w.strip().lower() for w in f if w.strip())
|
| 161 |
|
| 162 |
|
| 163 |
+
# Peta asimilasi prefiks: beberapa prefiks berubah bunyi saat ditambahkan ke akar
|
| 164 |
+
# Contoh: "menyapu" → hapus "meny" → "apu", tapi akarnya "sapu" (ada asimilasi s→s)
|
| 165 |
PREFIKS_ASIMILASI = {"meny": "s", "peny": "s", "pem": "p"}
|
| 166 |
|
| 167 |
|
| 168 |
def _kandidat_akar(word, prefiks, sufiks):
|
| 169 |
+
"""Cari kemungkinan akar kata dari kata berimbuhan (stems candidates).
|
| 170 |
+
|
| 171 |
+
Algoritma iteratif (max 3 putaran):
|
| 172 |
+
1. Coba hapus setiap sufiks → simpan hasilnya
|
| 173 |
+
2. Coba hapus setiap prefiks → simpan hasilnya (+ asimilasi)
|
| 174 |
+
3. Ulangi pada hasil baru sampai tidak ada kandidat baru
|
| 175 |
+
|
| 176 |
+
Kandidat hanya disimpan jika panjang sisa >= 3 karakter (mencegah akar terlalu pendek).
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
word: Kata yang akan dianalisis (string).
|
| 180 |
+
prefiks: Set of str — daftar prefiks (mis. {"men", "mem", "di", "ter"}).
|
| 181 |
+
sufiks: Set of str — daftar sufiks (mis. {"kan", "an", "i", "nya"}).
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
Set of str — semua kemungkinan akar kata.
|
| 185 |
+
"""
|
| 186 |
sisa = {word}
|
| 187 |
for _ in range(3):
|
| 188 |
lanjut = set()
|
| 189 |
for w in sisa:
|
| 190 |
+
# Coba hapus sufiks
|
| 191 |
for suf in sorted(sufiks, key=len, reverse=True):
|
| 192 |
if w.endswith(suf) and len(w) - len(suf) >= 3:
|
| 193 |
lanjut.add(w[: -len(suf)])
|
| 194 |
+
# Coba hapus prefiks (+ asimilasi)
|
| 195 |
for pref in sorted(prefiks, key=len, reverse=True):
|
| 196 |
if w.startswith(pref) and len(w) - len(pref) >= 3:
|
| 197 |
akar = PREFIKS_ASIMILASI.get(pref, "") + w[len(pref):]
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
def word_known_ratio(text, wordset, prefiks=None, sufiks=None):
|
| 208 |
+
"""Hitung rasio kata yang dikenal kamus dari sebuah teks.
|
| 209 |
+
|
| 210 |
+
Tahapan:
|
| 211 |
+
1. Ekstrak semua kata alfanumerik dari teks (min 2 huruf, hanya alpha)
|
| 212 |
+
2. Untuk setiap kata:
|
| 213 |
+
- Cek langsung di wordset → jika ada, dikenal
|
| 214 |
+
- Jika tidak ada dan afiks tersedia, cek semua kandidat akar → jika ada, dikenal
|
| 215 |
+
3. return jumlah_dikenal / total_kata
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
text: Teks yang akan dievaluasi.
|
| 219 |
+
wordset: frozenset of str — daftar kata yang dikenal (lowercase).
|
| 220 |
+
prefiks: Set of str atau None — daftar prefiks Indonesia.
|
| 221 |
+
sufiks: Set of str atau None — daftar sufiks Indonesia.
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
Float 0.0–1.0 — rasio kata yang dikenal kamus.
|
| 225 |
+
"""
|
| 226 |
words = [m.group(0).strip("'-").lower() for m in WORD_RE.finditer(text)]
|
| 227 |
words = [w for w in words if len(w) >= 2 and w.isalpha()]
|
| 228 |
if not words:
|
indigo/model.py
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import math
|
| 2 |
from dataclasses import dataclass
|
| 3 |
|
|
@@ -8,6 +20,17 @@ import torch.nn.functional as F
|
|
| 8 |
|
| 9 |
@dataclass
|
| 10 |
class GPTConfig:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
vocab_size: int = 256
|
| 12 |
block_size: int = 128
|
| 13 |
n_layer: int = 4
|
|
@@ -18,43 +41,79 @@ class GPTConfig:
|
|
| 18 |
|
| 19 |
|
| 20 |
class CausalSelfAttention(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def __init__(self, config):
|
| 22 |
super().__init__()
|
| 23 |
-
assert config.n_embd % config.n_head == 0
|
| 24 |
self.n_head = config.n_head
|
| 25 |
self.n_embd = config.n_embd
|
|
|
|
| 26 |
self.qkv = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
|
|
|
|
| 27 |
self.proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
|
| 28 |
self.attn_dropout = nn.Dropout(config.dropout)
|
| 29 |
self.resid_dropout = nn.Dropout(config.dropout)
|
| 30 |
|
| 31 |
def forward(self, x, kv=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
B, T, C = x.shape
|
|
|
|
| 33 |
q, k, v = self.qkv(x).split(self.n_embd, dim=2)
|
|
|
|
| 34 |
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
| 35 |
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
| 36 |
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
|
|
|
| 37 |
if kv is None:
|
|
|
|
| 38 |
y = F.scaled_dot_product_attention(
|
| 39 |
q, k, v,
|
| 40 |
dropout_p=self.attn_dropout.p if self.training else 0.0,
|
| 41 |
is_causal=True,
|
| 42 |
)
|
| 43 |
else:
|
|
|
|
| 44 |
pk, pv = kv
|
| 45 |
-
k = torch.cat((pk, k), dim=2)
|
| 46 |
v = torch.cat((pv, v), dim=2)
|
|
|
|
| 47 |
Tq, Tk = q.size(2), k.size(2)
|
| 48 |
mask = torch.ones(Tq, Tk, dtype=torch.bool, device=x.device).tril(diagonal=Tk - Tq)
|
|
|
|
| 49 |
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
|
| 50 |
att = att.masked_fill(~mask, float("-inf"))
|
| 51 |
att = F.softmax(att, dim=-1)
|
| 52 |
y = att @ v
|
|
|
|
|
|
|
| 53 |
y = y.transpose(1, 2).contiguous().view(B, T, C)
|
| 54 |
return self.resid_dropout(self.proj(y)), (k, v)
|
| 55 |
|
| 56 |
|
| 57 |
class MLP(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def __init__(self, config):
|
| 59 |
super().__init__()
|
| 60 |
self.fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
|
|
@@ -66,6 +125,14 @@ class MLP(nn.Module):
|
|
| 66 |
|
| 67 |
|
| 68 |
class Block(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def __init__(self, config):
|
| 70 |
super().__init__()
|
| 71 |
self.ln1 = nn.LayerNorm(config.n_embd, bias=config.bias)
|
|
@@ -74,13 +141,39 @@ class Block(nn.Module):
|
|
| 74 |
self.mlp = MLP(config)
|
| 75 |
|
| 76 |
def forward(self, x, kv=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
a, kv_new = self.attn(self.ln1(x), kv)
|
| 78 |
-
x = x + a
|
| 79 |
-
x = x + self.mlp(self.ln2(x))
|
| 80 |
return x, kv_new
|
| 81 |
|
| 82 |
|
| 83 |
def _sample_token(logits, temperature, top_k=None, top_p=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
logits = logits / max(temperature, 1e-8)
|
| 85 |
if top_k is not None:
|
| 86 |
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
|
|
@@ -97,6 +190,23 @@ def _sample_token(logits, temperature, top_k=None, top_p=None):
|
|
| 97 |
|
| 98 |
|
| 99 |
def _apply_repetition_penalty(logits, tokens, penalty, window):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
recent = set(tokens[0, -window:].tolist())
|
| 101 |
for t in recent:
|
| 102 |
val = logits[0, t]
|
|
@@ -105,19 +215,48 @@ def _apply_repetition_penalty(logits, tokens, penalty, window):
|
|
| 105 |
|
| 106 |
|
| 107 |
class GPT(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
def __init__(self, config):
|
| 109 |
super().__init__()
|
| 110 |
self.config = config
|
|
|
|
| 111 |
self.tok_emb = nn.Embedding(config.vocab_size, config.n_embd)
|
|
|
|
| 112 |
self.pos_emb = nn.Embedding(config.block_size, config.n_embd)
|
| 113 |
self.drop = nn.Dropout(config.dropout)
|
|
|
|
| 114 |
self.blocks = nn.ModuleList([Block(config) for _ in range(config.n_layer)])
|
|
|
|
| 115 |
self.ln_f = nn.LayerNorm(config.n_embd, bias=config.bias)
|
|
|
|
| 116 |
self.head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
|
|
|
|
| 117 |
self.head.weight = self.tok_emb.weight
|
|
|
|
| 118 |
self.apply(self._init_weights)
|
| 119 |
|
| 120 |
def _init_weights(self, module):
|
|
|
|
| 121 |
if isinstance(module, nn.Linear):
|
| 122 |
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 123 |
if module.bias is not None:
|
|
@@ -126,14 +265,31 @@ class GPT(nn.Module):
|
|
| 126 |
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 127 |
|
| 128 |
def forward(self, idx, targets=None, caches=None, return_caches=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
B, T = idx.shape
|
|
|
|
| 130 |
start = caches[0][0].size(2) if caches else 0
|
| 131 |
pos = torch.arange(start, start + T, device=idx.device)
|
|
|
|
| 132 |
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
|
|
|
|
| 133 |
new_caches = []
|
| 134 |
for i, block in enumerate(self.blocks):
|
| 135 |
x, c = block(x, caches[i] if caches is not None else None)
|
| 136 |
new_caches.append(c)
|
|
|
|
| 137 |
logits = self.head(self.ln_f(x))
|
| 138 |
loss = None
|
| 139 |
if targets is not None:
|
|
@@ -152,27 +308,52 @@ class GPT(nn.Module):
|
|
| 152 |
top_p=None,
|
| 153 |
repetition_penalty=1.0,
|
| 154 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
self.eval()
|
|
|
|
| 156 |
idx_cond = idx[:, -self.config.block_size:]
|
|
|
|
| 157 |
logits, _, caches = self(idx_cond, return_caches=True)
|
| 158 |
tokens = idx_cond
|
| 159 |
for i in range(max_new_tokens):
|
|
|
|
| 160 |
next_logits = logits[:, -1, :]
|
| 161 |
if repetition_penalty != 1.0:
|
| 162 |
next_logits = _apply_repetition_penalty(
|
| 163 |
next_logits.clone(), tokens, repetition_penalty, self.config.block_size
|
| 164 |
)
|
|
|
|
| 165 |
next_id = _sample_token(next_logits, temperature, top_k, top_p)
|
| 166 |
tokens = torch.cat((tokens, next_id), dim=1)
|
| 167 |
if i == max_new_tokens - 1:
|
| 168 |
break
|
|
|
|
| 169 |
if caches is not None and caches[0][0].size(2) >= self.config.block_size:
|
| 170 |
caches = None
|
| 171 |
idx_in = tokens[:, -self.config.block_size:]
|
| 172 |
else:
|
|
|
|
| 173 |
idx_in = next_id
|
| 174 |
logits, _, caches = self(idx_in, caches=caches, return_caches=True)
|
| 175 |
return tokens
|
| 176 |
|
| 177 |
def num_params(self):
|
|
|
|
| 178 |
return sum(p.numel() for p in self.parameters())
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Arsitektur GPT decoder-only transformer untuk Indigo.
|
| 3 |
+
|
| 4 |
+
Komponen utama:
|
| 5 |
+
- CausalSelfAttention: self-attention kausal dengan SDPA (Scaled Dot-Product Attention)
|
| 6 |
+
dan KV-cache untuk generasi cepat token-by-token.
|
| 7 |
+
- MLP: feed-forward network dengan aktivasi GELU.
|
| 8 |
+
- Block: satu blok transformer = LayerNorm + Attention + LayerNorm + MLP (pre-LN).
|
| 9 |
+
- GPT: model lengkap = token embedding + positional embedding + blok-blok + output head.
|
| 10 |
+
- Fungsi sampling: top-k, top-p, temperature, repetition penalty.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
import math
|
| 14 |
from dataclasses import dataclass
|
| 15 |
|
|
|
|
| 20 |
|
| 21 |
@dataclass
|
| 22 |
class GPTConfig:
|
| 23 |
+
"""Konfigurasi hyperparameter model GPT.
|
| 24 |
+
|
| 25 |
+
Attributes:
|
| 26 |
+
vocab_size: Jumlah token unik di vocabulary (termasuk semua byte untuk BPE).
|
| 27 |
+
block_size: Panjang konteks maksimum (jumlah token yang dilihat model sekaligus).
|
| 28 |
+
n_layer: Jumlah blok transformer bertumpuk.
|
| 29 |
+
n_head: Jumlah head per attention layer (harus habis membagi n_embd).
|
| 30 |
+
n_embd: Dimensi embedding / hidden size setiap layer.
|
| 31 |
+
dropout: Rate dropout (0.0 = tanpa dropout).
|
| 32 |
+
bias: Gunakan bias di Linear dan LayerNorm (GPT-2 asli pakai False).
|
| 33 |
+
"""
|
| 34 |
vocab_size: int = 256
|
| 35 |
block_size: int = 128
|
| 36 |
n_layer: int = 4
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
class CausalSelfAttention(nn.Module):
|
| 44 |
+
"""Multi-head causal self-attention dengan dua mode:
|
| 45 |
+
|
| 46 |
+
1. Forward pass biasa (kv=None): menggunakan SDPA dari PyTorch untuk
|
| 47 |
+
training yang efisien — mask kausal diterapkan otomatis.
|
| 48 |
+
2. Forward pass dengan KV-cache (kv=(k,v)): menggabungkan K/V baru dengan
|
| 49 |
+
K/V dari token sebelumnya, lalu menghitung attention secara manual
|
| 50 |
+
dengan mask kausal — digunakan saat generasi token-by-token.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
def __init__(self, config):
|
| 54 |
super().__init__()
|
| 55 |
+
assert config.n_embd % config.n_head == 0, "n_embd harus habis dibagi n_head"
|
| 56 |
self.n_head = config.n_head
|
| 57 |
self.n_embd = config.n_embd
|
| 58 |
+
# Q, K, V diproyeksikan sekaligus dalam satu Linear untuk efisiensi
|
| 59 |
self.qkv = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
|
| 60 |
+
# Proyeksi output setelah attention
|
| 61 |
self.proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
|
| 62 |
self.attn_dropout = nn.Dropout(config.dropout)
|
| 63 |
self.resid_dropout = nn.Dropout(config.dropout)
|
| 64 |
|
| 65 |
def forward(self, x, kv=None):
|
| 66 |
+
"""
|
| 67 |
+
Args:
|
| 68 |
+
x: Tensor (B, T, C) — batch, sequence length, channels.
|
| 69 |
+
kv: Tuple (K_prev, V_prev) dari token sebelumnya, atau None.
|
| 70 |
+
Saat None → training mode (SDPA). Saat ada → generasi (manual attention).
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
y: Tensor (B, T, C) — output setelah attention + dropout.
|
| 74 |
+
(k, v): Tuple K, V terbaru untuk disimpan ke cache.
|
| 75 |
+
"""
|
| 76 |
B, T, C = x.shape
|
| 77 |
+
# Proyeksi Q, K, V dari input
|
| 78 |
q, k, v = self.qkv(x).split(self.n_embd, dim=2)
|
| 79 |
+
# Reshape ke (B, n_head, T, head_dim) untuk multi-head attention
|
| 80 |
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
| 81 |
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
| 82 |
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
| 83 |
+
|
| 84 |
if kv is None:
|
| 85 |
+
# Mode training: SDPA dari PyTorch — mask kausal otomatis
|
| 86 |
y = F.scaled_dot_product_attention(
|
| 87 |
q, k, v,
|
| 88 |
dropout_p=self.attn_dropout.p if self.training else 0.0,
|
| 89 |
is_causal=True,
|
| 90 |
)
|
| 91 |
else:
|
| 92 |
+
# Mode generasi: gabungkan K/V baru dengan cache sebelumnya
|
| 93 |
pk, pv = kv
|
| 94 |
+
k = torch.cat((pk, k), dim=2) # (B, n_head, T_prev+T, head_dim)
|
| 95 |
v = torch.cat((pv, v), dim=2)
|
| 96 |
+
# Buat mask kausal manual: query baru hanya boleh melihat key yang valid
|
| 97 |
Tq, Tk = q.size(2), k.size(2)
|
| 98 |
mask = torch.ones(Tq, Tk, dtype=torch.bool, device=x.device).tril(diagonal=Tk - Tq)
|
| 99 |
+
# Hitung attention score secara manual
|
| 100 |
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
|
| 101 |
att = att.masked_fill(~mask, float("-inf"))
|
| 102 |
att = F.softmax(att, dim=-1)
|
| 103 |
y = att @ v
|
| 104 |
+
|
| 105 |
+
# Gabungkan kembali head-head: (B, n_head, T, head_dim) → (B, T, C)
|
| 106 |
y = y.transpose(1, 2).contiguous().view(B, T, C)
|
| 107 |
return self.resid_dropout(self.proj(y)), (k, v)
|
| 108 |
|
| 109 |
|
| 110 |
class MLP(nn.Module):
|
| 111 |
+
"""Feed-forward network per blok transformer.
|
| 112 |
+
|
| 113 |
+
Arsitektur: Linear(C → 4C) → GELU → Linear(4C → C) → Dropout.
|
| 114 |
+
Rasio 4x adalah standar GPT-2 (expansion factor).
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
def __init__(self, config):
|
| 118 |
super().__init__()
|
| 119 |
self.fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
class Block(nn.Module):
|
| 128 |
+
"""Satu blok transformer (pre-LayerNorm).
|
| 129 |
+
|
| 130 |
+
Arsitektur:
|
| 131 |
+
x → LayerNorm → Attention → (+ residual) → LayerNorm → MLP → (+ residual) → output
|
| 132 |
+
|
| 133 |
+
Pre-LN (LayerNorm di depan) lebih stabil untuk training dari nol dibanding Post-LN.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
def __init__(self, config):
|
| 137 |
super().__init__()
|
| 138 |
self.ln1 = nn.LayerNorm(config.n_embd, bias=config.bias)
|
|
|
|
| 141 |
self.mlp = MLP(config)
|
| 142 |
|
| 143 |
def forward(self, x, kv=None):
|
| 144 |
+
"""
|
| 145 |
+
Args:
|
| 146 |
+
x: Input tensor (B, T, C).
|
| 147 |
+
kv: KV-cache dari blok sebelumnya (opsional).
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
x: Output tensor (B, T, C).
|
| 151 |
+
kv_new: (K, V) baru untuk disimpan ke cache.
|
| 152 |
+
"""
|
| 153 |
a, kv_new = self.attn(self.ln1(x), kv)
|
| 154 |
+
x = x + a # residual connection (attention)
|
| 155 |
+
x = x + self.mlp(self.ln2(x)) # residual connection (MLP)
|
| 156 |
return x, kv_new
|
| 157 |
|
| 158 |
|
| 159 |
def _sample_token(logits, temperature, top_k=None, top_p=None):
|
| 160 |
+
"""Sampling satu token dari distribusi logit.
|
| 161 |
+
|
| 162 |
+
Pipeline sampling:
|
| 163 |
+
1. Scaling temperature: logits / T (T kecil → lebih deterministik, T besar → lebih random)
|
| 164 |
+
2. Top-k: buang semua kandidat kecuali k teratas
|
| 165 |
+
3. Top-p (nucleus): buang kandidat yang kumulatif probabilitasnya > p
|
| 166 |
+
4. Softmax → multinomial sampling
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
logits: Tensor (B, vocab_size) — skor sebelum softmax.
|
| 170 |
+
temperature: Float — skala randomness (0.0 ≈ greedy, 1.0 ≈ standar, >1.0 ≈ random).
|
| 171 |
+
top_k: Int atau None — batasi ke k token teratas.
|
| 172 |
+
top_p: Float atau None — batasi kumulatif probabilitas sampai p.
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
Token sampled: Tensor (B, 1).
|
| 176 |
+
"""
|
| 177 |
logits = logits / max(temperature, 1e-8)
|
| 178 |
if top_k is not None:
|
| 179 |
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
|
|
|
|
| 190 |
|
| 191 |
|
| 192 |
def _apply_repetition_penalty(logits, tokens, penalty, window):
|
| 193 |
+
"""Terapkan repetition penalty pada token yang baru saja muncul.
|
| 194 |
+
|
| 195 |
+
Token yang sudah muncul dalam `window` token terakhir akan didenda:
|
| 196 |
+
- Jika logit positif → dibagi penalty (menjadi lebih kecil)
|
| 197 |
+
- Jika logit negatif → dikali penalty (menjadi lebih negatif)
|
| 198 |
+
|
| 199 |
+
Efek: mengurangi kemungkinan model mengulang kata/frasa yang sama.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
logits: Tensor (1, vocab_size) — logit kandidat.
|
| 203 |
+
tokens: Tensor (1, T) — token yang sudah dihasilkan.
|
| 204 |
+
penalty: Float > 1.0 — semakin besar, semakin keras penaltinya.
|
| 205 |
+
window: Int — berapa token terakhir yang diperiksa.
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
logits yang sudah diubah in-place.
|
| 209 |
+
"""
|
| 210 |
recent = set(tokens[0, -window:].tolist())
|
| 211 |
for t in recent:
|
| 212 |
val = logits[0, t]
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
class GPT(nn.Module):
|
| 218 |
+
"""Model GPT decoder-only transformer.
|
| 219 |
+
|
| 220 |
+
Komponen:
|
| 221 |
+
- tok_emb: Embedding token (vocab_size → n_embd)
|
| 222 |
+
- pos_emb: Embedding posisi (block_size → n_embd) — learnable, bukan sinusoidal
|
| 223 |
+
- drop: Dropout gabungan (token + posisi)
|
| 224 |
+
- blocks: N blok transformer (pre-LN)
|
| 225 |
+
- ln_f: LayerNorm final
|
| 226 |
+
- head: Linear(n_embd → vocab_size) — bobot ditied dengan tok_emb (weight tying)
|
| 227 |
+
|
| 228 |
+
Forward pass:
|
| 229 |
+
1. Embed token + posisi → x
|
| 230 |
+
2. x melewati setiap blok (attention + MLP) → logits
|
| 231 |
+
3. Hitung loss (cross-entropy) jika targets tersedia
|
| 232 |
+
|
| 233 |
+
Generasi (generate):
|
| 234 |
+
1. Forward pass pertama: proses seluruh prompt sekaligus, simpan KV-cache
|
| 235 |
+
2. Forward pass selanjutnya: proses hanya 1 token baru, gunakan KV-cache
|
| 236 |
+
3. Cache di-reset jika sudah mencapai block_size (context window penuh)
|
| 237 |
+
"""
|
| 238 |
+
|
| 239 |
def __init__(self, config):
|
| 240 |
super().__init__()
|
| 241 |
self.config = config
|
| 242 |
+
# Token embedding: mengubah indeks token menjadi vektor
|
| 243 |
self.tok_emb = nn.Embedding(config.vocab_size, config.n_embd)
|
| 244 |
+
# Positional embedding: memberi tahu model posisi setiap token dalam sequence
|
| 245 |
self.pos_emb = nn.Embedding(config.block_size, config.n_embd)
|
| 246 |
self.drop = nn.Dropout(config.dropout)
|
| 247 |
+
# Blok-blok transformer bertumpuk
|
| 248 |
self.blocks = nn.ModuleList([Block(config) for _ in range(config.n_layer)])
|
| 249 |
+
# LayerNorm final sebelum output head
|
| 250 |
self.ln_f = nn.LayerNorm(config.n_embd, bias=config.bias)
|
| 251 |
+
# Output head: proyeksi dari hidden state ke vocabulary
|
| 252 |
self.head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
|
| 253 |
+
# Weight tying: bobot output head = bobot token embedding (hemat parameter)
|
| 254 |
self.head.weight = self.tok_emb.weight
|
| 255 |
+
# Inisialisasi bobot dengan distribusi normal kecil
|
| 256 |
self.apply(self._init_weights)
|
| 257 |
|
| 258 |
def _init_weights(self, module):
|
| 259 |
+
"""Inisialisasi bobot: Normal(0, 0.02) untuk Linear dan Embedding."""
|
| 260 |
if isinstance(module, nn.Linear):
|
| 261 |
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 262 |
if module.bias is not None:
|
|
|
|
| 265 |
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 266 |
|
| 267 |
def forward(self, idx, targets=None, caches=None, return_caches=False):
|
| 268 |
+
"""
|
| 269 |
+
Forward pass.
|
| 270 |
+
|
| 271 |
+
Args:
|
| 272 |
+
idx: Tensor (B, T) — indeks token input.
|
| 273 |
+
targets: Tensor (B, T) atau None — indeks token target (untuk hitung loss).
|
| 274 |
+
caches: List of (K, V) per blok atau None — KV-cache dari token sebelumnya.
|
| 275 |
+
return_caches: bool — jika True, return (logits, loss, new_caches).
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
logits: Tensor (B, T, vocab_size)
|
| 279 |
+
loss: Float atau None (None jika targets tidak diberikan)
|
| 280 |
+
"""
|
| 281 |
B, T = idx.shape
|
| 282 |
+
# Posisi dimulai dari panjang cache (offset untuk generasi autoregressive)
|
| 283 |
start = caches[0][0].size(2) if caches else 0
|
| 284 |
pos = torch.arange(start, start + T, device=idx.device)
|
| 285 |
+
# Embedding = token embedding + positional embedding, lalu dropout
|
| 286 |
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
|
| 287 |
+
# Forward pass melalui setiap blok, kumpulkan KV-cache baru
|
| 288 |
new_caches = []
|
| 289 |
for i, block in enumerate(self.blocks):
|
| 290 |
x, c = block(x, caches[i] if caches is not None else None)
|
| 291 |
new_caches.append(c)
|
| 292 |
+
# LayerNorm final → output logits
|
| 293 |
logits = self.head(self.ln_f(x))
|
| 294 |
loss = None
|
| 295 |
if targets is not None:
|
|
|
|
| 308 |
top_p=None,
|
| 309 |
repetition_penalty=1.0,
|
| 310 |
):
|
| 311 |
+
"""Generate token baru secara autoregressive menggunakan KV-cache.
|
| 312 |
+
|
| 313 |
+
Proses:
|
| 314 |
+
1. Forward pass awal: proses seluruh idx (prompt) sekaligus → simpan KV-cache.
|
| 315 |
+
2. Loop max_new_tokens kali: ambil logit terakhir → sample → tambah ke sequence.
|
| 316 |
+
3. Jika cache penuh (>= block_size), reset cache dan proses ulang block_size terakhir.
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
idx: Tensor (B, T) — token prompt awal.
|
| 320 |
+
max_new_tokens: Int — jumlah token baru yang akan dihasilkan.
|
| 321 |
+
temperature: Float — skala randomness sampling.
|
| 322 |
+
top_k: Int atau None — batasi kandidat ke k token teratas.
|
| 323 |
+
top_p: Float atau None — batasi kandidat berdasarkan kumulatif probabilitas.
|
| 324 |
+
repetition_penalty: Float — penalti pengulangan (>1.0 = aktif, 1.0 = nonaktif).
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Tensor (B, T + max_new_tokens) — prompt + token hasil generate.
|
| 328 |
+
"""
|
| 329 |
self.eval()
|
| 330 |
+
# Potong prompt jika lebih panjang dari block_size
|
| 331 |
idx_cond = idx[:, -self.config.block_size:]
|
| 332 |
+
# Forward pass pertama: proses seluruh prompt, simpan KV-cache
|
| 333 |
logits, _, caches = self(idx_cond, return_caches=True)
|
| 334 |
tokens = idx_cond
|
| 335 |
for i in range(max_new_tokens):
|
| 336 |
+
# Ambil logit untuk prediksi token berikutnya (posisi terakhir)
|
| 337 |
next_logits = logits[:, -1, :]
|
| 338 |
if repetition_penalty != 1.0:
|
| 339 |
next_logits = _apply_repetition_penalty(
|
| 340 |
next_logits.clone(), tokens, repetition_penalty, self.config.block_size
|
| 341 |
)
|
| 342 |
+
# Sample satu token berdasarkan logit + temperature + top-k + top-p
|
| 343 |
next_id = _sample_token(next_logits, temperature, top_k, top_p)
|
| 344 |
tokens = torch.cat((tokens, next_id), dim=1)
|
| 345 |
if i == max_new_tokens - 1:
|
| 346 |
break
|
| 347 |
+
# Jika cache penuh, reset dan proses ulang block_size terakhir
|
| 348 |
if caches is not None and caches[0][0].size(2) >= self.config.block_size:
|
| 349 |
caches = None
|
| 350 |
idx_in = tokens[:, -self.config.block_size:]
|
| 351 |
else:
|
| 352 |
+
# Forward pass untuk 1 token baru saja, gunakan cache sebelumnya
|
| 353 |
idx_in = next_id
|
| 354 |
logits, _, caches = self(idx_in, caches=caches, return_caches=True)
|
| 355 |
return tokens
|
| 356 |
|
| 357 |
def num_params(self):
|
| 358 |
+
"""Hitung total parameter model (termasuk yang tidak di-train seperti embedding tied)."""
|
| 359 |
return sum(p.numel() for p in self.parameters())
|
indigo/tokenizer.py
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
|
| 4 |
class CharTokenizer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
def __init__(self, vocab=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
if vocab is None:
|
| 7 |
self.itos = []
|
| 8 |
else:
|
|
@@ -11,23 +30,65 @@ class CharTokenizer:
|
|
| 11 |
|
| 12 |
@classmethod
|
| 13 |
def from_text(cls, text):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return cls(sorted(set(text)))
|
| 15 |
|
| 16 |
@property
|
| 17 |
def vocab_size(self):
|
|
|
|
| 18 |
return len(self.itos)
|
| 19 |
|
| 20 |
def encode(self, text):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return [self.stoi[ch] for ch in text if ch in self.stoi]
|
| 22 |
|
| 23 |
def decode(self, ids):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return "".join(self.itos[i] for i in ids)
|
| 25 |
|
| 26 |
def save(self, path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
with open(path, "w", encoding="utf-8") as f:
|
| 28 |
json.dump(self.itos, f, ensure_ascii=False)
|
| 29 |
|
| 30 |
@classmethod
|
| 31 |
def load(cls, path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
with open(path, encoding="utf-8") as f:
|
| 33 |
return cls(json.load(f))
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tokenizer karakter (char-level) untuk Indigo.
|
| 3 |
+
|
| 4 |
+
Setiap karakter unik dalam teks training mendapatkan satu indeks token.
|
| 5 |
+
Sederhana dan cepat, tapi kurang efisien untuk teks panjang (tiap karakter = 1 token).
|
| 6 |
+
Cocok untuk dataset kecil atau percobaan cepat.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
import json
|
| 10 |
|
| 11 |
|
| 12 |
class CharTokenizer:
|
| 13 |
+
"""Tokenizer level karakter: mapping satu-satu antara karakter dan indeks.
|
| 14 |
+
|
| 15 |
+
Atribut:
|
| 16 |
+
itos: List of str — indeks → karakter (integer-to-string).
|
| 17 |
+
stoi: Dict {karakter: indeks} — lookup cepat encode (string-to-integer).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
def __init__(self, vocab=None):
|
| 21 |
+
"""
|
| 22 |
+
Args:
|
| 23 |
+
vocab: Daftar karakter (list of str) atau None (tokenizer kosong).
|
| 24 |
+
"""
|
| 25 |
if vocab is None:
|
| 26 |
self.itos = []
|
| 27 |
else:
|
|
|
|
| 30 |
|
| 31 |
@classmethod
|
| 32 |
def from_text(cls, text):
|
| 33 |
+
"""Bangun tokenizer dari teks: setiap karakter unik jadi satu token.
|
| 34 |
+
|
| 35 |
+
Karakter diurutkan agar mapping deterministik (tidak bergantung urutan kemunculan).
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
text: Teks training (string).
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
CharTokenizer yang sudah terlatih.
|
| 42 |
+
"""
|
| 43 |
return cls(sorted(set(text)))
|
| 44 |
|
| 45 |
@property
|
| 46 |
def vocab_size(self):
|
| 47 |
+
"""Jumlah total karakter unik di vocabulary."""
|
| 48 |
return len(self.itos)
|
| 49 |
|
| 50 |
def encode(self, text):
|
| 51 |
+
"""Enkode teks menjadi daftar indeks token.
|
| 52 |
+
|
| 53 |
+
Karakter yang tidak ada di vocabulary diabaikan (tidak di-encode).
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
text: String teks.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Daftar integer (token IDs).
|
| 60 |
+
"""
|
| 61 |
return [self.stoi[ch] for ch in text if ch in self.stoi]
|
| 62 |
|
| 63 |
def decode(self, ids):
|
| 64 |
+
"""Dekode daftar indeks token kembali menjadi teks.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
ids: Daftar integer (token IDs).
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
String teks.
|
| 71 |
+
"""
|
| 72 |
return "".join(self.itos[i] for i in ids)
|
| 73 |
|
| 74 |
def save(self, path):
|
| 75 |
+
"""Simpan vocabulary (daftar karakter) ke file JSON.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
path: Path output file JSON.
|
| 79 |
+
"""
|
| 80 |
with open(path, "w", encoding="utf-8") as f:
|
| 81 |
json.dump(self.itos, f, ensure_ascii=False)
|
| 82 |
|
| 83 |
@classmethod
|
| 84 |
def load(cls, path):
|
| 85 |
+
"""Muat vocabulary dari file JSON.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
path: Path ke file JSON berisi daftar karakter.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
CharTokenizer yang sudah dipulihkan.
|
| 92 |
+
"""
|
| 93 |
with open(path, encoding="utf-8") as f:
|
| 94 |
return cls(json.load(f))
|
pipeline.py
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import argparse
|
| 2 |
import datetime
|
| 3 |
-
import glob
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import shutil
|
|
@@ -15,6 +33,25 @@ import train
|
|
| 15 |
|
| 16 |
|
| 17 |
def preprocess_alpaca_json(data_dir, patterns=("*.json",)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
converted = 0
|
| 19 |
for pat in patterns:
|
| 20 |
for jf in Path(data_dir).rglob(pat):
|
|
@@ -27,6 +64,7 @@ def preprocess_alpaca_json(data_dir, patterns=("*.json",)):
|
|
| 27 |
continue
|
| 28 |
if not isinstance(data, list) or not data:
|
| 29 |
continue
|
|
|
|
| 30 |
keys = set(data[0].keys()) if isinstance(data[0], dict) else set()
|
| 31 |
if not {"instruction", "output"}.issubset(keys):
|
| 32 |
continue
|
|
@@ -41,6 +79,7 @@ def preprocess_alpaca_json(data_dir, patterns=("*.json",)):
|
|
| 41 |
lines.append(f"### {q}\n\n{out}\n\n")
|
| 42 |
if not lines:
|
| 43 |
continue
|
|
|
|
| 44 |
txt_path = jf.with_suffix(".txt")
|
| 45 |
txt_path.write_text("".join(lines), encoding="utf-8")
|
| 46 |
print(f"[pipeline] alpaca: {jf.name} -> {txt_path.name} ({len(lines)} QA)")
|
|
@@ -49,6 +88,7 @@ def preprocess_alpaca_json(data_dir, patterns=("*.json",)):
|
|
| 49 |
|
| 50 |
|
| 51 |
def git_short():
|
|
|
|
| 52 |
try:
|
| 53 |
return subprocess.check_output(
|
| 54 |
["git", "rev-parse", "--short", "HEAD"], cwd=str(ROOT), text=True
|
|
@@ -58,6 +98,19 @@ def git_short():
|
|
| 58 |
|
| 59 |
|
| 60 |
def pull_hf(repo_id, dest, patterns):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
from huggingface_hub import snapshot_download
|
| 62 |
|
| 63 |
path = snapshot_download(
|
|
@@ -79,47 +132,87 @@ def main():
|
|
| 79 |
ap = argparse.ArgumentParser(
|
| 80 |
description="Pipeline latih Indigo: kumpul data -> tokenizer -> training -> manifest -> finalize/push"
|
| 81 |
)
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
ap.add_argument("--
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
ap.add_argument("--format-qa", action="store_true",
|
| 87 |
-
help="auto-convert Alpaca JSON (instruction/output)
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
ap.add_argument("--finalize", action="store_true",
|
| 91 |
help="promosikan checkpoint terbaik run ini ke folder out/ kanonik")
|
| 92 |
-
ap.add_argument("--push", action="store_true",
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
ap.add_argument("--guard", default=None,
|
| 95 |
help="file kamus kata (satu kata per baris); tambahkan metrik rasio ejaan ke manifest")
|
| 96 |
-
ap.add_argument("--guard-max-new", type=int, default=120
|
| 97 |
-
|
| 98 |
-
ap.add_argument("--guard-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
t = ap.add_argument_group("hyperparameter training (diteruskan ke train.py)")
|
| 100 |
-
t.add_argument("--steps", type=int, default=2000
|
| 101 |
-
|
| 102 |
-
t.add_argument("--
|
| 103 |
-
|
| 104 |
-
t.add_argument("--
|
| 105 |
-
|
| 106 |
-
t.add_argument("--
|
| 107 |
-
|
| 108 |
-
t.add_argument("--
|
| 109 |
-
|
| 110 |
-
t.add_argument("--
|
| 111 |
-
|
| 112 |
-
t.add_argument("--
|
| 113 |
-
|
| 114 |
-
t.add_argument("--
|
| 115 |
-
|
| 116 |
-
t.add_argument("--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
args = ap.parse_args()
|
| 118 |
|
|
|
|
| 119 |
run_dir = ROOT / args.runs / args.tag
|
| 120 |
data_dir = run_dir / "data"
|
| 121 |
data_dir.mkdir(parents=True, exist_ok=True)
|
| 122 |
|
|
|
|
| 123 |
sources = []
|
| 124 |
for p in args.data:
|
| 125 |
src = Path(p)
|
|
@@ -131,13 +224,15 @@ def main():
|
|
| 131 |
shutil.copy2(src, data_dir / src.name)
|
| 132 |
sources.append(str(src))
|
| 133 |
|
|
|
|
| 134 |
if args.hf_dataset:
|
| 135 |
patterns = list(args.hf_patterns)
|
| 136 |
if args.format_qa and "*.json" not in patterns:
|
| 137 |
-
patterns.append("*.json")
|
| 138 |
pull_hf(args.hf_dataset, data_dir / "_hf", patterns)
|
| 139 |
sources.append(f"hf:{args.hf_dataset}")
|
| 140 |
|
|
|
|
| 141 |
if args.format_qa:
|
| 142 |
n = preprocess_alpaca_json(data_dir)
|
| 143 |
if n:
|
|
@@ -145,6 +240,7 @@ def main():
|
|
| 145 |
else:
|
| 146 |
print("[pipeline] peringatan: tidak ada file Alpaca JSON ditemukan")
|
| 147 |
|
|
|
|
| 148 |
out_dir = run_dir / "ckpt"
|
| 149 |
argv = [
|
| 150 |
"--data", str(data_dir),
|
|
@@ -174,6 +270,7 @@ def main():
|
|
| 174 |
print(f"[pipeline] run='{args.tag}' | sumber={sources or ['<kosong>' ]}")
|
| 175 |
stats = train.main(argv) or {}
|
| 176 |
|
|
|
|
| 177 |
if args.guard:
|
| 178 |
import torch
|
| 179 |
|
|
@@ -181,6 +278,7 @@ def main():
|
|
| 181 |
from indigo.model import GPT, GPTConfig
|
| 182 |
from safetensors.torch import load_file
|
| 183 |
|
|
|
|
| 184 |
probe_path = out_dir / "indigo_best.safetensors"
|
| 185 |
if not probe_path.exists():
|
| 186 |
probe_path = out_dir / "indigo.safetensors"
|
|
@@ -188,6 +286,7 @@ def main():
|
|
| 188 |
p_def, s_def = ROOT / "data" / "prefiks.txt", ROOT / "data" / "sufiks.txt"
|
| 189 |
|
| 190 |
def muat_afiks(flag, default_path):
|
|
|
|
| 191 |
if flag:
|
| 192 |
return load_wordlist(flag) if Path(flag).exists() else None
|
| 193 |
return load_wordlist(str(default_path)) if default_path.exists() else None
|
|
@@ -199,6 +298,7 @@ def main():
|
|
| 199 |
mdl = GPT(GPTConfig(**meta["config"]))
|
| 200 |
mdl.load_state_dict(load_file(str(probe_path)), strict=False)
|
| 201 |
mdl.eval()
|
|
|
|
| 202 |
ids = tok.encode("\n") or [0]
|
| 203 |
idx = torch.tensor([ids], dtype=torch.long)
|
| 204 |
with torch.no_grad():
|
|
@@ -206,6 +306,8 @@ def main():
|
|
| 206 |
text = tok.decode(out[0].tolist())
|
| 207 |
stats["kamus_ratio"] = round(word_known_ratio(text, wordset, prefiks, sufiks), 4)
|
| 208 |
print(f"[pipeline] kamus_ratio={stats['kamus_ratio']:.2%}")
|
|
|
|
|
|
|
| 209 |
stats["pipeline"] = {
|
| 210 |
"tag": args.tag,
|
| 211 |
"timestamp": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
@@ -220,6 +322,7 @@ def main():
|
|
| 220 |
json.dump(stats, f, ensure_ascii=False, indent=2)
|
| 221 |
print(f"[pipeline] manifest -> {manifest}")
|
| 222 |
|
|
|
|
| 223 |
best = out_dir / "indigo_best.safetensors"
|
| 224 |
if args.finalize:
|
| 225 |
has_best = (out_dir / "indigo_best.safetensors").exists()
|
|
@@ -252,6 +355,7 @@ def main():
|
|
| 252 |
)
|
| 253 |
print("[pipeline] silsilah -> out/run_info.json")
|
| 254 |
|
|
|
|
| 255 |
if args.push:
|
| 256 |
from huggingface_hub import HfApi
|
| 257 |
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline end-to-end untuk training Indigo.
|
| 3 |
+
|
| 4 |
+
Alur kerja:
|
| 5 |
+
1. Kumpulkan data: copy file lokal + tarik dataset dari Hugging Face
|
| 6 |
+
2. Konversi JSON Alpaca ke .txt (opsional, --format-qa)
|
| 7 |
+
3. Jalankan training (train.py) dengan semua hyperparameter
|
| 8 |
+
4. Evaluasi guard kamus (opsional, --guard)
|
| 9 |
+
5. Simpan manifest.json (statistik + metadata run)
|
| 10 |
+
6. Finalize: promosikan checkpoint terbaik ke out/ (opsional, --finalize)
|
| 11 |
+
7. Push ke Hugging Face Hub (opsional, --push)
|
| 12 |
+
|
| 13 |
+
Cara pakai:
|
| 14 |
+
python pipeline.py --tag run01 --data data/sample.txt --steps 2000
|
| 15 |
+
python pipeline.py --tag run02 --hf-dataset adyoi/indigo --steps 900 --device cpu
|
| 16 |
+
python pipeline.py --tag run03 --data data/ --steps 2000 --finalize --push
|
| 17 |
+
python pipeline.py --tag qa01 --hf-dataset rohanrdy/CS-Theory-QA-Dataset --format-qa --steps 3000
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
import argparse
|
| 21 |
import datetime
|
|
|
|
| 22 |
import json
|
| 23 |
import os
|
| 24 |
import shutil
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def preprocess_alpaca_json(data_dir, patterns=("*.json",)):
|
| 36 |
+
"""Konversi file JSON Alpaca format ke .txt flat.
|
| 37 |
+
|
| 38 |
+
Format Alpaca: [{"instruction": "...", "input": "...", "output": "..."}, ...]
|
| 39 |
+
Format output:
|
| 40 |
+
### instruction
|
| 41 |
+
(input jika ada)
|
| 42 |
+
|
| 43 |
+
output
|
| 44 |
+
|
| 45 |
+
Fungsi ini auto-detect: hanya mengkonversi file JSON yang punya field "instruction" dan "output".
|
| 46 |
+
File yang sudah ada .txt-nya tidak ditimpa.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
data_dir: Direktori yang berisi file JSON (akan dicari rekursif).
|
| 50 |
+
patterns: Pola file JSON (default: "*.json").
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Int — jumlah file JSON yang berhasil dikonversi.
|
| 54 |
+
"""
|
| 55 |
converted = 0
|
| 56 |
for pat in patterns:
|
| 57 |
for jf in Path(data_dir).rglob(pat):
|
|
|
|
| 64 |
continue
|
| 65 |
if not isinstance(data, list) or not data:
|
| 66 |
continue
|
| 67 |
+
# Cek apakah ini format Alpaca (punya field instruction + output)
|
| 68 |
keys = set(data[0].keys()) if isinstance(data[0], dict) else set()
|
| 69 |
if not {"instruction", "output"}.issubset(keys):
|
| 70 |
continue
|
|
|
|
| 79 |
lines.append(f"### {q}\n\n{out}\n\n")
|
| 80 |
if not lines:
|
| 81 |
continue
|
| 82 |
+
# Simpan .txt di sebelah file JSON asli
|
| 83 |
txt_path = jf.with_suffix(".txt")
|
| 84 |
txt_path.write_text("".join(lines), encoding="utf-8")
|
| 85 |
print(f"[pipeline] alpaca: {jf.name} -> {txt_path.name} ({len(lines)} QA)")
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def git_short():
|
| 91 |
+
"""Ambil hash commit git pendek (7 karakter) atau "nogit" jika bukan repo git."""
|
| 92 |
try:
|
| 93 |
return subprocess.check_output(
|
| 94 |
["git", "rev-parse", "--short", "HEAD"], cwd=str(ROOT), text=True
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def pull_hf(repo_id, dest, patterns):
|
| 101 |
+
"""Tarik dataset dari Hugging Face Hub ke direktori lokal.
|
| 102 |
+
|
| 103 |
+
Menggunakan snapshot_download dengan allow_patterns untuk mengunduh
|
| 104 |
+
hanya file yang sesuai pola (mis. *.txt, *.md, *.json).
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
repo_id: ID repo HF (mis. "adyoi/indigo" atau "rohanrdy/CS-Theory-QA-Dataset").
|
| 108 |
+
dest: Direktori tujuan download.
|
| 109 |
+
patterns: Pola file yang akan diunduh.
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
Str — path ke direktori lokal yang berisi file yang diunduh.
|
| 113 |
+
"""
|
| 114 |
from huggingface_hub import snapshot_download
|
| 115 |
|
| 116 |
path = snapshot_download(
|
|
|
|
| 132 |
ap = argparse.ArgumentParser(
|
| 133 |
description="Pipeline latih Indigo: kumpul data -> tokenizer -> training -> manifest -> finalize/push"
|
| 134 |
)
|
| 135 |
+
|
| 136 |
+
# --- Identitas Run ---
|
| 137 |
+
ap.add_argument("--tag", required=True,
|
| 138 |
+
help="nama run; semua artefak disimpan di runs/<tag>/")
|
| 139 |
+
|
| 140 |
+
# --- Sumber Data ---
|
| 141 |
+
ap.add_argument("--data", nargs="*", default=[],
|
| 142 |
+
help="file/folder teks lokal tambahan (banyak, spasi-separated)")
|
| 143 |
+
ap.add_argument("--hf-dataset", default=None,
|
| 144 |
+
help="repo dataset HF untuk menarik file teks (mis. adyoi/indigo)")
|
| 145 |
+
ap.add_argument("--hf-patterns", nargs="*", default=["*.txt", "*.md"],
|
| 146 |
+
help="pola file yang diambil dari HF (default: *.txt *.md)")
|
| 147 |
ap.add_argument("--format-qa", action="store_true",
|
| 148 |
+
help="auto-convert Alpaca JSON (instruction/output) ke .txt sebelum training")
|
| 149 |
+
|
| 150 |
+
# --- Output & Finalize ---
|
| 151 |
+
ap.add_argument("--runs", default="runs",
|
| 152 |
+
help="folder root untuk semua run (default: runs)")
|
| 153 |
+
ap.add_argument("--device", default=None,
|
| 154 |
+
help="device untuk training (diteruskan ke train.py, default: auto)")
|
| 155 |
ap.add_argument("--finalize", action="store_true",
|
| 156 |
help="promosikan checkpoint terbaik run ini ke folder out/ kanonik")
|
| 157 |
+
ap.add_argument("--push", action="store_true",
|
| 158 |
+
help="upload checkpoint terbaik ke repo HF")
|
| 159 |
+
ap.add_argument("--repo", default="adyoi/indigo",
|
| 160 |
+
help="repo HF tujuan upload (default: adyoi/indigo)")
|
| 161 |
+
|
| 162 |
+
# --- Guard Kamus ---
|
| 163 |
ap.add_argument("--guard", default=None,
|
| 164 |
help="file kamus kata (satu kata per baris); tambahkan metrik rasio ejaan ke manifest")
|
| 165 |
+
ap.add_argument("--guard-max-new", type=int, default=120,
|
| 166 |
+
help="jumlah token generate untuk evaluasi guard (default: 120)")
|
| 167 |
+
ap.add_argument("--guard-prefiks", default=None,
|
| 168 |
+
help="file prefiks Indonesia (default: data/prefiks.txt bila ada)")
|
| 169 |
+
ap.add_argument("--guard-sufiks", default=None,
|
| 170 |
+
help="file sufiks Indonesia (default: data/sufiks.txt bila ada)")
|
| 171 |
+
|
| 172 |
+
# --- Hyperparameter Training (diteruskan ke train.py) ---
|
| 173 |
t = ap.add_argument_group("hyperparameter training (diteruskan ke train.py)")
|
| 174 |
+
t.add_argument("--steps", type=int, default=2000,
|
| 175 |
+
help="jumlah langkah training (default: 2000)")
|
| 176 |
+
t.add_argument("--batch-size", type=int, default=32,
|
| 177 |
+
help="batch size (default: 32)")
|
| 178 |
+
t.add_argument("--block-size", type=int, default=128,
|
| 179 |
+
help="panjang konteks (default: 128)")
|
| 180 |
+
t.add_argument("--n-layer", type=int, default=4,
|
| 181 |
+
help="jumlah layer transformer (default: 4)")
|
| 182 |
+
t.add_argument("--n-head", type=int, default=4,
|
| 183 |
+
help="jumlah head attention (default: 4)")
|
| 184 |
+
t.add_argument("--n-embd", type=int, default=128,
|
| 185 |
+
help="dimensi embedding (default: 128)")
|
| 186 |
+
t.add_argument("--dropout", type=float, default=0.1,
|
| 187 |
+
help="dropout rate (default: 0.1)")
|
| 188 |
+
t.add_argument("--lr", type=float, default=3e-4,
|
| 189 |
+
help="learning rate (default: 3e-4)")
|
| 190 |
+
t.add_argument("--warmup", type=int, default=100,
|
| 191 |
+
help="langkah warmup (default: 100)")
|
| 192 |
+
t.add_argument("--weight-decay", type=float, default=0.1,
|
| 193 |
+
help="weight decay (default: 0.1)")
|
| 194 |
+
t.add_argument("--eval-interval", type=int, default=200,
|
| 195 |
+
help="evaluasi setiap N langkah (default: 200)")
|
| 196 |
+
t.add_argument("--eval-iters", type=int, default=20,
|
| 197 |
+
help="jumlah batch evaluasi (default: 20)")
|
| 198 |
+
t.add_argument("--seed", type=int, default=1337,
|
| 199 |
+
help="seed random (default: 1337)")
|
| 200 |
+
t.add_argument("--init-from", default=None,
|
| 201 |
+
help="checkpoint untuk resume training")
|
| 202 |
+
t.add_argument("--tokenizer", default="char", choices=["char", "bpe"],
|
| 203 |
+
help="jenis tokenizer (default: char)")
|
| 204 |
+
t.add_argument("--vocab-size", type=int, default=512,
|
| 205 |
+
help="vocab size untuk BPE (default: 512)")
|
| 206 |
+
t.add_argument("--val-fraction", type=float, default=0.1,
|
| 207 |
+
help="proporsi file validasi (default: 0.1)")
|
| 208 |
args = ap.parse_args()
|
| 209 |
|
| 210 |
+
# --- Siapkan direktori run ---
|
| 211 |
run_dir = ROOT / args.runs / args.tag
|
| 212 |
data_dir = run_dir / "data"
|
| 213 |
data_dir.mkdir(parents=True, exist_ok=True)
|
| 214 |
|
| 215 |
+
# --- Kumpulkan data lokal ---
|
| 216 |
sources = []
|
| 217 |
for p in args.data:
|
| 218 |
src = Path(p)
|
|
|
|
| 224 |
shutil.copy2(src, data_dir / src.name)
|
| 225 |
sources.append(str(src))
|
| 226 |
|
| 227 |
+
# --- Tarik dataset dari HF ---
|
| 228 |
if args.hf_dataset:
|
| 229 |
patterns = list(args.hf_patterns)
|
| 230 |
if args.format_qa and "*.json" not in patterns:
|
| 231 |
+
patterns.append("*.json") # butuh JSON untuk Alpaca conversion
|
| 232 |
pull_hf(args.hf_dataset, data_dir / "_hf", patterns)
|
| 233 |
sources.append(f"hf:{args.hf_dataset}")
|
| 234 |
|
| 235 |
+
# --- Konversi JSON Alpaca → .txt ---
|
| 236 |
if args.format_qa:
|
| 237 |
n = preprocess_alpaca_json(data_dir)
|
| 238 |
if n:
|
|
|
|
| 240 |
else:
|
| 241 |
print("[pipeline] peringatan: tidak ada file Alpaca JSON ditemukan")
|
| 242 |
|
| 243 |
+
# --- Jalankan training ---
|
| 244 |
out_dir = run_dir / "ckpt"
|
| 245 |
argv = [
|
| 246 |
"--data", str(data_dir),
|
|
|
|
| 270 |
print(f"[pipeline] run='{args.tag}' | sumber={sources or ['<kosong>' ]}")
|
| 271 |
stats = train.main(argv) or {}
|
| 272 |
|
| 273 |
+
# --- Evaluasi guard kamus (opsional) ---
|
| 274 |
if args.guard:
|
| 275 |
import torch
|
| 276 |
|
|
|
|
| 278 |
from indigo.model import GPT, GPTConfig
|
| 279 |
from safetensors.torch import load_file
|
| 280 |
|
| 281 |
+
# Cari checkpoint: prioritaskan best, fallback ke final
|
| 282 |
probe_path = out_dir / "indigo_best.safetensors"
|
| 283 |
if not probe_path.exists():
|
| 284 |
probe_path = out_dir / "indigo.safetensors"
|
|
|
|
| 286 |
p_def, s_def = ROOT / "data" / "prefiks.txt", ROOT / "data" / "sufiks.txt"
|
| 287 |
|
| 288 |
def muat_afiks(flag, default_path):
|
| 289 |
+
"""Muat file afiks: prioritaskan flag CLI → default path."""
|
| 290 |
if flag:
|
| 291 |
return load_wordlist(flag) if Path(flag).exists() else None
|
| 292 |
return load_wordlist(str(default_path)) if default_path.exists() else None
|
|
|
|
| 298 |
mdl = GPT(GPTConfig(**meta["config"]))
|
| 299 |
mdl.load_state_dict(load_file(str(probe_path)), strict=False)
|
| 300 |
mdl.eval()
|
| 301 |
+
# Generate teks dari prompt newline (netral)
|
| 302 |
ids = tok.encode("\n") or [0]
|
| 303 |
idx = torch.tensor([ids], dtype=torch.long)
|
| 304 |
with torch.no_grad():
|
|
|
|
| 306 |
text = tok.decode(out[0].tolist())
|
| 307 |
stats["kamus_ratio"] = round(word_known_ratio(text, wordset, prefiks, sufiks), 4)
|
| 308 |
print(f"[pipeline] kamus_ratio={stats['kamus_ratio']:.2%}")
|
| 309 |
+
|
| 310 |
+
# --- Simpan manifest.json ---
|
| 311 |
stats["pipeline"] = {
|
| 312 |
"tag": args.tag,
|
| 313 |
"timestamp": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
|
|
| 322 |
json.dump(stats, f, ensure_ascii=False, indent=2)
|
| 323 |
print(f"[pipeline] manifest -> {manifest}")
|
| 324 |
|
| 325 |
+
# --- Finalize: promosikan checkpoint terbaik ke out/ ---
|
| 326 |
best = out_dir / "indigo_best.safetensors"
|
| 327 |
if args.finalize:
|
| 328 |
has_best = (out_dir / "indigo_best.safetensors").exists()
|
|
|
|
| 355 |
)
|
| 356 |
print("[pipeline] silsilah -> out/run_info.json")
|
| 357 |
|
| 358 |
+
# --- Push ke Hugging Face Hub ---
|
| 359 |
if args.push:
|
| 360 |
from huggingface_hub import HfApi
|
| 361 |
|
tests/test_core.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 5 |
+
sys.path.insert(0, str(ROOT))
|
| 6 |
+
|
| 7 |
+
from indigo.bpe import BPETokenizer
|
| 8 |
+
from indigo.common import (
|
| 9 |
+
_kandidat_akar,
|
| 10 |
+
load_wordlist,
|
| 11 |
+
save_meta,
|
| 12 |
+
load_meta,
|
| 13 |
+
word_known_ratio,
|
| 14 |
+
)
|
| 15 |
+
from indigo.tokenizer import CharTokenizer
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_bpe_roundtrip():
|
| 19 |
+
teks = "halo dunia, halo lagi! belajar bahasa indonesia bersama."
|
| 20 |
+
tok = BPETokenizer.train(teks * 5, 280)
|
| 21 |
+
ids = tok.encode(teks)
|
| 22 |
+
assert tok.decode(ids) == teks
|
| 23 |
+
assert 256 <= tok.vocab_size <= 280
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_char_roundtrip():
|
| 27 |
+
tok = CharTokenizer.from_text("abcba")
|
| 28 |
+
assert tok.decode(tok.encode("abc")) == "abc"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_meta_save_load(tmp_path):
|
| 32 |
+
base = str(tmp_path / "ck.safetensors")
|
| 33 |
+
(tmp_path / "ck.safetensors").write_bytes(b"")
|
| 34 |
+
cfg = {"vocab_size": 10, "block_size": 8}
|
| 35 |
+
save_meta(base, cfg, None, 5, 1.23, backend="pytorch", tokenizer={"type": "char"})
|
| 36 |
+
meta = load_meta(base)
|
| 37 |
+
assert meta["config"] == cfg and meta["step"] == 5 and meta["val_loss"] == 1.23
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_afiks_asimilasi():
|
| 41 |
+
pref = {"meng", "meny", "men", "mem", "pem", "di", "ter"}
|
| 42 |
+
suf = {"kan", "annya", "nya", "an", "i", "lah"}
|
| 43 |
+
assert "sapu" in _kandidat_akar("menyapu", pref, suf)
|
| 44 |
+
assert "pukul" in _kandidat_akar("pemukul", pref, suf)
|
| 45 |
+
assert "ambil" in _kandidat_akar("mengambil", pref, suf)
|
| 46 |
+
assert "terima" in _kandidat_akar("diterima", pref, suf)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_word_known_ratio_naik_dengan_afiks(tmp_path):
|
| 50 |
+
kamus = tmp_path / "kamus.txt"
|
| 51 |
+
kamus.write_text("paham\nterima\nringan\n", encoding="utf-8")
|
| 52 |
+
ws = load_wordlist(str(kamus))
|
| 53 |
+
pref = {"di"}
|
| 54 |
+
suf = {"i", "nya"}
|
| 55 |
+
teks = "paham dipahami ringannya"
|
| 56 |
+
r0 = word_known_ratio(teks, ws)
|
| 57 |
+
r1 = word_known_ratio(teks, ws, pref, suf)
|
| 58 |
+
assert r0 < r1 <= 1.0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_load_wordlist_normalisasi(tmp_path):
|
| 62 |
+
f = tmp_path / "w.txt"
|
| 63 |
+
f.write_text("Apa\nBEBEK\n\nbebek\n", encoding="utf-8")
|
| 64 |
+
ws = load_wordlist(str(f))
|
| 65 |
+
assert ws == {"apa", "bebek"}
|
train.py
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
import math
|
|
@@ -19,11 +37,24 @@ from indigo.tokenizer import CharTokenizer
|
|
| 19 |
|
| 20 |
|
| 21 |
def load_init(path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
if path.endswith(".safetensors"):
|
| 23 |
from safetensors.torch import load_file
|
| 24 |
|
| 25 |
state = load_file(path)
|
| 26 |
meta = load_meta(path)
|
|
|
|
| 27 |
opt_path = os.path.splitext(path)[0].replace("_best", "") + "_optimizer.pt"
|
| 28 |
opt = None
|
| 29 |
if os.path.exists(opt_path):
|
|
@@ -32,6 +63,7 @@ def load_init(path):
|
|
| 32 |
except Exception as e:
|
| 33 |
print(f"optimizer state dilewati: {e}")
|
| 34 |
return state, meta, opt
|
|
|
|
| 35 |
ckpt = torch.load(path, map_location="cpu", weights_only=True)
|
| 36 |
meta = {
|
| 37 |
"config": ckpt["config"],
|
|
@@ -42,10 +74,32 @@ def load_init(path):
|
|
| 42 |
return ckpt["model"], meta, ckpt.get("optimizer")
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
| 45 |
_ARANGE_CACHE = {}
|
| 46 |
|
| 47 |
|
| 48 |
def get_batch(data, block_size, batch_size, device):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
ix = torch.randint(len(data) - block_size - 1, (batch_size,))
|
| 50 |
arange = _ARANGE_CACHE.get((block_size, device))
|
| 51 |
if arange is None:
|
|
@@ -59,6 +113,20 @@ def get_batch(data, block_size, batch_size, device):
|
|
| 59 |
|
| 60 |
@torch.no_grad()
|
| 61 |
def estimate_loss(model, data, args, device):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
model.eval()
|
| 63 |
losses = []
|
| 64 |
for _ in range(args.eval_iters):
|
|
@@ -70,29 +138,83 @@ def estimate_loss(model, data, args, device):
|
|
| 70 |
|
| 71 |
|
| 72 |
def main(argv=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
parser = argparse.ArgumentParser(description="Latih model Indigo dari scratch")
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
parser.add_argument("--
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
parser.add_argument("--
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
parser.add_argument("--
|
| 85 |
-
|
| 86 |
-
parser.add_argument("--
|
| 87 |
-
|
| 88 |
-
parser.add_argument("--
|
| 89 |
-
|
| 90 |
-
parser.add_argument("--
|
| 91 |
-
|
| 92 |
-
parser.add_argument("--
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
args = parser.parse_args(argv)
|
| 95 |
|
|
|
|
| 96 |
torch.manual_seed(args.seed)
|
| 97 |
if args.device == "auto":
|
| 98 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
@@ -100,10 +222,15 @@ def main(argv=None):
|
|
| 100 |
device = args.device
|
| 101 |
os.makedirs(args.out, exist_ok=True)
|
| 102 |
|
|
|
|
|
|
|
| 103 |
paths = collect_text_files(args.data)
|
| 104 |
if not paths:
|
| 105 |
raise SystemExit("tidak ada file teks ditemukan")
|
| 106 |
|
|
|
|
|
|
|
|
|
|
| 107 |
files = sorted(paths)
|
| 108 |
rng = random.Random(args.seed)
|
| 109 |
rng.shuffle(files)
|
|
@@ -112,15 +239,18 @@ def main(argv=None):
|
|
| 112 |
|
| 113 |
train_text = "".join(read_clean(p) for p in files[n_val:])
|
| 114 |
val_text = "".join(read_clean(p) for p in files[:n_val])
|
| 115 |
-
all_text = train_text + val_text
|
| 116 |
|
|
|
|
| 117 |
init_state = None
|
| 118 |
init_opt = None
|
| 119 |
start_step = 0
|
| 120 |
init_meta = None
|
| 121 |
config = None
|
| 122 |
comp_ratio = 1.0
|
|
|
|
| 123 |
if args.init_from:
|
|
|
|
| 124 |
init_state, init_meta, init_opt = load_init(args.init_from)
|
| 125 |
config = GPTConfig(**init_meta["config"])
|
| 126 |
start_step = init_meta.get("step", 0)
|
|
@@ -128,6 +258,7 @@ def main(argv=None):
|
|
| 128 |
tokenizer = build_tokenizer(init_meta.get("tokenizer") or {"type": "char"}, init_meta["vocab"])
|
| 129 |
tinfo = init_meta.get("tokenizer") or {"type": "char"}
|
| 130 |
else:
|
|
|
|
| 131 |
if args.tokenizer == "bpe":
|
| 132 |
from indigo.bpe import BPETokenizer
|
| 133 |
|
|
@@ -142,6 +273,7 @@ def main(argv=None):
|
|
| 142 |
else:
|
| 143 |
tokenizer = CharTokenizer.from_text(all_text)
|
| 144 |
tinfo = {"type": "char"}
|
|
|
|
| 145 |
if config is None:
|
| 146 |
config = GPTConfig(
|
| 147 |
vocab_size=tokenizer.vocab_size,
|
|
@@ -151,11 +283,14 @@ def main(argv=None):
|
|
| 151 |
n_embd=args.n_embd,
|
| 152 |
dropout=args.dropout,
|
| 153 |
)
|
|
|
|
|
|
|
| 154 |
if config.vocab_size != tokenizer.vocab_size:
|
| 155 |
raise SystemExit(
|
| 156 |
f"vocab tidak cocok: checkpoint={config.vocab_size}, tokenizer={tokenizer.vocab_size}"
|
| 157 |
)
|
| 158 |
|
|
|
|
| 159 |
train_data = torch.tensor(tokenizer.encode(train_text), dtype=torch.long)
|
| 160 |
val_data = torch.tensor(tokenizer.encode(val_text), dtype=torch.long)
|
| 161 |
if len(train_data) < args.block_size * 2:
|
|
@@ -164,6 +299,7 @@ def main(argv=None):
|
|
| 164 |
f"tokens latih={len(train_data):,} | tokens validasi={len(val_data):,}"
|
| 165 |
)
|
| 166 |
|
|
|
|
| 167 |
model = GPT(config)
|
| 168 |
if init_state is not None:
|
| 169 |
missing, unexpected = model.load_state_dict(init_state, strict=False)
|
|
@@ -176,6 +312,7 @@ def main(argv=None):
|
|
| 176 |
f"vocab={tokenizer.vocab_size} | total_steps={total_steps}"
|
| 177 |
)
|
| 178 |
|
|
|
|
| 179 |
optimizer = torch.optim.AdamW(
|
| 180 |
model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=args.weight_decay
|
| 181 |
)
|
|
@@ -187,6 +324,7 @@ def main(argv=None):
|
|
| 187 |
print(f"optimizer state dilewati: {e}")
|
| 188 |
|
| 189 |
def save_model(base_path, val_loss):
|
|
|
|
| 190 |
tensors = {k: v.detach().clone().contiguous() for k, v in model.state_dict().items()}
|
| 191 |
save_file(tensors, base_path)
|
| 192 |
save_meta(
|
|
@@ -200,27 +338,43 @@ def main(argv=None):
|
|
| 200 |
)
|
| 201 |
|
| 202 |
def lr_at(step):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
if step < args.warmup:
|
| 204 |
-
return args.lr * (step + 1) / args.warmup
|
| 205 |
progress = (step - args.warmup) / max(1, total_steps - args.warmup)
|
| 206 |
return 0.1 * args.lr + 0.45 * args.lr * (1 + math.cos(math.pi * progress))
|
| 207 |
|
|
|
|
| 208 |
best_val = float("inf")
|
| 209 |
last_val = None
|
| 210 |
model.train()
|
| 211 |
t0 = time.time()
|
| 212 |
for step in range(start_step, total_steps):
|
|
|
|
| 213 |
lr = lr_at(step)
|
| 214 |
for g in optimizer.param_groups:
|
| 215 |
g["lr"] = lr
|
|
|
|
|
|
|
| 216 |
x, y = get_batch(train_data, config.block_size, args.batch_size, device)
|
| 217 |
_, loss = model(x, y)
|
|
|
|
|
|
|
| 218 |
optimizer.zero_grad(set_to_none=True)
|
| 219 |
loss.backward()
|
| 220 |
-
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 221 |
optimizer.step()
|
| 222 |
|
| 223 |
-
|
|
|
|
| 224 |
if len(val_data) > config.block_size + 1:
|
| 225 |
val_loss = estimate_loss(model, val_data, args, device)
|
| 226 |
marker = ""
|
|
@@ -237,11 +391,13 @@ def main(argv=None):
|
|
| 237 |
f"loss {loss.item():.4f} | val {val_str} | {time.time() - t0:.1f}s"
|
| 238 |
)
|
| 239 |
|
|
|
|
| 240 |
final_path = os.path.join(args.out, "indigo.safetensors")
|
| 241 |
save_model(final_path, last_val)
|
| 242 |
torch.save(optimizer.state_dict(), os.path.join(args.out, "indigo_optimizer.pt"))
|
| 243 |
print(f"model tersimpan di {final_path} (+_meta.json, indigo_optimizer.pt)")
|
| 244 |
|
|
|
|
| 245 |
stats = {
|
| 246 |
"out": args.out,
|
| 247 |
"device": device,
|
|
@@ -258,7 +414,8 @@ def main(argv=None):
|
|
| 258 |
"best_val": best_val if best_val != float("inf") else None,
|
| 259 |
"last_val": last_val,
|
| 260 |
"nats_per_char_best": (
|
| 261 |
-
round(best_val / comp_ratio, 4)
|
|
|
|
| 262 |
),
|
| 263 |
"params_million": round(model.num_params() / 1e6, 4),
|
| 264 |
"config": config.__dict__,
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script training model Indigo GPT dari nol.
|
| 3 |
+
|
| 4 |
+
Fitur:
|
| 5 |
+
- Training loop standar dengan AdamW optimizer
|
| 6 |
+
- Learning rate schedule: warmup linear → cosine decay
|
| 7 |
+
- Best checkpoint otomatis berdasarkan validasi
|
| 8 |
+
- Resume training dari checkpoint sebelumnya (--init-from)
|
| 9 |
+
- Dukungan tokenizer char dan BPE
|
| 10 |
+
- Gradient clipping untuk stabilitas
|
| 11 |
+
- Statistik ringkasan di akhir run
|
| 12 |
+
|
| 13 |
+
Cara pakai:
|
| 14 |
+
python train.py --data data/sample.txt --steps 2000
|
| 15 |
+
python train.py --data data/teks.txt --tokenizer bpe --vocab-size 512
|
| 16 |
+
python train.py --init-from out/indigo_best.safetensors --steps 1000
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
import os
|
| 20 |
import time
|
| 21 |
import math
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def load_init(path):
|
| 40 |
+
"""Muat checkpoint untuk melanjutkan training (resume).
|
| 41 |
+
|
| 42 |
+
Mendukung dua format:
|
| 43 |
+
1. .safetensors: format utama Indigo (safetensors + _meta.json + optimizer.pt)
|
| 44 |
+
2. .pt: format PyTorch checkpoint lama (model, config, vocab, optimizer dalam 1 file)
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
path: Path ke file checkpoint (.safetensors atau .pt).
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Tuple (state_dict, meta_dict, optimizer_state atau None).
|
| 51 |
+
"""
|
| 52 |
if path.endswith(".safetensors"):
|
| 53 |
from safetensors.torch import load_file
|
| 54 |
|
| 55 |
state = load_file(path)
|
| 56 |
meta = load_meta(path)
|
| 57 |
+
# Cari file optimizer (suffix _best dihapus untuk file optimizer)
|
| 58 |
opt_path = os.path.splitext(path)[0].replace("_best", "") + "_optimizer.pt"
|
| 59 |
opt = None
|
| 60 |
if os.path.exists(opt_path):
|
|
|
|
| 63 |
except Exception as e:
|
| 64 |
print(f"optimizer state dilewati: {e}")
|
| 65 |
return state, meta, opt
|
| 66 |
+
# Format .pt lama
|
| 67 |
ckpt = torch.load(path, map_location="cpu", weights_only=True)
|
| 68 |
meta = {
|
| 69 |
"config": ckpt["config"],
|
|
|
|
| 74 |
return ckpt["model"], meta, ckpt.get("optimizer")
|
| 75 |
|
| 76 |
|
| 77 |
+
# Cache arange tensor per (block_size, device) untuk menghindari alokasi berulang
|
| 78 |
+
# saat get_batch dipanggil ribuan kali — menghemat ~11x waktu.
|
| 79 |
_ARANGE_CACHE = {}
|
| 80 |
|
| 81 |
|
| 82 |
def get_batch(data, block_size, batch_size, device):
|
| 83 |
+
"""Ambil batch data latih secara random (vectorized).
|
| 84 |
+
|
| 85 |
+
Proses:
|
| 86 |
+
1. Pilih batch_size posisi awal secara acak dari data
|
| 87 |
+
2. Untuk setiap posisi, ambil potongan sepanjang block_size (input) dan block_size (target)
|
| 88 |
+
3. Target = input bergeser 1 posisi ke kanan (next-token prediction)
|
| 89 |
+
|
| 90 |
+
Menggunakan fancy indexing dan arange cache untuk efisiensi:
|
| 91 |
+
- ix: posisi awal random untuk setiap sampel dalam batch
|
| 92 |
+
- idx: matriks posisi (batch_size × block_size) dengan offset arange
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
data: Tensor 1D — seluruh data training (token IDs).
|
| 96 |
+
block_size: Int — panjang konteks per sampel.
|
| 97 |
+
batch_size: Int — jumlah sampel per batch.
|
| 98 |
+
device: Str — "cpu" atau "cuda".
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Tuple (x, y) — x: input (B, T), y: target (B, T).
|
| 102 |
+
"""
|
| 103 |
ix = torch.randint(len(data) - block_size - 1, (batch_size,))
|
| 104 |
arange = _ARANGE_CACHE.get((block_size, device))
|
| 105 |
if arange is None:
|
|
|
|
| 113 |
|
| 114 |
@torch.no_grad()
|
| 115 |
def estimate_loss(model, data, args, device):
|
| 116 |
+
"""Estimasi loss validasi dengan averaging beberapa batch.
|
| 117 |
+
|
| 118 |
+
Model dipindahkan ke mode eval (tanpa dropout), lalu dihitung loss rata-rata
|
| 119 |
+
dari eval_iters batch random. Hasilnya lebih stabil daripada single batch.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
model: Model GPT.
|
| 123 |
+
data: Tensor 1D — data validasi (token IDs).
|
| 124 |
+
args: Namespace — harus punya block_size, batch_size, eval_iters.
|
| 125 |
+
device: Str — "cpu" atau "cuda".
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Float — loss rata-rata (cross-entropy, nats per token).
|
| 129 |
+
"""
|
| 130 |
model.eval()
|
| 131 |
losses = []
|
| 132 |
for _ in range(args.eval_iters):
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
def main(argv=None):
|
| 141 |
+
"""Fungsi utama training — bisa dipanggil dari CLI atau dari pipeline.py.
|
| 142 |
+
|
| 143 |
+
Pipeline training:
|
| 144 |
+
1. Parse argumen → setup device & seed
|
| 145 |
+
2. Kumpulkan file teks → split train/val
|
| 146 |
+
3. Bangun atau muat tokenizer → encode teks ke token IDs
|
| 147 |
+
4. Bangun atau muat model GPT
|
| 148 |
+
5. Setup optimizer (AdamW) + learning rate schedule
|
| 149 |
+
6. Loop training: forward → loss → backward → clip grad → step optimizer
|
| 150 |
+
7. Setiap eval_interval langkah: hitung val loss → save best checkpoint
|
| 151 |
+
8. Simpan checkpoint final + optimizer state + statistik
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
argv: List argumen CLI (atau None untuk pakai sys.argv).
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
Dict statistik training (dipakai oleh pipeline.py untuk manifest.json).
|
| 158 |
+
"""
|
| 159 |
parser = argparse.ArgumentParser(description="Latih model Indigo dari scratch")
|
| 160 |
+
|
| 161 |
+
# --- Data ---
|
| 162 |
+
parser.add_argument("--data", nargs="+", default=["data/sample.txt"],
|
| 163 |
+
help="path file/folder teks untuk training (bisa banyak, spasi-separated)")
|
| 164 |
+
|
| 165 |
+
# --- Output ---
|
| 166 |
+
parser.add_argument("--out", default="out",
|
| 167 |
+
help="folder output checkpoint (.safetensors + _meta.json + _optimizer.pt)")
|
| 168 |
+
|
| 169 |
+
# --- Hyperparameter Training ---
|
| 170 |
+
parser.add_argument("--steps", type=int, default=2000,
|
| 171 |
+
help="jumlah total langkah training (default: 2000)")
|
| 172 |
+
parser.add_argument("--batch-size", type=int, default=32,
|
| 173 |
+
help="jumlah sampel per batch (default: 32)")
|
| 174 |
+
parser.add_argument("--block-size", type=int, default=128,
|
| 175 |
+
help="panjang konteks token per sampel (default: 128)")
|
| 176 |
+
parser.add_argument("--lr", type=float, default=3e-4,
|
| 177 |
+
help="learning rate maksimum (default: 3e-4)")
|
| 178 |
+
parser.add_argument("--warmup", type=int, default=100,
|
| 179 |
+
help="jumlah langkah warmup linear sebelum cosine decay (default: 100)")
|
| 180 |
+
parser.add_argument("--weight-decay", type=float, default=0.1,
|
| 181 |
+
help="L2 regularization / weight decay (default: 0.1)")
|
| 182 |
+
parser.add_argument("--dropout", type=float, default=0.1,
|
| 183 |
+
help="dropout rate (0.0 = nonaktif, default: 0.1)")
|
| 184 |
+
|
| 185 |
+
# --- Arsitektur Model ---
|
| 186 |
+
parser.add_argument("--n-layer", type=int, default=4,
|
| 187 |
+
help="jumlah blok transformer (default: 4)")
|
| 188 |
+
parser.add_argument("--n-head", type=int, default=4,
|
| 189 |
+
help="jumlah head per attention layer (default: 4)")
|
| 190 |
+
parser.add_argument("--n-embd", type=int, default=128,
|
| 191 |
+
help="dimensi embedding / hidden size (default: 128)")
|
| 192 |
+
|
| 193 |
+
# --- Tokenizer ---
|
| 194 |
+
parser.add_argument("--tokenizer", default="char", choices=["char", "bpe"],
|
| 195 |
+
help="jenis tokenizer: 'char' (karakter) atau 'bpe' (subword, default: char)")
|
| 196 |
+
parser.add_argument("--vocab-size", type=int, default=512,
|
| 197 |
+
help="ukuran vocab untuk BPE (diabaikan jika --tokenizer char, default: 512)")
|
| 198 |
+
|
| 199 |
+
# --- Evaluasi & Seed ---
|
| 200 |
+
parser.add_argument("--eval-interval", type=int, default=200,
|
| 201 |
+
help="evaluasi validasi setiap N langkah (0 = tidak ada validasi, default: 200)")
|
| 202 |
+
parser.add_argument("--eval-iters", type=int, default=20,
|
| 203 |
+
help="jumlah batch untuk estimasi loss validasi (default: 20)")
|
| 204 |
+
parser.add_argument("--seed", type=int, default=1337,
|
| 205 |
+
help="seed random untuk reproduktibilitas (default: 1337)")
|
| 206 |
+
|
| 207 |
+
# --- Validasi & Resume ---
|
| 208 |
+
parser.add_argument("--val-fraction", type=float, default=0.1,
|
| 209 |
+
help="proporsi file untuk validasi (default: 0.1 = 10%%)")
|
| 210 |
+
parser.add_argument("--init-from", default=None,
|
| 211 |
+
help="path checkpoint untuk melanjutkan training (resume)")
|
| 212 |
+
parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"],
|
| 213 |
+
help="device training: auto/cpu/cuda (default: auto)")
|
| 214 |
+
|
| 215 |
args = parser.parse_args(argv)
|
| 216 |
|
| 217 |
+
# --- Setup seed & device ---
|
| 218 |
torch.manual_seed(args.seed)
|
| 219 |
if args.device == "auto":
|
| 220 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
| 222 |
device = args.device
|
| 223 |
os.makedirs(args.out, exist_ok=True)
|
| 224 |
|
| 225 |
+
# --- Kumpulkan & split data ---
|
| 226 |
+
# collect_text_files: jika path adalah direktori, cari .txt rekursif
|
| 227 |
paths = collect_text_files(args.data)
|
| 228 |
if not paths:
|
| 229 |
raise SystemExit("tidak ada file teks ditemukan")
|
| 230 |
|
| 231 |
+
# Acak urutan file → split: n_val file untuk validasi, sisanya untuk training
|
| 232 |
+
# Split dilakukan per-file (bukan per-karakter), sehingga satu file kecil
|
| 233 |
+
# bisa menghabiskan seluruh kuota validasi
|
| 234 |
files = sorted(paths)
|
| 235 |
rng = random.Random(args.seed)
|
| 236 |
rng.shuffle(files)
|
|
|
|
| 239 |
|
| 240 |
train_text = "".join(read_clean(p) for p in files[n_val:])
|
| 241 |
val_text = "".join(read_clean(p) for p in files[:n_val])
|
| 242 |
+
all_text = train_text + val_text # dibutuhkan untuk training tokenizer BPE
|
| 243 |
|
| 244 |
+
# --- Setup model & tokenizer ---
|
| 245 |
init_state = None
|
| 246 |
init_opt = None
|
| 247 |
start_step = 0
|
| 248 |
init_meta = None
|
| 249 |
config = None
|
| 250 |
comp_ratio = 1.0
|
| 251 |
+
|
| 252 |
if args.init_from:
|
| 253 |
+
# Resume dari checkpoint: muat model, tokenizer, dan optimizer
|
| 254 |
init_state, init_meta, init_opt = load_init(args.init_from)
|
| 255 |
config = GPTConfig(**init_meta["config"])
|
| 256 |
start_step = init_meta.get("step", 0)
|
|
|
|
| 258 |
tokenizer = build_tokenizer(init_meta.get("tokenizer") or {"type": "char"}, init_meta["vocab"])
|
| 259 |
tinfo = init_meta.get("tokenizer") or {"type": "char"}
|
| 260 |
else:
|
| 261 |
+
# Training dari nol: bangun tokenizer baru
|
| 262 |
if args.tokenizer == "bpe":
|
| 263 |
from indigo.bpe import BPETokenizer
|
| 264 |
|
|
|
|
| 273 |
else:
|
| 274 |
tokenizer = CharTokenizer.from_text(all_text)
|
| 275 |
tinfo = {"type": "char"}
|
| 276 |
+
# Bangun config model baru dari argumen CLI
|
| 277 |
if config is None:
|
| 278 |
config = GPTConfig(
|
| 279 |
vocab_size=tokenizer.vocab_size,
|
|
|
|
| 283 |
n_embd=args.n_embd,
|
| 284 |
dropout=args.dropout,
|
| 285 |
)
|
| 286 |
+
|
| 287 |
+
# Validasi: vocab size model harus cocok dengan tokenizer
|
| 288 |
if config.vocab_size != tokenizer.vocab_size:
|
| 289 |
raise SystemExit(
|
| 290 |
f"vocab tidak cocok: checkpoint={config.vocab_size}, tokenizer={tokenizer.vocab_size}"
|
| 291 |
)
|
| 292 |
|
| 293 |
+
# --- Encode teks ke token IDs ---
|
| 294 |
train_data = torch.tensor(tokenizer.encode(train_text), dtype=torch.long)
|
| 295 |
val_data = torch.tensor(tokenizer.encode(val_text), dtype=torch.long)
|
| 296 |
if len(train_data) < args.block_size * 2:
|
|
|
|
| 299 |
f"tokens latih={len(train_data):,} | tokens validasi={len(val_data):,}"
|
| 300 |
)
|
| 301 |
|
| 302 |
+
# --- Inisialisasi model ---
|
| 303 |
model = GPT(config)
|
| 304 |
if init_state is not None:
|
| 305 |
missing, unexpected = model.load_state_dict(init_state, strict=False)
|
|
|
|
| 312 |
f"vocab={tokenizer.vocab_size} | total_steps={total_steps}"
|
| 313 |
)
|
| 314 |
|
| 315 |
+
# --- Setup optimizer: AdamW dengan betas=(0.9, 0.95) ---
|
| 316 |
optimizer = torch.optim.AdamW(
|
| 317 |
model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=args.weight_decay
|
| 318 |
)
|
|
|
|
| 324 |
print(f"optimizer state dilewati: {e}")
|
| 325 |
|
| 326 |
def save_model(base_path, val_loss):
|
| 327 |
+
"""Simpan checkpoint model + metadata ke file .safetensors + _meta.json."""
|
| 328 |
tensors = {k: v.detach().clone().contiguous() for k, v in model.state_dict().items()}
|
| 329 |
save_file(tensors, base_path)
|
| 330 |
save_meta(
|
|
|
|
| 338 |
)
|
| 339 |
|
| 340 |
def lr_at(step):
|
| 341 |
+
"""Hitung learning rate pada step tertentu.
|
| 342 |
+
|
| 343 |
+
Schedule:
|
| 344 |
+
- Warmup (step < warmup): linear naik dari 0 ke lr maks
|
| 345 |
+
- Setelah warmup: cosine decay dari lr maks ke 10% lr maks
|
| 346 |
+
- Formula cosine: 0.1*lr + 0.45*lr * (1 + cos(pi * progress))
|
| 347 |
+
|
| 348 |
+
Jika warmup=0, langsung masuk cosine decay dari step 0.
|
| 349 |
+
"""
|
| 350 |
if step < args.warmup:
|
| 351 |
+
return args.lr * (step + 1) / max(1, args.warmup)
|
| 352 |
progress = (step - args.warmup) / max(1, total_steps - args.warmup)
|
| 353 |
return 0.1 * args.lr + 0.45 * args.lr * (1 + math.cos(math.pi * progress))
|
| 354 |
|
| 355 |
+
# --- Training loop ---
|
| 356 |
best_val = float("inf")
|
| 357 |
last_val = None
|
| 358 |
model.train()
|
| 359 |
t0 = time.time()
|
| 360 |
for step in range(start_step, total_steps):
|
| 361 |
+
# Update learning rate sesuai schedule
|
| 362 |
lr = lr_at(step)
|
| 363 |
for g in optimizer.param_groups:
|
| 364 |
g["lr"] = lr
|
| 365 |
+
|
| 366 |
+
# Forward pass: ambil batch → hitung loss
|
| 367 |
x, y = get_batch(train_data, config.block_size, args.batch_size, device)
|
| 368 |
_, loss = model(x, y)
|
| 369 |
+
|
| 370 |
+
# Backward pass: zero grad → backward → clip grad → step optimizer
|
| 371 |
optimizer.zero_grad(set_to_none=True)
|
| 372 |
loss.backward()
|
| 373 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # mencegah gradient explosion
|
| 374 |
optimizer.step()
|
| 375 |
|
| 376 |
+
# Evaluasi validasi + simpan best checkpoint
|
| 377 |
+
if args.eval_interval > 0 and (step % args.eval_interval == 0 or step == total_steps - 1):
|
| 378 |
if len(val_data) > config.block_size + 1:
|
| 379 |
val_loss = estimate_loss(model, val_data, args, device)
|
| 380 |
marker = ""
|
|
|
|
| 391 |
f"loss {loss.item():.4f} | val {val_str} | {time.time() - t0:.1f}s"
|
| 392 |
)
|
| 393 |
|
| 394 |
+
# --- Simpan checkpoint final (bukan best) ---
|
| 395 |
final_path = os.path.join(args.out, "indigo.safetensors")
|
| 396 |
save_model(final_path, last_val)
|
| 397 |
torch.save(optimizer.state_dict(), os.path.join(args.out, "indigo_optimizer.pt"))
|
| 398 |
print(f"model tersimpan di {final_path} (+_meta.json, indigo_optimizer.pt)")
|
| 399 |
|
| 400 |
+
# --- Ringkasan statistik ---
|
| 401 |
stats = {
|
| 402 |
"out": args.out,
|
| 403 |
"device": device,
|
|
|
|
| 414 |
"best_val": best_val if best_val != float("inf") else None,
|
| 415 |
"last_val": last_val,
|
| 416 |
"nats_per_char_best": (
|
| 417 |
+
round(best_val / comp_ratio, 4)
|
| 418 |
+
if best_val != float("inf") and comp_ratio else None
|
| 419 |
),
|
| 420 |
"params_million": round(model.num_params() / 1e6, 4),
|
| 421 |
"config": config.__dict__,
|