Chapter Instruction Tuning membuktikan bahwa melanjutkan pelatihan pada data berformat instruksi mengubah perilaku model dari 0/5 menjadi 4/5 instruksi yang dipatuhi. Bab ini membuka ruang mesinnya: supervised fine-tuning (SFT) sebagai training loop yang sesungguhnya. Kita akan menjawab tiga pertanyaan teknis yang menentukan kualitas SFT: bagaimana pasangan (prompt, respons) diubah menjadi tensor pelatihan, mengapa loss hanya boleh dihitung pada token respons — termasuk membuktikan mekanisme masking-nya dengan angka — dan apa yang terjadi pada kemampuan lama model ketika seluruh bobotnya diperbarui. Fondasi cross-entropy dan optimizer sudah kita kuasai di chapter Loss dan Optimizer ; di sini keduanya dipakai dengan satu perubahan kunci pada label.
Dari Pasangan Instruksi ke Tensor #
Data SFT adalah daftar pasangan (prompt, respons). Setiap pasangan digabung menjadi satu sekuens, lalu target pelatihannya adalah sekuens yang sama tergeser satu posisi — persis pola teacher forcing pada pretraining. Perbedaannya ada pada label: posisi yang berasal dari prompt ditandai agar diabaikan dari perhitungan loss.
sekuens : <bos> tanya nama : budi . <eos>
masukan : <bos> tanya nama : budi .
label penuh: tanya nama : budi . <eos>
label SFT : -100 -100 -100 budi . <eos>
`------- prompt -------' `----- respons ----`Alasannya mekanis, bukan selera. Saat inference, prompt adalah masukan yang sudah diketahui — tugas model hanya membangkitkan respons. Loss pada token prompt melatih model meniru pertanyaan pengguna, menghabiskan separuh gradien untuk kemampuan yang tidak pernah dipakai. Pada dataset nyata yang prompt-nya panjang (paragraf dokumen, riwayat percakapan), tanpa masking mayoritas loss justru berasal dari teks yang bukan target.
Masking Prompt dengan ignore_index #
Mekanismenya satu baris: ganti label posisi prompt dengan nilai -100, lalu panggil CrossEntropyLoss(ignore_index=-100). PyTorch menghitung loss hanya pada posisi dengan label bukan -100, dan rata-ratanya dibagi jumlah posisi itu saja — bukan seluruh posisi. Detail lengkapnya ada pada dokumentasi CrossEntropyLoss
.
Demo: Sembilan Token dari Delapan Belas #
Skenario demo meniru siklus adaptasi nyata dalam miniatur. Decoder mini (arsitektur sama dengan dua chapter sebelumnya, 18.384 parameter) di-pra-latih pada domain lama: 250 sekuens berisi lima huruf berbeda dari distribusi tidak merata. Data SFT-nya tiga pasangan tanya-jawab. Contoh diuji nyata pada CPU dengan PyTorch 2.14 dan selesai dalam sekitar 8 detik.
"""Supervised fine-tuning mini: masking token prompt dengan ignore_index.
Bagian 1: decoder mini (arsitektur sama dengan chapter sebelumnya) di-pra-
latih pada domain lama: 250 sekuens lima huruf berbeda.
Bagian 2: data SFT disusun sebagai pasangan (prompt, respons); label
disiapkan dua versi: penuh (semua token dihitung) dan masking
(token prompt diganti -100), lalu jumlah token yang dihitung
loss dibandingkan dengan angka.
Bagian 3: bukti mekanik ignore_index: loss CrossEntropyLoss pada label
masking identik dengan rata-rata manual pada token respons saja.
Bagian 4: SFT penuh — seluruh bobot dilatih dengan learning rate kecil
(1e-3, sepersepuluh dari pretraining) pada label masking.
Bagian 5: perilaku tanya-jawab sebelum vs sesudah SFT, lalu loss domain
lama diukur ulang: naik -> catastrophic forgetting.
Diuji di CPU dengan PyTorch 2.14, seed tetap, deterministik.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
# ---------- 1. Vocabulary + dua domain data ----------
KATA = ["<bos>", "<eos>", "tanya", "nama", "kota", "hobi", ":",
"budi", "jakarta", "baca", ".", "a", "b", "c", "d", "e"]
ID = {k: i for i, k in enumerate(KATA)}
V = len(KATA)
BOS, EOS, ABAIKAN = ID["<bos>"], ID["<eos>"], -100
HURUF = ["a", "b", "c", "d", "e"]
BOBOT = torch.tensor([0.45, 0.25, 0.15, 0.10, 0.05]) # a paling sering
def sekuens_huruf(n, seed):
"""Sekuens domain lama: lima huruf berbeda + penanda awal/akhir."""
gg = torch.Generator().manual_seed(seed)
idx = torch.multinomial(BOBOT.repeat(n, 1), 5, generator=gg)
return [[BOS] + [ID[HURUF[i]] for i in baris] + [EOS] for baris in idx.tolist()]
korpus_lama_latih = sekuens_huruf(250, 7)
korpus_lama_uji = sekuens_huruf(40, 99)
PASANGAN = [("nama", "budi"), ("kota", "jakarta"), ("hobi", "baca")]
data_sft = [[BOS, ID["tanya"], ID[t], ID[":"]] + [ID[j], ID["."], EOS]
for t, j in PASANGAN]
print(f"vocab : {V} token")
print(f"domain lama : {len(korpus_lama_latih)} sekuens latih + "
f"{len(korpus_lama_uji)} uji (7 token/sekuens)")
print(f"data SFT : {len(data_sft)} pasangan prompt-respons")
print(f"contoh SFT : {[KATA[i] for i in data_sft[0]]}")
print()
# ---------- 2. Decoder mini (arsitektur sama chapter sebelumnya) ----------
class BlokDecoder(nn.Module):
"""Satu blok decoder: causal self-attention + FFN + dua LayerNorm."""
def __init__(self, d, kepala):
super().__init__()
self.h, self.d_k = kepala, d // kepala
self.wq, self.wk, self.wv, self.wo = (nn.Linear(d, d) for _ in range(4))
self.ffn = nn.Sequential(nn.Linear(d, 64), nn.ReLU(), nn.Linear(64, d))
self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
def forward(self, x):
n, T, d = x.shape
def pecah(z):
return z.view(n, T, self.h, self.d_k).transpose(1, 2)
q, k, v = pecah(self.wq(x)), pecah(self.wk(x)), pecah(self.wv(x))
skor = q @ k.transpose(-2, -1) / self.d_k ** 0.5
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
a = skor.masked_fill(mask, float("-inf")).softmax(-1)
z = (a @ v).transpose(1, 2).contiguous().view(n, T, d)
x = self.norm1(x + self.wo(z))
x = self.norm2(x + self.ffn(x))
return x
class DecoderMini(nn.Module):
"""Transformer decoder: embedding + dua blok + kepala keluaran."""
def __init__(self, vocab, d=32, kepala=2, T_maks=8, n_blok=2):
super().__init__()
self.embed = nn.Embedding(vocab, d)
self.pos = nn.Embedding(T_maks, d)
self.blok = nn.ModuleList([BlokDecoder(d, kepala) for _ in range(n_blok)])
self.keluar = nn.Linear(d, vocab)
def forward(self, idx):
n, T = idx.shape
x = self.embed(idx) + self.pos(torch.arange(T))
for blok in self.blok:
x = blok(x)
return self.keluar(x)
model = DecoderMini(V)
total_param = sum(p.numel() for p in model.parameters())
print(f"parameter model: {total_param}")
print()
def latih(x, y, epoch, lr, judul, abaikan=None):
"""Training loop: loss cross-entropy; abaikan != None -> masking."""
opt = torch.optim.Adam(model.parameters(), lr=lr)
print(f"=== {judul} (lr={lr}) ===")
for ep in range(epoch):
logits = model(x)
if abaikan is None:
loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
else:
loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1),
ignore_index=abaikan)
opt.zero_grad()
loss.backward()
opt.step()
if ep == 0 or ep == epoch // 2 or ep == epoch - 1:
print(f" epoch {ep:3d} loss = {loss.item():.4f}")
print()
def jawab(prompt_token, maks=8):
"""Lanjutkan prompt secara greedy sampai <eos> atau batas token."""
seq = list(prompt_token)
with torch.no_grad():
while len(seq) < maks:
logits = model(torch.tensor([seq]))[0, -1]
berikutnya = logits.argmax().item()
if berikutnya == EOS:
break
seq.append(berikutnya)
return [KATA[i] for i in seq[len(prompt_token):]]
def uji_tanya(label):
"""Uji ketiga prompt 'tanya <topik> :' secara greedy."""
print(f"--- {label} ---")
benar = 0
for t, j in PASANGAN:
respons = jawab([BOS, ID["tanya"], ID[t], ID[":"]])
ok = respons == [j, "."]
benar += ok
print(f" tanya {t}: -> {' '.join(respons):14s} "
f"{'OK' if ok else 'GAGAL'}")
print(f" benar: {benar}/{len(PASANGAN)}")
print()
return benar
# ---------- 3. Pretrain domain lama ----------
x_lama = torch.tensor([s[:-1] for s in korpus_lama_latih])
y_lama = torch.tensor([s[1:] for s in korpus_lama_latih])
x_uji = torch.tensor([s[:-1] for s in korpus_lama_uji])
y_uji = torch.tensor([s[1:] for s in korpus_lama_uji])
latih(x_lama, y_lama, epoch=400, lr=1e-2, judul="pretrain domain lama")
with torch.no_grad():
loss_lama_sebelum = F.cross_entropy(
model(x_uji).reshape(-1, V), y_uji.reshape(-1)).item()
print(f"loss uji domain lama (sebelum SFT): {loss_lama_sebelum:.4f}")
print()
# ---------- 4. Susun data SFT: label penuh vs label masking ----------
x_sft = torch.tensor([s[:-1] for s in data_sft]) # 6 token masukan
y_penuh = torch.tensor([s[1:] for s in data_sft]) # 6 target per sekuens
y_mask = y_penuh.clone()
y_mask[:, :3] = ABAIKAN # posisi 0-2 = token prompt -> diabaikan
n_semua = y_penuh.numel()
n_respons = (y_mask != ABAIKAN).sum().item()
print("=== Susun label SFT (prompt 4 token, respons 3 token) ===")
print(f"posisi target total : {n_semua} "
f"({x_sft.shape[0]} sekuens x {x_sft.shape[1]} posisi)")
print(f"tanpa masking : {n_semua} token dihitung (100%)")
print(f"dengan masking -100 : {n_respons} token dihitung "
f"({100 * n_respons / n_semua:.0f}%, hanya respons)")
print(f"contoh label masking : {y_mask[0].tolist()}")
print()
# ---------- 5. Bukti mekanik ignore_index ----------
with torch.no_grad():
logits = model(x_sft).reshape(-1, V)
loss_penuh = F.cross_entropy(logits, y_penuh.reshape(-1)).item()
loss_abai = F.cross_entropy(logits, y_mask.reshape(-1),
ignore_index=ABAIKAN).item()
posisi = (y_mask.reshape(-1) != ABAIKAN)
loss_manual = F.cross_entropy(logits[posisi],
y_mask.reshape(-1)[posisi]).item()
print("=== Bukti mekanik ignore_index (model pra-latih, data SFT) ===")
print(f"loss tanpa masking ({n_semua} token) : {loss_penuh:.4f}")
print(f"loss masking, CrossEntropyLoss(ignore_index) : {loss_abai:.4f}")
print(f"loss masking, manual {n_respons} token respons : {loss_manual:.4f}")
print(f"identik: {abs(loss_abai - loss_manual) < 1e-6}")
print()
# ---------- 6. Perilaku sebelum vs SFT ----------
uji_tanya("SEBELUM SFT (pra-latih domain huruf)")
print(f"SFT penuh: {sum(p.numel() for p in model.parameters() if p.requires_grad)}"
f"/{total_param} parameter dilatih (100%)")
latih(x_sft, y_mask, epoch=200, lr=1e-3,
judul="SFT full fine-tuning, label masking")
uji_tanya("SESUDAH SFT")
# ---------- 7. Catastrophic forgetting pada domain lama ----------
with torch.no_grad():
loss_lama_sesudah = F.cross_entropy(
model(x_uji).reshape(-1, V), y_uji.reshape(-1)).item()
print("=== Catastrophic forgetting (domain lama diukur ulang) ===")
print(f"loss uji domain lama: {loss_lama_sebelum:.4f} (sebelum SFT) "
f"-> {loss_lama_sesudah:.4f} (sesudah SFT)")
print(f"kenaikan: {loss_lama_sesudah - loss_lama_sebelum:+.4f} "
f"({100 * (loss_lama_sesudah / loss_lama_sebelum - 1):.0f}%)")Output yang Dihasilkan:
vocab : 16 token
domain lama : 250 sekuens latih + 40 uji (7 token/sekuens)
data SFT : 3 pasangan prompt-respons
contoh SFT : ['<bos>', 'tanya', 'nama', ':', 'budi', '.', '<eos>']
parameter model: 18384
=== pretrain domain lama (lr=0.01) ===
epoch 0 loss = 2.9750
epoch 200 loss = 0.6348
epoch 399 loss = 0.6307
loss uji domain lama (sebelum SFT): 0.6276
=== Susun label SFT (prompt 4 token, respons 3 token) ===
posisi target total : 18 (3 sekuens x 6 posisi)
tanpa masking : 18 token dihitung (100%)
dengan masking -100 : 9 token dihitung (50%, hanya respons)
contoh label masking : [-100, -100, -100, 7, 10, 1]
=== Bukti mekanik ignore_index (model pra-latih, data SFT) ===
loss tanpa masking (18 token) : 9.9606
loss masking, CrossEntropyLoss(ignore_index) : 8.0519
loss masking, manual 9 token respons : 8.0519
identik: True
--- SEBELUM SFT (pra-latih domain huruf) ---
tanya nama: -> b d GAGAL
tanya kota: -> b d GAGAL
tanya hobi: -> b d GAGAL
benar: 0/3
SFT penuh: 18384/18384 parameter dilatih (100%)
=== SFT full fine-tuning, label masking (lr=0.001) ===
epoch 0 loss = 8.0519
epoch 100 loss = 0.1266
epoch 199 loss = 0.0180
--- SESUDAH SFT ---
tanya nama: -> budi . OK
tanya kota: -> jakarta . OK
tanya hobi: -> baca . OK
benar: 3/3
=== Catastrophic forgetting (domain lama diukur ulang) ===
loss uji domain lama: 0.6276 (sebelum SFT) -> 0.8766 (sesudah SFT)
kenaikan: +0.2491 (40%)Mari kita telaah kode di atas baris per baris, dengan fokus pada label masking dan akibatnya:
- Baris 32-43: domain lama dibangun dari distribusi huruf berbobot (
apaling sering); data uji memakai seed berbeda agar angka loss mengukur penguasaan pola, bukan hafalan sekuens. - Baris 45-47: data SFT — setiap pasangan menjadi satu sekuens tujuh token: empat token prompt (
<bos> tanya <topik> :) diikuti tiga token respons (<jawaban> . <eos>). - Baris 107-125: fungsi
latihadalah training loop standar dari chapter Training Loop ; parameterabaikanmenjadi satu-satunya perbedaan antara pretraining (tanpa masking) dan SFT (dengan masking). - Baris 160-165: pretraining domain lama mencapai loss uji 0,6276 — model menguasai domain lama sebelum disentuh SFT.
- Baris 168-181: inti bab ini.
y_mask[:, :3] = -100menandai tiga posisi target pertama sebagai prompt; dari 18 posisi target, hanya 9 yang dihitung loss (50%) — tepat separuh, karena respons memuat tiga dari enam token per sekuens. - Baris 184-198: bukti mekanik pada logits yang sama:
CrossEntropyLoss(ignore_index=-100)menghasilkan 8,0519 dan rata-rata manual pada 9 token respons juga 8,0519 (identik: True); tanpa masking nilainya 9,9606 karena prediksi token prompt ikut dirata-rata. Angka 8,0519 yang sama muncul lagi sebagai loss epoch-0 SFT — konsistensi internal yang bisa Anda cek sendiri. - Baris 200-207: perilaku tanya-jawab berbalik dari 0/3 menjadi 3/3 dengan loss turun 8,0519 → 0,0180, dan seluruh 18.384 parameter dilatih (100%) — inilah arti kata full pada full fine-tuning.
- Baris 209-217: loss domain lama naik dari 0,6276 ke 0,8766 (+40%) setelah SFT — catastrophic forgetting terukur pada kemampuan yang sebelumnya dikuasai.
Learning Rate Kecil, Epoch Sedikit #
Dua angka pada demo sengaja ditulis bersebelahan: pretraining memakai learning rate 1e-2, SFT memakai 1e-3 — sepersepuluh. Pada LLM nyata kontrasnya jauh lebih tajam: pretraining berjalan pada triliunan token dengan learning rate besar, sedangkan SFT cukup dengan learning rate orde 1e-5 sampai 2e-5 dan satu sampai tiga epoch. Bobot pra-latih sudah berada dekat solusi yang baik; learning rate besar justru menggeser representasi jauh dari titik itu dan mempercepat degradasi kemampuan lama.
| Aspek | Pretraining | SFT |
|---|---|---|
| Data | korpus teks masif, tanpa label pasangan | pasangan (prompt, respons) terkurasi |
| Token dihitung loss | semua posisi | hanya token respons |
| Learning rate | besar (demo: 1e-2) | kecil (demo: 1e-3; nyata: ~1e-5) |
| Jumlah epoch | banyak | sedikit (nyata: 1-3) |
| Bobot diperbarui | seluruhnya | seluruhnya — inilah masalahnya |
Full fine-tuning menuntut seluruh bobot hadir sebagai gradien dan status optimizer, dan hasilnya disimpan sebagai salinan model utuh per tugas. Ketika model berukuran miliaran parameter, dua tuntutan itu menjadi tembok biaya — tembok yang dirobohkan keluarga teknik parameter-efficient fine-tuning.