Dua chapter sebelumnya menutup siklus adaptasi dengan satu kalimat yang jarang dipertanyakan: seluruh bobot model ikut diperbarui. Pada decoder mini 18 ribu parameter, pilihan itu tidak terasa. Pada LLM 7 miliar parameter, full fine-tuning menuntut seluruh bobot hadir di memori sebagai gradien dan status optimizer, menghasilkan salinan model utuh untuk setiap tugas, dan menggeser kemampuan lama — catastrophic forgetting yang sudah kita ukur naik 40% pada chapter Supervised Fine-Tuning . Parameter-efficient fine-tuning (PEFT) membalik pilihan itu: bekukan hampir seluruh bobot, latih hanya modul kecil yang disisipkan atau ditambahkan. Bab ini memetakan keluarga metodenya, lalu membuktikan konsep intinya lewat demo adapter — 4,6% parameter, akurasi di atas full fine-tuning, kemampuan lama utuh.

Tiga Biaya Full Fine-Tuning #

Biaya pertama adalah memori pelatihan. Untuk setiap parameter yang dilatih, Adam menyimpan bobot, gradien, dan dua status (momentum serta varians): kira-kira 16 byte per parameter dalam fp32. Model 7 miliar parameter membutuhkan lebih dari 100 GB hanya untuk ketiganya — sebelum memperhitungkan aktivasi — jauh melebihi GPU konsumen. Biaya kedua adalah penyimpanan: setiap adaptasi menghasilkan salinan model utuh (7B dalam fp16 sudah 14 GB), sehingga sepuluh tugas berarti sepuluh salinan penuh. Biaya ketiga sudah kita buktikan dengan angka: menggeser seluruh bobot ke distribusi data baru merusak kemampuan pra-latih.

Asumsi kerja PEFT menjawab ketiganya sekaligus: adaptasi ke tugas baru tidak membutuhkan perubahan seluruh representasi — cukup koreksi kecil pada jalur yang tepat, sementara representasi umum hasil pretraining dibiarkan apa adanya.

Keluarga Metode PEFT #

Empat pendekatan utama berkembang hampir bersamaan. Adapter (Houlsby dkk. ) menyisipkan module kecil di dalam tiap blok transformer: proyeksi turun ke dimensi sempit, non-linearitas, proyeksi naik kembali, dengan jalur residual agar module bisa di-nol-kan saat awal. LoRA mengganti sebagian bobot proyeksi dengan koreksi rank rendah ΔW = B·A yang dilatih di atas bobot beku — dan karena koreksinya berbentuk matriks, koreksi bisa digabungkan ke bobot saat deployment sehingga tidak ada latensi tambahan. Prompt tuning (Lester dkk. ) melatih embedding beberapa token virtual yang ditautkan di depan input; bobot model tidak disentuh sama sekali. BitFit (Ben Zaken dkk. ) paling sederhana: hanya vektor bias tiap lapis yang diperbarui.

Diagram di atas menunjukkan penempatan yang kita pakai di demo: dua sisipan adapter di antara lapis-lapis yang dibekukan. Pada transformer nyata, adapter disisipkan setelah sub-layer attention dan feed-forward di setiap blok. Perbandingan keempat metode dirangkum berikut:

MetodeYang dilatihSkala (model 7B)Karakter
Full fine-tuningseluruh bobot7 miliar (100%)batas atas kualitas; paling mahal
Adaptermodule kecil yang disisipkanpuluhan juta (<1%)menambah kedalaman; sedikit menambah latensi inference
LoRAΔW = B·A rank rendahjutaan (0,1-1%)digabung ke bobot saat deploy; tanpa latensi ekstra
Prefix / prompt tuningembedding token virtualribuan-jutaantidak menyentuh bobot model sama sekali
Bias-only (BitFit)vektor biasjutaan (<0,1%)paling sederhana; kapasitas adaptasi terbatas

Demo: Empat Koma Enam Persen Parameter #

Demo memakai MLP tiga lapis (128 → 256 → 256 → 2, total 99.330 parameter) sebagai model dasar yang di-pra-latih pada Tugas A: sinyal kelas pada dimensi 0. Tugas B memindahkan sinyal ke dimensi 1 dan menjadikan dimensi 0 penanda berlawanan — keputusan desain yang sama dengan demo LoRA pada chapter berikutnya, sehingga angka kedua chapter bisa dibandingkan. Contoh diuji nyata pada CPU dengan PyTorch 2.14 dan selesai dalam sekitar 8 detik.

python
"""Parameter-efficient fine-tuning mini: adapter pada model dasar beku.

Bagian 1: MLP dasar (128-256-256-2) di-pra-latih pada Tugas A: sinyal kelas
          pada dimensi 0. Tugas B memindahkan sinyal ke dimensi 1 dan
          menjadikan dimensi 0 penanda lawan — model beku tertipu.
Bagian 2: bukti inisialisasi identitas: sisipan adapter (proyeksi turun
          r=4 -> ReLU -> proyeksi naik, residual) di-nol-kan pada proyeksi
          naik sehingga logits model beradapter = logits model dasar.
Bagian 3: dua jalur adaptasi Tugas B — full fine-tuning seluruh bobot vs
          adapter dengan seluruh bobot dasar dibekukan.
Bagian 4: anggaran parameter + ukuran berkas per tugas, lalu akurasi
          Tugas A diukur ulang: full FT lupa, adapter mempertahankan.
Diuji di CPU dengan PyTorch 2.14, seed tetap, deterministik.
"""

import copy

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

D_IN, D_H, R = 128, 256, 4   # dimensi masukan, dimensi tersembunyi, rank adapter


def buat_data(n, dim_sinyal, dim_lawan, seed):
    """Klasifikasi 2 kelas: sinyal kelas pada satu dimensi fitur."""
    g = torch.Generator().manual_seed(seed)
    x = torch.randn(n, D_IN, generator=g)
    s = torch.randint(0, 2, (n,), generator=g) * 2 - 1     # label -1 / +1
    x[:, dim_sinyal] += s * 2.5                            # sinyal kelas
    if dim_lawan is not None:
        x[:, dim_lawan] -= s * 2.5                         # penanda lawan
    return x, (s > 0).long()


# Tugas A: sinyal di dimensi 0. Tugas B: sinyal pindah ke dimensi 1,
# dimensi 0 justru membawa penanda berlawanan.
xA, yA = buat_data(512, 0, None, 11)
xA_uji, yA_uji = buat_data(256, 0, None, 12)
xB, yB = buat_data(512, 1, 0, 21)
xB_uji, yB_uji = buat_data(256, 1, 0, 22)


class ModelDasar(nn.Module):
    """MLP tiga lapis yang berperan sebagai model pra-latih."""

    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(D_IN, D_H)
        self.l2 = nn.Linear(D_H, D_H)
        self.keluar = nn.Linear(D_H, 2)

    def forward(self, x):
        h = F.relu(self.l1(x))
        h = F.relu(self.l2(h))
        return self.keluar(h)


class Adapter(nn.Module):
    """Sisipan kecil: turun ke rank r, non-linear, naik kembali + residual."""

    def __init__(self, d, r):
        super().__init__()
        self.turun = nn.Linear(d, r)
        self.naik = nn.Linear(r, d)
        nn.init.zeros_(self.naik.weight)   # koreksi nol saat awal ->
        nn.init.zeros_(self.naik.bias)     # model beradapter == model dasar

    def forward(self, h):
        return h + self.naik(F.relu(self.turun(h)))


class ModelBeradapter(nn.Module):
    """Model dasar utuh + dua sisipan adapter setelah tiap lapis tersembunyi."""

    def __init__(self, dasar, r):
        super().__init__()
        self.dasar = dasar
        self.ad1 = Adapter(D_H, r)
        self.ad2 = Adapter(D_H, r)

    def forward(self, x):
        h = F.relu(self.dasar.l1(x))
        h = self.ad1(h)
        h = F.relu(self.dasar.l2(h))
        h = self.ad2(h)
        return self.dasar.keluar(h)


def akurasi(model, x, y):
    with torch.no_grad():
        return (model(x).argmax(1) == y).float().mean().item()


def latih(model, x, y, epoch, lr):
    """Training loop penuh-batch; hanya parameter requires_grad dilatih."""
    opt = torch.optim.Adam([p for p in model.parameters() if p.requires_grad],
                           lr=lr)
    loss = None
    for ep in range(epoch):
        loss = F.cross_entropy(model(x), y)
        opt.zero_grad()
        loss.backward()
        opt.step()
    return loss.item()


# ---------- 1. Pretrain Tugas A ----------
dasar = ModelDasar()
total_param = sum(p.numel() for p in dasar.parameters())
loss_A = latih(dasar, xA, yA, epoch=300, lr=1e-3)
acc_A_awal = akurasi(dasar, xA_uji, yA_uji)
print("== 1. pretrain Tugas A (sinyal di dimensi 0) ==")
print(f"parameter model dasar : {total_param} "
      f"({D_IN} -> {D_H} -> {D_H} -> 2)")
print(f"loss akhir            : {loss_A:.4f} | akurasi uji A: {acc_A_awal:.3f}")
print()

# ---------- 2. Model beku menghadapi Tugas B ----------
acc_beku = akurasi(dasar, xB_uji, yB_uji)
print("== 2. model beku menghadapi Tugas B ==")
print(f"sinyal pindah ke dimensi 1, dimensi 0 jadi penanda lawan")
print(f"akurasi B tanpa adaptasi: {acc_beku:.3f} (tertipu penanda lama)")
print()

# ---------- 3. Pasang adapter, bukti inisialisasi identitas ----------
m_ad = ModelBeradapter(copy.deepcopy(dasar), R)
with torch.no_grad():
    selisih = (m_ad(xA_uji) - dasar(xA_uji)).abs().max().item()
print("== 3. pasang adapter (2 sisipan, rank r=4), cek inisialisasi ==")
print(f"selisih logits maksimum model dasar vs beradapter: {selisih:.6f}")
print("(proyeksi naik di-nol-kan -> koreksi awal nol persis)")
print()

# ---------- 4. Jalur adaptasi 1: full fine-tuning ----------
m_full = copy.deepcopy(dasar)
loss_full = latih(m_full, xB, yB, epoch=300, lr=1e-3)
acc_full_B = akurasi(m_full, xB_uji, yB_uji)
acc_full_A = akurasi(m_full, xA_uji, yA_uji)
print("== 4. jalur 1: full fine-tuning (seluruh bobot dilatih) ==")
print(f"loss akhir B : {loss_full:.4f} | akurasi B: {acc_full_B:.3f}")
print(f"akurasi A sesudah: {acc_full_A:.3f} "
      f"(turun dari {acc_A_awal:.3f} -> catastrophic forgetting)")
print()

# ---------- 5. Jalur adaptasi 2: adapter, bobot dasar beku ----------
for p in m_ad.dasar.parameters():
    p.requires_grad_(False)                # seluruh bobot dasar dibekukan
latih_ad = sum(p.numel() for p in m_ad.parameters() if p.requires_grad)
loss_ad = latih(m_ad, xB, yB, epoch=600, lr=3e-3)
acc_ad_B = akurasi(m_ad, xB_uji, yB_uji)
acc_ad_A_lepas = akurasi(m_ad.dasar, xA_uji, yA_uji)
acc_ad_A_aktif = akurasi(m_ad, xA_uji, yA_uji)
print("== 5. jalur 2: adapter (hanya sisipan kecil yang dilatih) ==")
print(f"loss akhir B : {loss_ad:.4f} | akurasi B: {acc_ad_B:.3f}")
print(f"akurasi A, adapter dilepas (dasar beku)  : {acc_ad_A_lepas:.3f}")
print(f"akurasi A, adapter aktif                 : {acc_ad_A_aktif:.3f}")
print("(adapter spesialis Tugas B; kemampuan lama dipakai dengan melepasnya)")
print()

# ---------- 6. Anggaran parameter dan penyimpanan ----------
uk_full = total_param * 4 / 1024
uk_ad = latih_ad * 4 / 1024
print("== 6. anggaran parameter dan penyimpanan per tugas (fp32) ==")
print(f"model dasar total        : {total_param} parameter")
print(f"full fine-tuning         : {total_param} dilatih (100,0%)"
      f" | salinan model {uk_full:.0f} KB")
print(f"adapter r={R} (2 sisipan) : {latih_ad} dilatih "
      f"({100 * latih_ad / total_param:.1f}%) | berkas adapter {uk_ad:.0f} KB")
print()

# ---------- 7. Ringkasan ----------
print("== 7. ringkasan (Tugas A -> Tugas B) ==")
print(f"model beku    : A {acc_A_awal:.3f} | B {acc_beku:.3f}")
print(f"full FT       : A {acc_full_A:.3f} | B {acc_full_B:.3f} "
      f"| {total_param} parameter dilatih")
print(f"adapter {100 * latih_ad / total_param:.1f}%  : A {acc_ad_A_lepas:.3f} | "
      f"B {acc_ad_B:.3f} | {latih_ad} parameter dilatih")

Output yang Dihasilkan:

text
== 1. pretrain Tugas A (sinyal di dimensi 0) ==
parameter model dasar : 99330 (128 -> 256 -> 256 -> 2)
loss akhir            : 0.0000 | akurasi uji A: 0.977

== 2. model beku menghadapi Tugas B ==
sinyal pindah ke dimensi 1, dimensi 0 jadi penanda lawan
akurasi B tanpa adaptasi: 0.020 (tertipu penanda lama)

== 3. pasang adapter (2 sisipan, rank r=4), cek inisialisasi ==
selisih logits maksimum model dasar vs beradapter: 0.000000
(proyeksi naik di-nol-kan -> koreksi awal nol persis)

== 4. jalur 1: full fine-tuning (seluruh bobot dilatih) ==
loss akhir B : 0.0005 | akurasi B: 0.969
akurasi A sesudah: 0.379 (turun dari 0.977 -> catastrophic forgetting)

== 5. jalur 2: adapter (hanya sisipan kecil yang dilatih) ==
loss akhir B : 0.0001 | akurasi B: 0.996
akurasi A, adapter dilepas (dasar beku)  : 0.977
akurasi A, adapter aktif                 : 0.074
(adapter spesialis Tugas B; kemampuan lama dipakai dengan melepasnya)

== 6. anggaran parameter dan penyimpanan per tugas (fp32) ==
model dasar total        : 99330 parameter
full fine-tuning         : 99330 dilatih (100,0%) | salinan model 388 KB
adapter r=4 (2 sisipan) : 4616 dilatih (4.6%) | berkas adapter 18 KB

== 7. ringkasan (Tugas A -> Tugas B) ==
model beku    : A 0.977 | B 0.020
full FT       : A 0.379 | B 0.969 | 99330 parameter dilatih
adapter 4.6%  : A 0.977 | B 0.996 | 4616 parameter dilatih

Mari kita telaah kode di atas baris per baris, dengan fokus pada mekanisme membekukan dan menyisipkan:

  1. Baris 27-38: fungsi buat_data menanam sinyal kelas pada satu dimensi fitur; dim_lawan menanam penanda berlawanan. Karena penanda itu berlawanan, model lama tidak sekadar gagal — model lama aktif memilih kelas yang salah, terlihat dari akurasi 0,020 (jauh di bawah tebakan acak 0,500).
  2. Baris 46-57: ModelDasar — MLP tiga lapis dengan 99.330 parameter yang berperan sebagai model pra-latih. Seluruh angka efisiensi berikutnya dibandingkan terhadap jumlah ini.
  3. Baris 61-72: class Adapter — dua Linear kecil (turun 256→4, naik 4→256) plus jalur residual. Baris 67-68 meng-nol-kan bobot dan bias proyeksi naik sehingga koreksi awal nol persis; keluaran bagian 3 membuktikannya dengan selisih logits 0,000000.
  4. Baris 75-88: ModelBeradapter menempatkan sisipan setelah tiap lapis tersembunyi. self.dasar adalah referensi langsung ke model yang sama — bukan salinan konseptual — sehingga membekukannya membekukan model utuh.
  5. Baris 97-107: fungsi latih menyaring [p for p in model.parameters() if p.requires_grad] untuk optimizer. Baris inilah seluruh mekanisme PEFT pada PyTorch: parameter yang tidak masuk daftar tidak menerima gradien dan tidak menempati memori optimizer.
  6. Baris 149-151: pembekuan dilakukan eksplisit — requires_grad_(False) untuk seluruh parameter dasar — lalu dihitung ulang jumlah parameter yang masih dilatih: 4.616 dari 99.330 (4,6%).
  7. Baris 163-172: anggaran penyimpanan dihitung dari jumlah parameter × 4 byte: salinan model utuh 388 KB versus berkas adapter 18 KB per tugas — rasio 1 banding 21.

Tiga angka pada ringkasan menutup argumen. Adapter mencapai akurasi Tugas B 0,996 — di atas full fine-tuning 0,969 — karena kapasitas yang kecil bekerja sebagai regularisasi pada data 512 sampel. Akurasi Tugas A tetap 0,977 karena bobot dasar tidak pernah tersentuh gradien; bandingkan dengan full fine-tuning yang jatuh ke 0,379. Dan ketika adapter aktif pada data Tugas A (0,074), perilakunya menegaskan sifat PEFT: adapter adalah spesialis per tugas yang dipasang-lepas, bukan model serbaguna baru.

Dari Demo ke Pustaka #

Pada praktik nyata kita tidak menulis class adapter sendiri: pustaka PEFT dari Hugging Face menyatukan LoRA, prefix tuning, prompt tuning, adapter, BitFit, dan varian lain di balik satu antarmuka — misalnya get_peft_model(model, config) yang otomatis membekukan bobot dasar dan melaporkan persentase parameter yang dilatih. Hasilnya berformat berkas kecil yang bisa dibagikan dan dipasang ke model dasar yang sama, persis pola 18 KB pada demo kita.

Di antara seluruh keluarga PEFT, LoRA naik menjadi pilihan paling umum di industri: tanpa latensi tambahan saat deployment dan mudah dikombinasikan dengan bobot terkuantisasi 4-bit. Dua sifat itulah yang dibedah tuntas pada chapter berikutnya — dari derivasi ΔW = B·A sampai QLoRA yang membawa fine-tuning model besar ke GPU kecil.