Parameter-Efficient Fine-Tuning: the family
Five papers, one question: where do you plug the small trainable thing into the big frozen one?
Download peft_from_scratch.py — everything below runs from it. NumPy only.
I'm an AI Engineer. I've run get_peft_model() in production,
picked r=8 because the tutorial did, and I still couldn't tell
you why an adapter adds latency and a LoRA doesn't. Five papers, five
acronyms, and nobody told me they are one paper asked five ways.
PEFT is middleware
You know this. The framework handles the request. You don't fork it:
def handle(request): # the framework. Frozen. Not yours.
return framework.handle(request)
app.use(my_middleware) # small, yours, sits in the request path
Full fine-tuning is forking the framework: every file is yours now, one copy per task. PEFT is registering middleware. Freeze the base, put one small trainable thing in the path, train only that:
for p in model.parameters():
p.requires_grad = False # the whole method is this line
def adapter(h): # Houlsby 2019. h flows through untouched
return h + W_up @ relu(W_down @ h) # plus a tiny, learned diff
Every method on this page is that, with the hook somewhere else.
| Term | Means | Programmer's analogy |
|---|---|---|
| Frozen base | The pretrained weights. Never updated | The framework you pip install |
| Hook | The few parameters that do train | Your middleware |
Residual h + f(h) | The hook proposes a diff, not a replacement | A skip link. next() still runs |
| Bottleneck | Squeeze d → m → d with m ≪ d | A thin interface, not a second framework |
| Merge | Fold the hook back into the frozen weights | Inlining. Zero cost at runtime |
Did it beat full fine-tuning? Not on accuracy — on everything else. Full fine-tuning BERT-large moves 340M parameters and stores a 340M checkpoint per task. Houlsby's adapters train about 3.6% of the parameters and land within 0.4 points of full fine-tuning on GLUE. So the interview question is never "which is more accurate". It's "where does each go, what does it cost to train, what does it cost to serve".
The one formula
Read it inside-out, as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
h | (n, d) | The sublayer's output. n tokens, d wide. Frozen code made it |
Wdown | (d, m) | Squeeze. d=64 down to m=8. This is where the saving lives |
ReLU | (n, m) | A nonlinearity, so the two matrices don't collapse into one |
Wup | (m, d) | Unsqueeze. Back to d. Initialised to zero |
h + | (n, d) | Skip link. Shape unchanged, so it drops into any block |
Two per block in the paper: after attention and after the feed-forward, before each add-and-norm. Houlsby also unfreezes the layer norms; I leave those frozen to keep the count clean.
Five hooks, one frozen block
The script builds one block — d=64, one head, FFN 4d — as a dict of
arrays. Nothing in it trains. Each method is a function that returns
only its trainable thing, and n_params() counts it.
attention (W_q, W_k, W_v, W_o): 16,384
feed-forward (W_1, b_1, W_2, b_2): 33,088
two layer norms: 256
TOTAL frozen params: 49,728
method per block % block x12 blocks
full fine-tuning 49,728 100.00% 49,728
adapters, m=8 (x2) 2,192 4.41% 26,304
prefix tuning, p=8 1,024 2.06% 12,288
prompt tuning, p=8 512 1.03% 512
(IA)^3 384 0.77% 4,608
LoRA r=8 on W_q, W_v 2,048 4.12% 24,576
Every method except prompt tuning pays per layer; prompt tuning pays once, at the input, however deep the model is. The percentages are high only because the toy is tiny — a hook grows with d, a block with d² — which is how these become Houlsby's 3.6% and (IA)³'s 0.01% at real widths. And this is the table you'll be asked for:
| Method | Where the hook goes | Trainable (toy) | Inference cost |
|---|---|---|---|
| Adapter Houlsby 2019 | After each sublayer, inside the residual | 2,192 (4.41%) | Extra sequential matmuls. Latency |
| Prefix tuning Li & Liang 2021 | p extra K and V rows, every layer | 1,024 (2.06%) | +p entries in every layer's KV cache |
| Prompt tuning Lester 2021 | p extra input rows, once | 512 (1.03%) | +p tokens through the whole stack |
| (IA)³ Liu 2022 | Scaling vectors on K, V and the FFN hidden | 384 (0.77%) | Zero, after merging |
| LoRA Hu 2021 | Low-rank diff on Wq, Wv | 2,048 (4.12%) | Zero, after merging |
Step 0 must be a no-op
A hook that changes the output before training has thrown the pretrained model away on the first forward pass. Houlsby's fix: zero the up-projection. Which of the five start clean?
max |block_with_hook(X) - block(X)| before any training:
adapters, W_up = 0: 0.000000
adapters, W_up random: 0.376937 ← not a no-op
(IA)^3, vectors = 1: 0.000000
prefix, K and V = 0: 0.277744 ← not a no-op
prompt, 8 sampled tokens: 0.491051 ← not a no-op
Adapter and (IA)³: byte-identical. The prefix is not, even zeroed — a zero key still scores exp(0) = 1 in the softmax, so it takes attention mass from real tokens before anything is learned. Prompt tokens likewise: they are seen. LoRA starts clean for the adapter's reason: B = 0.
Prefix tuning: keys decide, values deliver
Li and Liang moved the hook into attention. Prepend p learned vectors to the keys and p to the values, every layer, so real tokens' queries have p extra slots to look at. Nothing passes through Wk or Wv: the prefix is the keys and values. The paper trains about 0.1% of the parameters this way on GPT-2 and BART (via a small MLP for stability, thrown away after; I skip it). Now learn only the values:
attention weights shape with p=4 prefix rows: (16, 20) (n, p+n)
weight each query puts on the 4 prefix slots (first 3 queries):
[[0.043 0.027 0.05 0.047]
[0.071 0.061 0.017 0.067]
[0.083 0.04 0.023 0.007]]
BREAK IT: train V only, leave the prefix keys at zero
[[0.035 0.035 0.035 0.035]
[0.027 0.027 0.027 0.027]
[0.032 0.032 0.032 0.032]]
change every value row, max |weights diff|: 0.0
With learned keys each query reads the four slots differently. With zero keys the columns are identical and redrawing every value vector moves the weights by exactly 0: the prefix has collapsed into one fixed vector added to everyone. Keys are the addressing, values the payload. You need both.
Prompt tuning: the hook is in the input
Lester et al. asked the smallest version of the question: what if the only trainable thing is p token embeddings, prepended once at the input? No hook in any block; they are ordinary positions and every layer sees them.
input: 8 prompt rows + 16 real rows = (24, 64)
attention mass real tokens put on the prompt:
mean 0.340 min 0.196 max 0.546 (uniform would be 0.333)
BREAK IT: read the output at row 0
out.shape = (24, 64) ← 8 of those rows are the prompt
out[0] belongs to prompt[0] out[8] belongs to real token 0
the answer is out[8:], shape (16, 64)
A third of the attention mass, on tokens nobody has trained yet (sampled
from real token vectors, the paper's recommended init). Training moves
them so what real tokens read there is useful. And the output has 24 rows:
out[0] is a prompt token's output. Slice the prompt off.
(IA)³ and LoRA: hooks that fold away
Liu et al. went smaller still. (IA)³ learns three vectors per block — one scales the keys, one the values, one the FFN hidden — and nothing else. 384 numbers here, about 0.01% of a real model. A scale is a multiply, so afterwards you fold it into the frozen matrix (columns of Wk and Wv, rows of W2) and the hook is gone. That's the column the family is really sorted by:
base block FLOPs: 1,638,400
method extra FLOPs extra tokens adds a step?
adapters, m=8 65,536 0 yes
prefix, p=8 32,768 8 no
prompt, p=8 868,352 8 no
(IA)^3, unmerged 6,144 0 no
(IA)^3, merged 0 0 no
LoRA, merged 0 0 no
(IA)^3 merge check, max |hooked - merged|: 1.33e-15 identical? True
The adapter's 65,536 extra FLOPs are 4% of the block. Small. But they sit in the residual path: the block can't finish until the adapter does, a serial delay paid every layer, every token. That was Houlsby's cost, and the gap LoRA closed: put the diff on the weight matrix itself, W + BA, and merge it after training. Same idea, zero serving cost. It has its own page; I won't re-explain it here.
Prefix and prompt tuning don't add a step, they widen one. But the prompt row is 868,352 extra FLOPs, half the block again: 8 more tokens through every matmul in every layer. Nothing is free; pick where.
நான் ஒரு AI Engineer. Production-ல் get_peft_model()
ஓட்டியிருக்கிறேன், tutorial சொன்னதால் r=8 வைத்திருக்கிறேன் —
இருந்தும் ஒரு adapter ஏன் latency சேர்க்கிறது, LoRA ஏன் சேர்க்கவில்லை
என்று சொல்ல முடியவில்லை. ஐந்து papers, ஐந்து acronyms; இவை ஒரே கேள்வியை
ஐந்து விதமாகக் கேட்கின்றன என்று யாரும் சொல்லவில்லை.
PEFT என்பது middleware
இது உங்களுக்குத் தெரியும். Framework request-ஐக் கையாளும். நீங்கள் அதை fork செய்வதில்லை:
def handle(request): # framework. Frozen. உங்களுடையது இல்லை.
return framework.handle(request)
app.use(my_middleware) # சிறியது, உங்களுடையது, request path-ல்
Full fine-tuning என்பது framework-ஐ fork செய்வது: ஒவ்வொரு file-ம் இப்போது உங்களுடையது, ஒரு task-க்கு ஒரு நகல். PEFT என்பது middleware register செய்வது. Base-ஐ freeze செய்யுங்கள், path-ல் ஒரு சிறிய trainable பொருளை வையுங்கள், அதை மட்டும் train செய்யுங்கள்:
for p in model.parameters():
p.requires_grad = False # முழு method-ம் இந்த வரிதான்
def adapter(h): # Houlsby 2019. h தொடாமல் பாய்கிறது
return h + W_up @ relu(W_down @ h) # கூட ஒரு சிறிய, கற்ற diff
இந்தப் பக்கத்தின் ஒவ்வொரு method-ம் இதுதான் — hook வேறு இடத்தில் இருக்கும், அவ்வளவே.
| சொல் | அர்த்தம் | Programmer-க்கான உவமை |
|---|---|---|
| Frozen base | Pretrained weights. ஒருபோதும் update ஆகாது | நீங்கள் pip install செய்யும் framework |
| Hook | உண்மையில் train ஆகும் சில parameters | உங்கள் middleware |
Residual h + f(h) | Hook ஒரு diff-ஐ முன்வைக்கிறது, மாற்றீட்டை அல்ல | ஒரு skip link. next() இன்னும் ஓடும் |
| Bottleneck | d → m → d என அழுத்துதல், m ≪ d | ஒரு மெல்லிய interface, இரண்டாவது framework அல்ல |
| Merge | Hook-ஐ frozen weights-க்குள் மடக்குதல் | Inlining. Runtime-ல் பூஜ்ஜியச் செலவு |
Full fine-tuning-ஐ இது வென்றதா? Accuracy-ல் இல்லை — மற்ற எல்லாவற்றிலும். BERT-large-ஐ full fine-tune செய்தால் 340M parameters நகரும், ஒரு task-க்கு ஒரு 340M checkpoint சேமிக்க வேண்டும். Houlsby-யின் adapters சுமார் 3.6% parameters-ஐ மட்டும் train செய்து, GLUE-ல் full fine-tuning-க்கு 0.4 புள்ளிகளுக்குள் வருகின்றன. எனவே interview கேள்வி ஒருபோதும் "எது அதிக accurate" அல்ல. "எது எங்கே செல்கிறது, train செய்ய என்ன செலவு, serve செய்ய என்ன செலவு" — அதுதான்.
PEFT = middleware. Base-ai freeze pannitu, request path-la oru chinna trainable hook mattum vachu train pannuratu. Accuracy vidha, cost dhaan kelvi.
ஒரே formula
உள்ளிருந்து வெளியே, pipeline stages-ஆகப் படியுங்கள்:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
h | (n, d) | Sublayer-ன் output. n tokens, d அகலம். Frozen code உருவாக்கியது |
Wdown | (d, m) | அழுத்து. d=64 இலிருந்து m=8. சேமிப்பு இங்குதான் இருக்கிறது |
ReLU | (n, m) | ஒரு nonlinearity, இல்லையேல் இரு matrices ஒன்றாகச் சுருங்கிவிடும் |
Wup | (m, d) | விரி. மீண்டும் d-க்கு. பூஜ்ஜியமாக initialise |
h + | (n, d) | Skip link. Shape மாறவில்லை, எனவே எந்த block-லும் பொருந்தும் |
Paper-ல் ஒரு block-க்கு இரண்டு: attention-க்குப் பிறகு ஒன்று, feed-forward-க்குப் பிறகு ஒன்று, ஒவ்வொரு add-and-norm-க்கு முன்பாக. Houlsby layer norms-ஐயும் unfreeze செய்கிறார்; எண்ணிக்கை சுத்தமாக இருக்க நான் அவற்றை frozen-ஆகவே விடுகிறேன்.
ஐந்து hooks, ஒரே frozen block
Script ஒரு block-ஐ — d=64, ஒரு head, FFN 4d — arrays-ன் dict-ஆகக்
கட்டுகிறது. அதில் எதுவும் train ஆகாது. ஒவ்வொரு method-ம் தன் trainable
பொருளை மட்டும் திருப்பித் தரும் function; n_params() அதை
எண்ணும்.
attention (W_q, W_k, W_v, W_o): 16,384
feed-forward (W_1, b_1, W_2, b_2): 33,088
two layer norms: 256
TOTAL frozen params: 49,728
method per block % block x12 blocks
full fine-tuning 49,728 100.00% 49,728
adapters, m=8 (x2) 2,192 4.41% 26,304
prefix tuning, p=8 1,024 2.06% 12,288
prompt tuning, p=8 512 1.03% 512
(IA)^3 384 0.77% 4,608
LoRA r=8 on W_q, W_v 2,048 4.12% 24,576
Prompt tuning தவிர ஒவ்வொரு method-ம் layer-க்கு layer கட்டணம் செலுத்துகிறது; prompt tuning ஒரே ஒரு முறை, input-ல், model எவ்வளவு ஆழமானாலும். இந்தச் சதவீதங்கள் அதிகமாக இருப்பதற்குக் காரணம் இந்த toy மிகச் சிறியது — hook d-உடன் வளரும், block d²-உடன் வளரும் — அதனால்தான் உண்மையான அகலங்களில் இவை Houlsby-யின் 3.6%, (IA)³-ன் 0.01% ஆகின்றன. உங்களிடம் கேட்கப்படும் table இது:
| Method | Hook எங்கே செல்கிறது | Trainable (toy) | Inference செலவு |
|---|---|---|---|
| Adapter Houlsby 2019 | ஒவ்வொரு sublayer-க்குப் பின், residual-க்குள் | 2,192 (4.41%) | கூடுதல் தொடர் matmuls. Latency |
| Prefix tuning Li & Liang 2021 | ஒவ்வொரு layer-லும் p கூடுதல் K, V வரிசைகள் | 1,024 (2.06%) | ஒவ்வொரு layer-ன் KV cache-ல் +p entries |
| Prompt tuning Lester 2021 | p கூடுதல் input வரிசைகள், ஒரே முறை | 512 (1.03%) | முழு stack-ஊடாகவும் +p tokens |
| (IA)³ Liu 2022 | K, V மற்றும் FFN hidden-ல் scaling vectors | 384 (0.77%) | Merge செய்தபின் பூஜ்ஜியம் |
| LoRA Hu 2021 | Wq, Wv-ல் low-rank diff | 2,048 (4.12%) | Merge செய்தபின் பூஜ்ஜியம் |
Step 0 ஒரு no-op ஆக இருக்க வேண்டும்
Train செய்வதற்கு முன்பே output-ஐ மாற்றும் hook, முதல் forward pass-லேயே pretrained model-ஐத் தூக்கி எறிந்துவிட்டது. Houlsby-யின் தீர்வு: up-projection-ஐப் பூஜ்ஜியமாக்குங்கள். ஐந்தில் எவை சுத்தமாகத் தொடங்குகின்றன?
max |block_with_hook(X) - block(X)| before any training:
adapters, W_up = 0: 0.000000
adapters, W_up random: 0.376937 ← no-op இல்லை
(IA)^3, vectors = 1: 0.000000
prefix, K and V = 0: 0.277744 ← no-op இல்லை
prompt, 8 sampled tokens: 0.491051 ← no-op இல்லை
Adapter மற்றும் (IA)³: byte-identical. Prefix அப்படி இல்லை, பூஜ்ஜியமாக்கினாலும் — பூஜ்ஜியமான key-க்கும் softmax-ல் exp(0) = 1 மதிப்பு கிடைக்கும், எனவே எதுவும் கற்கப்படுவதற்கு முன்பே அது உண்மையான tokens-ல் இருந்து attention mass-ஐ எடுத்துக்கொள்கிறது. Prompt tokens-ம் அப்படியே: அவை பார்க்கப்படுகின்றன. Adapter-ன் அதே காரணத்திற்காக LoRA சுத்தமாகத் தொடங்குகிறது: B = 0.
Prefix tuning: keys முகவரி, values சரக்கு
Li மற்றும் Liang hook-ஐ attention-க்குள் நகர்த்தினார்கள். ஒவ்வொரு layer-லும் keys-ன் முன் p கற்ற vectors, values-ன் முன் p vectors சேருங்கள்; அப்போது உண்மையான tokens-ன் queries-க்குப் பார்க்க p கூடுதல் இடங்கள் கிடைக்கும். எதுவும் Wk அல்லது Wv வழியாகப் போவதில்லை: prefix தானே keys மற்றும் values. GPT-2, BART-ல் இப்படிச் சுமார் 0.1% parameters-ஐ paper train செய்கிறது (நிலைத்தன்மைக்காக ஒரு சிறிய MLP வழியாக, பிறகு தூக்கி எறியப்படும்; நான் அதைத் தவிர்க்கிறேன்). இப்போது values-ஐ மட்டும் கற்றுப் பாருங்கள்:
attention weights shape with p=4 prefix rows: (16, 20) (n, p+n)
weight each query puts on the 4 prefix slots (first 3 queries):
[[0.043 0.027 0.05 0.047]
[0.071 0.061 0.017 0.067]
[0.083 0.04 0.023 0.007]]
BREAK IT: train V only, leave the prefix keys at zero
[[0.035 0.035 0.035 0.035]
[0.027 0.027 0.027 0.027]
[0.032 0.032 0.032 0.032]]
change every value row, max |weights diff|: 0.0
கற்ற keys இருக்கும்போது ஒவ்வொரு query-ம் அந்த நான்கு இடங்களையும் வெவ்வேறாகப் படிக்கிறது. Keys பூஜ்ஜியமாக இருந்தால் columns ஒரே மாதிரி ஆகிவிடுகின்றன; ஒவ்வொரு value vector-ஐயும் மீண்டும் வரைந்தாலும் weights சரியாக 0 அளவே நகர்கின்றன: prefix எல்லாருக்கும் சேர்க்கப்படும் ஒரே நிலையான vector-ஆகச் சுருங்கிவிட்டது. Keys முகவரி, values சரக்கு. இரண்டும் வேண்டும்.
Prefix-la keys dhaan address, values dhaan payload. Keys zero-va irundha ella query-um oreye maari padikkum, value maathinaalum weights nagara maatta.
Prompt tuning: hook input-ல் இருக்கிறது
Lester et al. இந்தக் கேள்வியின் மிகச் சிறிய வடிவத்தைக் கேட்டார்கள்: train ஆகும் ஒரே பொருள் p token embeddings மட்டும், input-ல் ஒரே முறை முன்னால் சேர்க்கப்பட்டால் என்ன? எந்த block-லும் hook இல்லை; அவை சாதாரண positions, ஒவ்வொரு layer-ம் அவற்றைப் பார்க்கிறது.
input: 8 prompt rows + 16 real rows = (24, 64)
attention mass real tokens put on the prompt:
mean 0.340 min 0.196 max 0.546 (uniform would be 0.333)
BREAK IT: read the output at row 0
out.shape = (24, 64) ← இதில் 8 வரிசைகள் prompt
out[0] belongs to prompt[0] out[8] belongs to real token 0
the answer is out[8:], shape (16, 64)
Attention mass-ல் மூன்றில் ஒரு பங்கு, இதுவரை யாரும் train செய்யாத tokens-ன்
மீது (உண்மையான token vectors-ல் இருந்து sample செய்யப்பட்டவை, paper பரிந்துரைக்கும்
init). Training அவற்றை நகர்த்தி, உண்மையான tokens அங்கே படிப்பதைப் பயனுள்ளதாக
ஆக்குகிறது. மேலும் output-ல் 24 வரிசைகள்: out[0] என்பது ஒரு
prompt token-ன் output. Prompt-ஐ வெட்டி நீக்குங்கள்.
(IA)³ மற்றும் LoRA: மடங்கி மறையும் hooks
Liu et al. இன்னும் சிறியதாகப் போனார்கள். (IA)³ ஒரு block-க்கு மூன்று vectors-ஐக் கற்கிறது — ஒன்று keys-ஐ scale செய்யும், ஒன்று values-ஐ, ஒன்று FFN hidden-ஐ — வேறு எதுவும் இல்லை. இங்கே 384 எண்கள், உண்மையான model-ல் சுமார் 0.01%. Scale என்பது ஒரு பெருக்கல், எனவே பிறகு அதை frozen matrix-க்குள் மடக்கிவிடலாம் (Wk, Wv-ன் columns, W2-ன் rows) — hook மறைந்துவிடும். இந்தக் குடும்பம் உண்மையில் வரிசைப்படுத்தப்படுவது இந்த column-ஆல்தான்:
base block FLOPs: 1,638,400
method extra FLOPs extra tokens adds a step?
adapters, m=8 65,536 0 yes
prefix, p=8 32,768 8 no
prompt, p=8 868,352 8 no
(IA)^3, unmerged 6,144 0 no
(IA)^3, merged 0 0 no
LoRA, merged 0 0 no
(IA)^3 merge check, max |hooked - merged|: 1.33e-15 identical? True
Adapter-ன் கூடுதல் 65,536 FLOPs என்பது block-ன் 4%. சிறியது. ஆனால் அவை residual path-க்கு உள்ளே அமர்ந்திருக்கின்றன: adapter முடியும் வரை block முடியாது — ஒவ்வொரு layer-லும், ஒவ்வொரு token-க்கும் செலுத்தப்படும் தொடர் தாமதம். அதுதான் Houlsby-யின் செலவு; அந்த இடைவெளியை LoRA மூடியது: diff-ஐ weight matrix-ன் மீதே வையுங்கள், W + BA, training-க்குப் பின் merge செய்யுங்கள். அதே கருத்து, serving செலவு பூஜ்ஜியம். அதற்குத் தனிப் பக்கம் உண்டு; இங்கே மீண்டும் விளக்கவில்லை.
Prefix-ம் prompt tuning-ம் ஒரு step சேர்ப்பதில்லை, ஒன்றை அகலமாக்குகின்றன. ஆனால் prompt வரிசை 868,352 கூடுதல் FLOPs — மீண்டும் அரை block: ஒவ்வொரு layer-ன் ஒவ்வொரு matmul-ஊடாகவும் 8 கூடுதல் tokens. எதுவும் இலவசம் இல்லை; எங்கே செலவழிப்பது என்பதைத் தேர்ந்தெடுங்கள்.
मैं एक AI Engineer हूँ। मैंने production में get_peft_model()
चलाया है, r=8 इसलिए चुना क्योंकि tutorial में वही था — फिर भी
मैं बता नहीं सकता था कि adapter latency क्यों जोड़ता है और LoRA क्यों नहीं।
पाँच papers, पाँच acronyms, और किसी ने नहीं बताया कि ये एक ही paper हैं जो
पाँच तरह से पूछा गया है।
PEFT एक middleware है
यह आप जानते हैं। Framework request सँभालता है। आप उसे fork नहीं करते:
def handle(request): # framework. Frozen. आपका नहीं।
return framework.handle(request)
app.use(my_middleware) # छोटा, आपका, request path में बैठा
Full fine-tuning यानी framework को fork करना: अब हर file आपकी है, हर task के लिए एक copy। PEFT यानी middleware register करना। Base को freeze करो, path में एक छोटी trainable चीज़ रखो, और सिर्फ़ उसी को train करो:
for p in model.parameters():
p.requires_grad = False # पूरा method यही एक लाइन है
def adapter(h): # Houlsby 2019. h बिना छुए बहता है
return h + W_up @ relu(W_down @ h) # साथ में एक छोटा सीखा हुआ diff
इस पेज का हर method यही है — बस hook किसी और जगह पर।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Frozen base | Pretrained weights. कभी update नहीं होते | वह framework जो आप pip install करते हैं |
| Hook | वे थोड़े parameters जो सचमुच train होते हैं | आपका middleware |
Residual h + f(h) | Hook एक diff सुझाता है, replacement नहीं | एक skip link. next() फिर भी चलता है |
| Bottleneck | d → m → d, जहाँ m ≪ d | एक पतला interface, दूसरा framework नहीं |
| Merge | Hook को frozen weights में वापस मोड़ देना | Inlining. Runtime पर शून्य लागत |
क्या इसने full fine-tuning को हराया? Accuracy में नहीं — बाक़ी हर चीज़ में। BERT-large का full fine-tuning 340M parameters हिलाता है और हर task के लिए 340M का checkpoint रखता है। Houlsby के adapters लगभग 3.6% parameters train करते हैं और GLUE पर full fine-tuning से 0.4 अंक के भीतर रहते हैं। इसलिए interview का सवाल कभी "कौन ज़्यादा accurate है" नहीं होता। होता है "कौन कहाँ लगता है, train करने की लागत क्या, serve करने की लागत क्या"।
एक ही formula
इसे अंदर से बाहर, pipeline stages की तरह पढ़िए:
| चरण | Shape | क्या करता है |
|---|---|---|
h | (n, d) | Sublayer का output. n tokens, d चौड़ाई। इसे frozen code ने बनाया |
Wdown | (d, m) | दबाओ। d=64 से m=8 तक। बचत यहीं है |
ReLU | (n, m) | एक nonlinearity, वरना दोनों matrices एक में सिमट जातीं |
Wup | (m, d) | वापस फैलाओ। फिर से d तक। शून्य से initialise |
h + | (n, d) | Skip link. Shape वही, इसलिए किसी भी block में फिट हो जाता है |
Paper में हर block पर दो: attention के बाद और feed-forward के बाद, हर add-and-norm से पहले। Houlsby layer norms भी unfreeze करते हैं; गिनती साफ़ रखने के लिए मैं उन्हें frozen ही छोड़ता हूँ।
पाँच hooks, एक frozen block
Script एक block बनाता है — d=64, एक head, FFN 4d — arrays की एक dict के रूप
में। उसमें कुछ भी train नहीं होता। हर method एक function है जो सिर्फ़ अपनी
trainable चीज़ लौटाता है, और n_params() उसे गिनता है।
attention (W_q, W_k, W_v, W_o): 16,384
feed-forward (W_1, b_1, W_2, b_2): 33,088
two layer norms: 256
TOTAL frozen params: 49,728
method per block % block x12 blocks
full fine-tuning 49,728 100.00% 49,728
adapters, m=8 (x2) 2,192 4.41% 26,304
prefix tuning, p=8 1,024 2.06% 12,288
prompt tuning, p=8 512 1.03% 512
(IA)^3 384 0.77% 4,608
LoRA r=8 on W_q, W_v 2,048 4.12% 24,576
Prompt tuning को छोड़कर हर method हर layer पर क़ीमत चुकाता है; prompt tuning एक ही बार चुकाता है, input पर, चाहे model कितना भी गहरा हो। प्रतिशत सिर्फ़ इसलिए बड़े दिख रहे हैं क्योंकि यह toy बहुत छोटा है — hook d के साथ बढ़ता है, block d² के साथ — और इसी वजह से असली चौड़ाइयों पर ये Houlsby के 3.6% और (IA)³ के 0.01% बन जाते हैं। और यही वह table है जो आपसे माँगा जाएगा:
| Method | Hook कहाँ लगता है | Trainable (toy) | Inference लागत |
|---|---|---|---|
| Adapter Houlsby 2019 | हर sublayer के बाद, residual के अंदर | 2,192 (4.41%) | अतिरिक्त क्रमिक matmuls. Latency |
| Prefix tuning Li & Liang 2021 | हर layer में p अतिरिक्त K और V rows | 1,024 (2.06%) | हर layer के KV cache में +p entries |
| Prompt tuning Lester 2021 | p अतिरिक्त input rows, सिर्फ़ एक बार | 512 (1.03%) | पूरे stack से गुज़रते +p tokens |
| (IA)³ Liu 2022 | K, V और FFN hidden पर scaling vectors | 384 (0.77%) | Merge के बाद शून्य |
| LoRA Hu 2021 | Wq, Wv पर low-rank diff | 2,048 (4.12%) | Merge के बाद शून्य |
Step 0 का no-op होना ज़रूरी है
जो hook training से पहले ही output बदल देता है, उसने पहले ही forward pass में pretrained model को फेंक दिया। Houlsby का हल: up-projection को शून्य कर दो। पाँच में से कौन साफ़ शुरुआत करते हैं?
max |block_with_hook(X) - block(X)| before any training:
adapters, W_up = 0: 0.000000
adapters, W_up random: 0.376937 ← no-op नहीं
(IA)^3, vectors = 1: 0.000000
prefix, K and V = 0: 0.277744 ← no-op नहीं
prompt, 8 sampled tokens: 0.491051 ← no-op नहीं
Adapter और (IA)³: byte-identical. Prefix नहीं, शून्य करने पर भी — शून्य key को भी softmax में exp(0) = 1 मिलता है, इसलिए कुछ सीखे जाने से पहले ही वह असली tokens से attention mass छीन लेता है। Prompt tokens का भी यही हाल: वे दिखते हैं। LoRA उसी वजह से साफ़ शुरू होता है जिस वजह से adapter: B = 0.
Prefix tuning: keys पता देती हैं, values सामान
Li और Liang ने hook को attention के अंदर सरका दिया। हर layer में keys के आगे p सीखे हुए vectors और values के आगे p vectors जोड़ो, ताकि असली tokens की queries को देखने के लिए p अतिरिक्त slots मिलें। कुछ भी Wk या Wv से होकर नहीं जाता: prefix ही keys और values है। Paper इस तरह GPT-2 और BART पर लगभग 0.1% parameters train करता है (स्थिरता के लिए एक छोटे MLP के ज़रिए, जिसे बाद में फेंक दिया जाता है; मैं उसे छोड़ रहा हूँ)। अब सिर्फ़ values सीखिए:
attention weights shape with p=4 prefix rows: (16, 20) (n, p+n)
weight each query puts on the 4 prefix slots (first 3 queries):
[[0.043 0.027 0.05 0.047]
[0.071 0.061 0.017 0.067]
[0.083 0.04 0.023 0.007]]
BREAK IT: train V only, leave the prefix keys at zero
[[0.035 0.035 0.035 0.035]
[0.027 0.027 0.027 0.027]
[0.032 0.032 0.032 0.032]]
change every value row, max |weights diff|: 0.0
सीखी हुई keys के साथ हर query उन चार slots को अलग-अलग पढ़ती है। शून्य keys के साथ columns एक जैसे हो जाते हैं और हर value vector दोबारा खींचने पर भी weights ठीक 0 हिलते हैं: prefix सिमटकर एक तय vector बन गया है जो सबमें जुड़ जाता है। Keys पता हैं, values सामान। दोनों चाहिए।
Prompt tuning: hook input में है
Lester et al. ने इस सवाल का सबसे छोटा रूप पूछा: अगर सिर्फ़ p token embeddings ही trainable हों, जो input पर एक बार आगे जोड़ दिए जाएँ, तो? किसी block में कोई hook नहीं; वे साधारण positions हैं और हर layer उन्हें देखती है।
input: 8 prompt rows + 16 real rows = (24, 64)
attention mass real tokens put on the prompt:
mean 0.340 min 0.196 max 0.546 (uniform would be 0.333)
BREAK IT: read the output at row 0
out.shape = (24, 64) ← इनमें 8 rows prompt की हैं
out[0] belongs to prompt[0] out[8] belongs to real token 0
the answer is out[8:], shape (16, 64)
एक तिहाई attention mass, उन tokens पर जिन्हें अभी किसी ने train ही नहीं किया
(असली token vectors में से sample किए गए — paper का सुझाया init)। Training
उन्हें ऐसा हिलाती है कि असली tokens वहाँ जो पढ़ें वह काम का हो। और output में
24 rows हैं: out[0] एक prompt token का output है। Prompt को काट
दीजिए।
(IA)³ और LoRA: वे hooks जो मुड़कर ग़ायब हो जाते हैं
Liu et al. और भी छोटे गए। (IA)³ हर block पर तीन vectors सीखता है — एक keys को scale करता है, एक values को, एक FFN hidden को — और बस। यहाँ 384 संख्याएँ, असली model में लगभग 0.01%। Scale एक गुणा है, इसलिए बाद में आप उसे frozen matrix में मोड़ देते हैं (Wk और Wv के columns, W2 की rows) और hook ग़ायब हो जाता है। यही वह column है जिससे यह परिवार असल में छँटता है:
base block FLOPs: 1,638,400
method extra FLOPs extra tokens adds a step?
adapters, m=8 65,536 0 yes
prefix, p=8 32,768 8 no
prompt, p=8 868,352 8 no
(IA)^3, unmerged 6,144 0 no
(IA)^3, merged 0 0 no
LoRA, merged 0 0 no
(IA)^3 merge check, max |hooked - merged|: 1.33e-15 identical? True
Adapter के 65,536 अतिरिक्त FLOPs block का 4% हैं। छोटा है। लेकिन वे residual path के अंदर बैठते हैं: जब तक adapter पूरा न हो, block पूरा नहीं होता — हर layer, हर token पर चुकाई जाने वाली क्रमिक देरी। यही Houlsby की लागत थी, और यही खाई LoRA ने पाटी: diff को सीधे weight matrix पर रखो, W + BA, और training के बाद merge कर दो। वही विचार, serving लागत शून्य। उसका अपना पेज है; यहाँ दोबारा नहीं समझाऊँगा।
Prefix और prompt tuning कोई step नहीं जोड़ते, वे एक step को चौड़ा करते हैं। लेकिन prompt वाली row 868,352 अतिरिक्त FLOPs है, यानी फिर से आधा block: हर layer के हर matmul से गुज़रते 8 अतिरिक्त tokens। कुछ भी मुफ़्त नहीं; चुनिए कहाँ।
The code
Pure NumPy. No torch, no autograd, no training — the forward pass with every
hook point marked, so you can put a breakpoint in block() and
watch which one fires. Every function under 20 lines.
Download the full script — 170 lines plus six runnable demos. Then:
python3 peft_from_scratch.py
def adapter(h, A):
z = relu(h @ A["W_down"] + A["b_down"]) # (n, d) -> (n, m)
return h + z @ A["W_up"] + A["b_up"] # (n, m) -> (n, d), + skip
def block(W, X, hooks=None, return_weights=False):
h = hooks or {}
Q, K, V = X @ W["W_q"], X @ W["W_k"], X @ W["W_v"]
if "ia3_k" in h:
K, V = K * h["ia3_k"], V * h["ia3_v"] # (IA)^3: scale K, V
if "prefix_k" in h: # prefix: p extra rows
K = np.concatenate([h["prefix_k"], K]) # the queries can
V = np.concatenate([h["prefix_v"], V]) # attend to
weights = softmax(Q @ K.T / np.sqrt(Q.shape[-1])) # (n, n + p)
a = (weights @ V) @ W["W_o"]
if "adapter_attn" in h:
a = adapter(a, h["adapter_attn"]) # Houlsby hook 1
X = layernorm(X + a, W["ln1_g"], W["ln1_b"])
f = relu(X @ W["W_1"] + W["b_1"])
if "ia3_ff" in h:
f = f * h["ia3_ff"] # (IA)^3: scale hidden
f = f @ W["W_2"] + W["b_2"]
if "adapter_ffn" in h:
f = adapter(f, h["adapter_ffn"]) # Houlsby hook 2
X = layernorm(X + f, W["ln2_g"], W["ln2_b"])
return (X, weights) if return_weights else X
def make_adapters(m, d=D, zero_up=True, seed=1):
"""Houlsby: two bottleneck adapters per block, width m. Up-proj zeroed so
step 0 is the identity (the paper: 'near-identity initialisation')."""
rng = np.random.default_rng(seed)
def one():
up = np.zeros((m, d)) if zero_up else rng.normal(0, 0.1, (m, d))
return {"W_down": rng.normal(0, 0.1, (d, m)), "b_down": np.zeros(m),
"W_up": up, "b_up": np.zeros(d)}
return {"adapter_attn": one(), "adapter_ffn": one()}
def make_prefix(p, d=D, zero=False, seed=2):
"""Li & Liang: p learned key rows and p learned value rows, per layer.
They never pass through W_k / W_v -- they ARE the keys and values."""
rng = np.random.default_rng(seed)
if zero:
return {"prefix_k": np.zeros((p, d)), "prefix_v": np.zeros((p, d))}
return {"prefix_k": rng.normal(0, 1, (p, d)),
"prefix_v": rng.normal(0, 1, (p, d))}
def make_prompt(p, X, d=D, seed=3):
"""Lester: p learned INPUT tokens. Not a hook in the block -- new rows
prepended to X. Init by sampling real token vectors (the paper's best)."""
rng = np.random.default_rng(seed)
return X[rng.integers(0, len(X), size=p)].copy()
def make_ia3(d=D, d_ff=D_FF):
"""Liu 2022: three scaling vectors, all ones at init -> identity."""
return {"ia3_k": np.ones(d), "ia3_v": np.ones(d), "ia3_ff": np.ones(d_ff)}
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living; reverse-engineering from broken behaviour is your native mode.
| Break this | Watch for |
|---|---|
In demo 2, change make_adapters(8) to make_adapters(64) — bottleneck = d | 16,640 trainable, 33.46% of the block: more than the whole attention sublayer (16,384) you bolted them onto. No bottleneck, no method. |
In demo 3, pass zero_up=False to make_adapters | Step-0 drift goes from 0.000000 to 0.376937. You overwrote the pretrained behaviour before the first gradient. |
In demo 4, keep "prefix_k": np.zeros((p, D)) and only redraw prefix_v | Every prefix column is identical per row (0.035 0.035 0.035 0.035) and the weights move by 0.0. The prefix can't be addressed. |
In demo 5, read out[0] instead of out[8:] | Your "answer" is prompt token 0's output. out.shape is (24, 64); the real tokens are the last 16 rows. |
Count n_params(hooks) + n_params(W) — what forgetting requires_grad = False does in a framework | 51,920 trainable, 104.41% of the block. Full fine-tuning with extra steps, and the checkpoint is the whole model again. |
On the maths anxiety
There's less here than on the attention page. The family is matrix shapes
and one skip link. An adapter is two matmuls and an add. A prefix is a
concatenate; a prompt is a concatenate one call
earlier. (IA)³ is a multiply. Gradients are still loss.backward().
What separates AI engineers here is knowing which hook merges, what each does to the KV cache, and that prompt tuning's scale threshold is real.
Where to go next
This page gave you the family as one hook table, in code you can break. These cover what I skipped: the history and the pictures.
-
Parameter-Efficient Transfer Learning for NLP
Section 2 and Figure 2 are
adapter()with a diagram. Then the parameter/performance trade-off section: my bottleneck sweep run on real BERT, showing how far m shrinks before GLUE notices. -
Understanding Parameter-Efficient Finetuning of LLMs
The family as a timeline — prefix tuning, adapters, LLaMA-Adapter — with the block diagrams I replaced with a dict of hooks. See each hook drawn onto a real transformer, and where newer methods fit.
-
Soft prompts
Prompt tuning, prefix tuning and P-tuning side by side, with pictures. I gave prompt tuning one demo and a caveat; this gives it Lester's scale plot and the variants I didn't cover.
adapter() with a picture.