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.

text
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.

python
"""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:

text
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:

  1. Baris 32-43: domain lama dibangun dari distribusi huruf berbobot (a paling sering); data uji memakai seed berbeda agar angka loss mengukur penguasaan pola, bukan hafalan sekuens.
  2. Baris 45-47: data SFT — setiap pasangan menjadi satu sekuens tujuh token: empat token prompt (<bos> tanya <topik> :) diikuti tiga token respons (<jawaban> . <eos>).
  3. Baris 107-125: fungsi latih adalah training loop standar dari chapter Training Loop ; parameter abaikan menjadi satu-satunya perbedaan antara pretraining (tanpa masking) dan SFT (dengan masking).
  4. Baris 160-165: pretraining domain lama mencapai loss uji 0,6276 — model menguasai domain lama sebelum disentuh SFT.
  5. Baris 168-181: inti bab ini. y_mask[:, :3] = -100 menandai 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.
  6. 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.
  7. 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.
  8. 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.

AspekPretrainingSFT
Datakorpus teks masif, tanpa label pasanganpasangan (prompt, respons) terkurasi
Token dihitung losssemua posisihanya token respons
Learning ratebesar (demo: 1e-2)kecil (demo: 1e-3; nyata: ~1e-5)
Jumlah epochbanyaksedikit (nyata: 1-3)
Bobot diperbaruiseluruhnyaseluruhnya — 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.