adyoi commited on
Commit
9a82835
·
verified ·
1 Parent(s): 5bec821

docs: komentar inline + README argumen + bugfix & optimasi (7dd92fc)

Browse files
Files changed (11) hide show
  1. README.md +337 -39
  2. audit_kamus.py +58 -7
  3. eval.py +91 -12
  4. generate.py +79 -16
  5. indigo/bpe.py +117 -1
  6. indigo/common.py +135 -0
  7. indigo/model.py +185 -4
  8. indigo/tokenizer.py +61 -0
  9. pipeline.py +135 -31
  10. tests/test_core.py +65 -0
  11. 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 | 96 token |
28
- | Tokenizer | karakter atau BPE (`--tokenizer bpe`) |
 
 
 
29
 
30
- ## File penting
31
 
32
  ```
33
- indigo/model.py arsitektur GPT (attention kausal + KV-cache)
34
- indigo/bpe.py tokenizer BPE byte-level minimal
35
- indigo/tokenizer.py tokenizer karakter
36
- train.py training (best-checkpoint, resume penuh, split val per-file)
37
- generate.py generasi (top-k, top-p, repetition penalty)
38
- out/indigo_best.safetensors bobot terbaik + _meta.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ```
40
 
41
- ## Cara pakai
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
- python generate.py --prompt "Indigo" --max-new 300 \
50
- --temperature 0.8 --top-k 40 --top-p 0.9 --repetition-penalty 1.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- python train.py --init-from out/indigo_best.safetensors --steps 1000 # lanjutkan training
53
  ```
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- Generasi memakai KV-cache sehingga cepat untuk output panjang.
56
 
57
- ### Penjaga kamus (kualitas ejaan)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- `--guard` memuat kamus kata (satu kata per baris, mis. wordlist KBBI berlisensi MIT dari
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
- # metrik otomatis per run di manifest.json ("kamus_ratio")
67
- python pipeline.py --tag run03 --data data/ --steps 2000 --guard data/kamus_id.txt
68
  ```
69
 
70
- Kata berimbuhan dicek lewat formula morfologi: `data/prefiks.txt` & `data/sufiks.txt`
71
- (mis. *dipahami* → akar *paham*, termasuk asimilasi *meny*-→*s*, *pem*-→*p*).
72
- Edit kedua file itu untuk memperluas cakupan tanpa menyentuh kode.
 
 
 
 
 
 
 
 
 
 
73
 
74
- ### Pipeline
75
 
76
- `pipeline.py` merangkai semuanya: menyalin data lokal dan/atau menarik dataset HF,
77
- melatih, menyimpan `manifest.json` (statistik + konfigurasi run), lalu opsional
78
- mempromosikan checkpoint terbaik ke `out/` dan mengunggahnya ke Hub.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  ```bash
81
- # data lokal + dataset HF, tokenizer BPE
82
- python pipeline.py --tag run01 --data data/tekskamu.txt --hf-dataset adyoi/indigo \
 
 
 
83
  --tokenizer bpe --vocab-size 512 --steps 900 --device cpu
84
 
85
- # promosikan hasil terbaik ke out/ sekaligus upload ke Hub
86
- python pipeline.py --tag run02 --data data/ --steps 2000 --finalize --push
 
 
 
 
 
 
 
 
87
  ```
88
 
89
- ### Evaluasi standar antar-checkpoint
 
 
 
 
 
 
90
 
91
- `eval.py` menykor checkpoint pada set uji tetap (nats/token & nats/karakter) sehingga
92
- run dengan tokenizer berbeda tetap sebanding:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  ```bash
 
 
 
 
95
  python eval.py --ckpt out/indigo_best.safetensors runs/*/ckpt/indigo.safetensors \
96
- --test data/sample.txt --guard data/kamus_id.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
- ap.add_argument("--n", type=int, default=200, help="jumlah kata yang diambil sampel")
58
- ap.add_argument("--seed", type=int, default=1337)
59
- ap.add_argument("--delay", type=float, default=0.8, help="jeda antar-permintaan (detik)")
 
 
 
 
60
  ap.add_argument("--backend", choices=["auto", "pypi", "web"], default="auto",
61
- help="web: kbbi.web.id (heuristik jumlah marker; rapuh)")
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, help="path laporan json")
 
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
- ap.add_argument("--test", default="data/sample.txt")
64
- ap.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
65
- ap.add_argument("--guard", default=None, help="kamus opsional untuk metrik rasio ejaan")
66
- ap.add_argument("--seed", type=int, default=42)
67
- ap.add_argument("--batch-size", type=int, default=32)
 
 
 
 
 
 
 
 
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
- 120,
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
- parser.add_argument("--ckpt", default="out/indigo_best.safetensors")
35
- parser.add_argument("--prompt", default="")
36
- parser.add_argument("--max-new", type=int, default=300)
37
- parser.add_argument("--temperature", type=float, default=0.8)
38
- parser.add_argument("--top-k", type=int, default=40)
39
- parser.add_argument("--top-p", type=float, default=1.0)
40
- parser.add_argument("--repetition-penalty", type=float, default=1.0)
41
- parser.add_argument("--seed", type=int, default=None)
42
- parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"])
43
- parser.add_argument("--guard", default=None, help="file kamus (satu kata per baris); pilih kandidat dengan ejaan terbaik")
44
- parser.add_argument("--guard-prefiks", default=None, help="daftar prefiks (default data/prefiks.txt bila ada)")
45
- parser.add_argument("--guard-sufiks", default=None, help="daftar sufiks (default data/sufiks.txt bila ada)")
46
- parser.add_argument("--guard-tries", type=int, default=5, help="jumlah kandidat saat --guard aktif")
47
- parser.add_argument("--guard-min", type=float, default=0.6, help="rasio kata dikenal minimum (info saja)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ap.add_argument("--tag", required=True, help="nama run; artefak di <runs>/<tag>/")
83
- ap.add_argument("--data", nargs="*", default=[], help="file/folder teks lokal tambahan")
84
- ap.add_argument("--hf-dataset", default=None, help="repo dataset HF untuk menarik file teks")
85
- ap.add_argument("--hf-patterns", nargs="*", default=["*.txt", "*.md"])
 
 
 
 
 
 
 
 
86
  ap.add_argument("--format-qa", action="store_true",
87
- help="auto-convert Alpaca JSON (instruction/output) to .txt sebelum training")
88
- ap.add_argument("--runs", default="runs")
89
- ap.add_argument("--device", default=None, help="diteruskan ke train.py (default auto)")
 
 
 
 
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", help="upload checkpoint terbaik ke repo HF")
93
- ap.add_argument("--repo", default="adyoi/indigo")
 
 
 
 
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
- ap.add_argument("--guard-prefiks", default=None, help="default data/prefiks.txt bila ada")
98
- ap.add_argument("--guard-sufiks", default=None, help="default data/sufiks.txt bila ada")
 
 
 
 
 
99
  t = ap.add_argument_group("hyperparameter training (diteruskan ke train.py)")
100
- t.add_argument("--steps", type=int, default=2000)
101
- t.add_argument("--batch-size", type=int, default=32)
102
- t.add_argument("--block-size", type=int, default=128)
103
- t.add_argument("--n-layer", type=int, default=4)
104
- t.add_argument("--n-head", type=int, default=4)
105
- t.add_argument("--n-embd", type=int, default=128)
106
- t.add_argument("--dropout", type=float, default=0.1)
107
- t.add_argument("--lr", type=float, default=3e-4)
108
- t.add_argument("--warmup", type=int, default=100)
109
- t.add_argument("--weight-decay", type=float, default=0.1)
110
- t.add_argument("--eval-interval", type=int, default=200)
111
- t.add_argument("--eval-iters", type=int, default=20)
112
- t.add_argument("--seed", type=int, default=1337)
113
- t.add_argument("--init-from", default=None)
114
- t.add_argument("--tokenizer", default="char", choices=["char", "bpe"])
115
- t.add_argument("--vocab-size", type=int, default=512)
116
- t.add_argument("--val-fraction", type=float, default=0.1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- parser.add_argument("--data", nargs="+", default=["data/sample.txt"], help="path file/folder teks untuk training")
75
- parser.add_argument("--out", default="out", help="folder output checkpoint")
76
- parser.add_argument("--steps", type=int, default=2000)
77
- parser.add_argument("--batch-size", type=int, default=32)
78
- parser.add_argument("--block-size", type=int, default=128)
79
- parser.add_argument("--n-layer", type=int, default=4)
80
- parser.add_argument("--n-head", type=int, default=4)
81
- parser.add_argument("--n-embd", type=int, default=128)
82
- parser.add_argument("--dropout", type=float, default=0.1)
83
- parser.add_argument("--lr", type=float, default=3e-4)
84
- parser.add_argument("--warmup", type=int, default=100)
85
- parser.add_argument("--weight-decay", type=float, default=0.1)
86
- parser.add_argument("--eval-interval", type=int, default=200)
87
- parser.add_argument("--eval-iters", type=int, default=20)
88
- parser.add_argument("--seed", type=int, default=1337)
89
- parser.add_argument("--init-from", default=None, help="checkpoint untuk melanjutkan training")
90
- parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"])
91
- parser.add_argument("--tokenizer", default="char", choices=["char", "bpe"])
92
- parser.add_argument("--vocab-size", type=int, default=512, help="ukuran vocab untuk tokenizer bpe")
93
- parser.add_argument("--val-fraction", type=float, default=0.1, help="proporsi file untuk validasi")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if step % args.eval_interval == 0 or step == total_steps - 1:
 
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) if best_val != float("inf") and comp_ratio else None
 
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__,