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:
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 dTiga 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:
"""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:
== 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 GBMari kita telaah kode di atas baris per baris:
- Baris 24-36 (
BlokAttention): satu blok attention satu kepala —Wq,Wk,Wvmemproyeksikan sekuens, skorQ·Kᵀ/√ddinormalisasi softmax menjadi bobot perhatian, keluaranbobot·Vditambahkan kembali ke masukan lewat residual. Dua matriks inilah tempat adapter akan ditempel. - Baris 39-51 (
LoRALinear): inti bab ini. Baris 43-44 membekukan bobot dasar; baris 45 menginisialisasiAgaussian kecil; baris 46 menginisialisasiBnol sehinggaΔW = B·A = 0— terbukti pada keluaran bagian 2 (norm 0.0000). Baris 50 menghitungA·xlaluB·(A·x)(efisien: matriks(r×d)dan(d×r)bekerja pada vektor rank rendah, bukan membentukΔWpenuh), dan baris 51 menerapkan skalaα/r = 8/4 = 2. - Baris 66-73 (
buat_data): sekuens acak dengan sinyal ±1,6 pada satu fitur token tertentu;idx_negatifmenanam penanda berlawanan sehingga rata-rata pooling tidak bisa menyontek — satu-satunya jalan adalah mengarahkan perhatian ke token yang benar. - Baris 122-128: seluruh salinan model dibekukan, lalu hanya
AdanBpadaWqkedua blok yang diaktifkan kembali — persis polarequires_gradpada LoRA sungguhan. - 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.
- Baris 160-170: aritmetika skala nyata. Adapter rank 16 pada
Wq+Wvsepanjang 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 #
| Aspek | Full fine-tune | LoRA | QLoRA |
|---|---|---|---|
| Bobot dasar | fp16/bf16, diperbarui | fp16/bf16, beku | 4-bit NF4, beku |
| Yang dilatih | semua parameter | adapter A, B | adapter A, B (bf16) |
| Memori model 7B (perkiraan) | > 50 GB dengan optimizer | ± 16 GB | ± 5-6 GB |
| Penyimpanan per varian tugas | salinan model penuh (14 GB) | berkas adapter (~ 17-170 MB) | berkas adapter |
| Kualitas | batas atas | umumnya setara | sedikit di bawah LoRA |
| Kecepatan langkah | tercepat per epoch | mendekati full | paling 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 .