Chapter Parameter-Efficient Fine-Tuning memperlihatkan bahwa fine-tuning penuh satu model 7B pada GPU konsumen bukan perkara mudah: seluruh bobot harus ada di memori sebagai gradien dan status optimizer, padahal hasil adaptasinya sering hanya dibutuhkan pada sebagian kecil kemampuan model. LoRA (Low-Rank Adaptation) menyelesaikan konflik ini dengan asumsi sederhana namun berdaya: perubahan bobot yang dibutuhkan tugas baru memiliki rank rendah, sehingga cukup dimodelkan sebagai perkalian dua matriks kecil. Bab ini membedah mekanikanya sampai ke inisialisasi, membuktikannya pada model attention mini yang benar-benar dilatih, lalu melanjutkan ke QLoRA yang menggabungkan adapter dengan bobot 4-bit.

Inti LoRA: Delta W = B · A #

Fine-tuning penuh mencari bobot baru W' untuk tiap matriks proyeksi. LoRA mempertahankan W dalam keadaan beku dan menambahkan koreksi berbentuk perkalian dua matriks ramping:

text
h = W·x + (α/r)·B·(A·x)

W : matriks beku      (d_out × d_in)
A : dilatih, gaussian (r × d_in)
B : dilatih, nol       (d_out × r)
r : rank, jauh lebih kecil dari d

Tiga keputusan desainnya saling menopang. Pertama, inisialisasi B = 0 membuat ΔW = B·A bernilai nol persis saat pelatihan dimulai — model berperilaku identik dengan model asli, sehingga pelatihan dimulai dari titik yang aman, bukan dari derau acak. Kedua, A diinisialisasi gaussian agar gradien mengalir sejak langkah pertama. Ketiga, faktor α/r mengatur seberapa besar kontribusi adapter relatif terhadap bobot beku; saat rank r dinaikkan, tiap komponen adapter otomatis mengecil supaya skala keluaran tetap terkendali.

LoRA juga selektif soal lokasi. Makalah aslinya menemukan bahwa menempelkan adapter hanya pada proyeksi attention Wq dan Wv sudah menutup sebagian besar kebutuhan adaptasi — Wk dan proyeksi feed-forward boleh tetap beku. Semakin sedikit matriks yang diberi adapter, semakin kecil anggaran parameter yang dilatih.

Demo: Mengalihkan Perhatian Model dengan 4% Parameter #

Kita akan menguji klaim LoRA pada model mini dua blok self-attention (dimensi 64) yang meniru arsitektur transformer dalam bentuk sederhana. Skenarionya meniru siklus adaptasi nyata: model dipra-latih pada Tugas A, lalu diadaptasi ke Tugas B. Pada Tugas A label ditentukan token indeks 1; pada Tugas B sinyal pindah ke token indeks 4 dan token indeks 1 justru membawa penanda berlawanan — model lama yang tidak diadaptasi akan tertipu. Berkas lora_minimal.py menjalankan pra-latih, memasang LoRA hanya pada Wq, mengadu tiga jalur adaptasi, lalu menghitung anggaran parameter:

python
"""LoRA mini: mengadaptasi model attention dengan adapter rank rendah.

Bagian 1: bangun model dua blok self-attention sederhana, lalu pra-latih
          pada Tugas A (label ditentukan oleh token indeks 1).
Bagian 2: bungkus proyeksi Wq dengan LoRA (B nol, A gaussian, skala
          alpha/r) dan buktikan delta W = B @ A nol saat inisialisasi.
Bagian 3: fine-tune pada Tugas B (sinyal pindah ke token indeks 4, token
          indeks 1 menjadi penanda lawan) melalui tiga jalur: full
          fine-tune, LoRA hanya pada Wq, dan model beku total.
Bagian 4: hitung parameter dilatih vs total, lalu simulasi aritmetika
          LoRA pada model 7B parameter.
"""

import copy

import torch
import torch.nn as nn

torch.manual_seed(7)

T, D, N = 6, 64, 256  # panjang sekuens, dimensi, jumlah sampel


class BlokAttention(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.d = d
        self.Wq = nn.Linear(d, d, bias=False)
        self.Wk = nn.Linear(d, d, bias=False)
        self.Wv = nn.Linear(d, d, bias=False)

    def forward(self, x):                       # x: (N, T, d)
        Q, K, V = self.Wq(x), self.Wk(x), self.Wv(x)
        skor = Q @ K.transpose(1, 2) / self.d**0.5
        bobot = skor.softmax(dim=-1)
        return x + bobot @ V                    # residual connection


class LoRALinear(nn.Module):
    def __init__(self, dasar, r=4, alpha=8.0):
        super().__init__()
        self.dasar = dasar                      # bobot beku
        for p in self.dasar.parameters():
            p.requires_grad_(False)
        self.A = nn.Parameter(torch.randn(r, dasar.in_features) * 0.01)
        self.B = nn.Parameter(torch.zeros(dasar.out_features, r))
        self.skala = alpha / r

    def forward(self, x):
        delta = x @ self.A.transpose(0, 1) @ self.B.transpose(0, 1)
        return self.dasar(x) + self.skala * delta


class ModelKecil(nn.Module):
    def __init__(self, d=D, n_blok=2):
        super().__init__()
        self.blok = nn.ModuleList([BlokAttention(d) for _ in range(n_blok)])
        self.kepala = nn.Linear(d, 2)

    def forward(self, x):
        for b in self.blok:
            x = b(x)
        return self.kepala(x.mean(dim=1))       # mean pooling -> klasifikasi


def buat_data(n, idx_positif, idx_negatif, seed):
    g = torch.Generator().manual_seed(seed)
    x = torch.randn(n, T, D, generator=g)
    s = torch.randint(0, 2, (n,), generator=g) * 2 - 1   # sinyal -1 / +1
    x[:, idx_positif, 0] += s * 1.6
    if idx_negatif is not None:
        x[:, idx_negatif, 0] -= s * 1.6         # penanda lawan
    return x, (s > 0).long()


def siklus_latih(model, x, y, n_epoch, lr, titik_jeda):
    opt = torch.optim.Adam(
        [p for p in model.parameters() if p.requires_grad], lr=lr)
    crit = nn.CrossEntropyLoss()
    riwayat = {}
    for ep in range(1, n_epoch + 1):
        opt.zero_grad()
        loss = crit(model(x), y)
        loss.backward()
        opt.step()
        if ep in titik_jeda:
            riwayat[ep] = loss.item()
    return riwayat


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


def jumlah_param(model):
    total = sum(p.numel() for p in model.parameters())
    latih = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return latih, total


# ---------- 1. Pra-latih pada Tugas A ----------
xA, yA = buat_data(N, 1, None, seed=11)
model = ModelKecil()
riwayat = siklus_latih(model, xA, yA, 150, 5e-3, {1, 75, 150})
print("== 1. pra-latih Tugas A (sinyal di token 1) ==")
print(f"loss ep1 {riwayat[1]:.4f} | ep75 {riwayat[75]:.4f} | "
      f"ep150 {riwayat[150]:.4f} | akurasi {akurasi(model, xA, yA):.3f}")

# ---------- 2. Pasang LoRA, cek delta W nol di awal ----------
lora_cek = copy.deepcopy(model)
lora_cek.blok[0].Wq = LoRALinear(lora_cek.blok[0].Wq, r=4, alpha=8.0)
dW = (lora_cek.blok[0].Wq.B @ lora_cek.blok[0].Wq.A).norm().item()
print()
print("== 2. inisialisasi LoRA (r=4, alpha=8) ==")
print(f"norm delta W = B @ A saat awal = {dW:.4f}  (B di-nol-kan)")

# ---------- 3. Fine-tune Tugas B, tiga jalur ----------
xB, yB = buat_data(N, 4, 1, seed=22)            # sinyal di token 4
m_full = copy.deepcopy(model)
m_lora = copy.deepcopy(model)
for i in range(2):
    m_lora.blok[i].Wq = LoRALinear(m_lora.blok[i].Wq, r=4, alpha=8.0)
for p in m_lora.parameters():                  # beku seluruh model dasar
    p.requires_grad_(False)
for i in range(2):                             # hanya A dan B yang dilatih
    m_lora.blok[i].Wq.A.requires_grad_(True)
    m_lora.blok[i].Wq.B.requires_grad_(True)
m_beku = copy.deepcopy(model)

crit = nn.CrossEntropyLoss()
print()
print("== 3. fine-tune Tugas B (sinyal pindah ke token 4, "
      "token 1 jadi penanda lawan) ==")
print(f"loss awal ketiga jalur sama: {crit(m_full(xB), yB).item():.4f}")

r_full = siklus_latih(m_full, xB, yB, 200, 1e-3, {1, 100, 200})
r_lora = siklus_latih(m_lora, xB, yB, 200, 1e-2, {1, 100, 200})
for judul, r, m in (("full fine-tune", r_full, m_full),
                    ("LoRA r=4 (Wq)", r_lora, m_lora)):
    print(f"{judul:16s} ep1 {r[1]:.4f} | ep100 {r[100]:.4f} | "
          f"ep200 {r[200]:.4f} | akurasi {akurasi(m, xB, yB):.3f}")
print(f"{'model beku':16s} loss tetap "
      f"{crit(m_beku(xB), yB).item():.4f} | "
      f"akurasi {akurasi(m_beku, xB, yB):.3f}")

# ---------- 4. Parameter dilatih vs total ----------
latih_full, total = jumlah_param(m_full)
latih_lora, _ = jumlah_param(m_lora)
print()
print("== 4. anggaran parameter model mini ==")
print(f"total parameter        : {total}")
print(f"dilatih full FT        : {latih_full} (100,0%)")
print(f"dilatih LoRA (2x Wq)   : {latih_lora} "
      f"({100 * latih_lora / total:.1f}%)")
for i in range(2):
    dW = (m_lora.blok[i].Wq.B @ m_lora.blok[i].Wq.A).norm().item()
    print(f"norm delta W blok {i} setelah latih: {dW:.4f}")

d_model, n_lapis, r = 4096, 32, 16
per_matriks = r * (d_model + d_model)
total_adapter = per_matriks * 2 * n_lapis      # Wq dan Wv tiap lapis
total_7b = 7_000_000_000
print()
print(f"== simulasi model 7B: d_model={d_model}, {n_lapis} lapis, "
      f"r={r} pada Wq+Wv ==")
print(f"parameter adapter : {total_adapter:,} "
      f"({100 * total_adapter / total_7b:.2f}% dari 7B)")
print(f"memori adapter fp16: {total_adapter * 2 / 1e6:.1f} MB "
      f"vs bobot penuh fp16 {total_7b * 2 / 1e9:.0f} GB")

Output yang Dihasilkan:

text
== 1. pra-latih Tugas A (sinyal di token 1) ==
loss ep1 0.7205 | ep75 0.0000 | ep150 0.0000 | akurasi 1.000

== 2. inisialisasi LoRA (r=4, alpha=8) ==
norm delta W = B @ A saat awal = 0.0000  (B di-nol-kan)

== 3. fine-tune Tugas B (sinyal pindah ke token 4, token 1 jadi penanda lawan) ==
loss awal ketiga jalur sama: 9.5857
full fine-tune   ep1 9.5857 | ep100 0.0192 | ep200 0.0039 | akurasi 1.000
LoRA r=4 (Wq)    ep1 9.5857 | ep100 0.2376 | ep200 0.1967 | akurasi 0.980
model beku       loss tetap 9.5857 | akurasi 0.457

== 4. anggaran parameter model mini ==
total parameter        : 24706
dilatih full FT        : 24706 (100,0%)
dilatih LoRA (2x Wq)   : 1024 (4.1%)
norm delta W blok 0 setelah latih: 6.7451
norm delta W blok 1 setelah latih: 10.2221

== simulasi model 7B: d_model=4096, 32 lapis, r=16 pada Wq+Wv ==
parameter adapter : 8,388,608 (0.12% dari 7B)
memori adapter fp16: 16.8 MB vs bobot penuh fp16 14 GB

Mari kita telaah kode di atas baris per baris:

  1. Baris 24-36 (BlokAttention): satu blok attention satu kepala — Wq, Wk, Wv memproyeksikan sekuens, skor Q·Kᵀ/√d dinormalisasi softmax menjadi bobot perhatian, keluaran bobot·V ditambahkan kembali ke masukan lewat residual. Dua matriks inilah tempat adapter akan ditempel.
  2. Baris 39-51 (LoRALinear): inti bab ini. Baris 43-44 membekukan bobot dasar; baris 45 menginisialisasi A gaussian kecil; baris 46 menginisialisasi B nol sehingga ΔW = B·A = 0 — terbukti pada keluaran bagian 2 (norm 0.0000). Baris 50 menghitung A·x lalu B·(A·x) (efisien: matriks (r×d) dan (d×r) bekerja pada vektor rank rendah, bukan membentuk ΔW penuh), dan baris 51 menerapkan skala α/r = 8/4 = 2.
  3. Baris 66-73 (buat_data): sekuens acak dengan sinyal ±1,6 pada satu fitur token tertentu; idx_negatif menanam penanda berlawanan sehingga rata-rata pooling tidak bisa menyontek — satu-satunya jalan adalah mengarahkan perhatian ke token yang benar.
  4. Baris 122-128: seluruh salinan model dibekukan, lalu hanya A dan B pada Wq kedua blok yang diaktifkan kembali — persis pola requires_grad pada LoRA sungguhan.
  5. Baris 137-145: dua jalur dilatih, jalur beku hanya dievaluasi. LoRA mencapai akurasi 0,980 dengan 1.024 parameter dilatih (4,1%), mendekati full fine-tune 1,000 dengan 24.706 parameter — sementara model beku tertahan di 0,457 karena perhatiannya masih tertuju pada token 1 yang kini menyesatkan.
  6. Baris 160-170: aritmetika skala nyata. Adapter rank 16 pada Wq+Wv sepanjang 32 lapisan model 7B hanya menambah 8.388.608 parameter (0,12%) atau 16,8 MB dalam fp16 — satu adapter baru bisa disimpan sebagai berkas kecil, bukan salinan model 14 GB.

QLoRA: LoRA di Atas Model 4-bit #

LoRA memangkas biaya gradien dan optimizer, tetapi bobot beku tetap harus dimuat dalam fp16 — model 7B tetap memakan 14 GB sebelum satu pun langkah pelatihan dijalankan. QLoRA meruntuhkan tembok terakhir itu dengan tiga teknik yang bekerja bersama:

  • Kuantisasi NF4: bobot beku disimpan sebagai 4-bit pada grid kuantil normal — bobot neural network terdistribusi mendekati gaussian, sehingga grid yang rapat di tengah distribusi merepresentasikan bobot lebih akurat daripada grid seragam. Selama forward dan backward, tiap bobot di-dekuantisasi ke bf16 hanya saat dibutuhkan.
  • Double quantization: konstanta skala kuantisasi itu sendiri dikuantisasi lagi, menghemat rata-rata 0,37 bit per parameter.
  • Paged optimizer: status optimizer dipindahkan sementara ke memori CPU ketika memori GPU hampir penuh, sehingga pelatihan panjang tidak gagal di tengah jalan.

Hasil gabungannya: fine-tuning model 65B menjadi mungkin pada satu GPU 48 GB, dan model 7-8B dapat diadaptasi pada laptop dengan memori 16 GB. Gradien tetap mengalir hanya ke adapter LoRA (dalam bf16), sementara bobot 4-bit tidak pernah diperbarui langsung.

Memilih Jalur Adaptasi #

AspekFull fine-tuneLoRAQLoRA
Bobot dasarfp16/bf16, diperbaruifp16/bf16, beku4-bit NF4, beku
Yang dilatihsemua parameteradapter A, Badapter A, B (bf16)
Memori model 7B (perkiraan)> 50 GB dengan optimizer± 16 GB± 5-6 GB
Penyimpanan per varian tugassalinan model penuh (14 GB)berkas adapter (~ 17-170 MB)berkas adapter
Kualitasbatas atasumumnya setarasedikit di bawah LoRA
Kecepatan langkahtercepat per epochmendekati fullpaling lambat (dekuantisasi)

Pola pemakaiannya pun jelas: QLoRA saat memori terbatas, LoRA saat memori cukup namun banyak varian tugas harus disimpan, full fine-tune saat tugas benar-benar berbeda dari domain pra-latih. Implementasi produksi untuk semuanya tersedia di pustaka PEFT (mendukung LoraConfig, prepare_model_for_kbit_training, hingga SFTTrainer), sementara derivasi rank-rendah dan hasil percobaannya dibuktikan pada makalah LoRA: Low-Rank Adaptation of Large Language Models serta analisis NF4 pada makalah QLoRA .

LoRA menuntaskan sisi efisiensi dari adaptasi model, namun adaptasi terhadap instruksi saja belum menjamin model memilih jawaban yang paling disukai manusia. Dua tahap lagi dibutuhkan: membangun preferensi, lalu memindahkannya ke dalam kebijakan model — itulah panggung Preference Optimization .