Lesson 24 / 26
Adaptation Methods
Measuring side by side, in the same field adaptation, the updated-parameter share and accuracy of full update, layer freezing and a low-rank update: on field spelling a fictional local model of 2292 numbers gives 0.2500 and 0.0833, full update 1.0000 and 0.9861, layer freezing that updates only the head 0.6944 and 0.7500, a low-rank update that updates 0.2094 of the parameters 0.9583 and 0.9306. Raising the rank from 3 to 4 makes 210 more numbers stored per adaptation and its mean gain is 0.0070, below the two-run gap. For adaptation to eight domains the stored count is 6132 against 18336, and as the hidden width grows the share of the low-rank update falls from 0.2094 to 0.0284.
Published Updated
In this lesson
- Tells full update, layer freezing and a low-rank update apart by the number of parameters updated.
- Measures the accuracy of three setups on the same adaptation task in two runs and compares the gap between the setups across the two runs.
- Computes the count of numbers stored per adaptation and shows how the share changes as the hidden width grows.
- Determines with numbers the point at which the gain of raising the rank falls below the two-run gap.
Contents
Fine-tuning carries a model into a new domain, and it does this by moving all the weights. The previous lesson wrote the cost of this as example count and epoch count. There is one more cost that was not written: for every domain adapted, a full copy of the model is stored. Eight domains means eight copies.
This lesson tries moving less. Three setups are placed side by side on the same adaptation task, full update, layer freezing and a low-rank update, and the updated-parameter share and the accuracy of each are printed together. The number of stored numbers is written next to them, because the real cost of adaptation is paid not during training but after it.
- LA31. The code is a simulator. The local model, vocabulary, records and labels are fictional; no real endpoint is called. Seed 20260218.
- LA32. The model is three layers: an input lookup table, a body matrix and a head. All of the weights are in our hands; this is the distinguishing side of a model you host yourself.
- LA33. The two domains are separate: the model is pretrained on general-spelling fictional tickets, then adapted to the field team’s abbreviated fictional records. A share of the records get a misleading suffix carrying the abbreviation of another topic.
- LA34. The low-rank update does not change the weight matrix in place; next to it, it adds the product of two thin matrices. Because the second starts from zero, the base model is not disturbed at first. The singular value decomposition lesson in the Unsupervised Learning course had measured that a matrix can be written approximately as a low-rank product; the structure here is the update side of the same idea.
- LA35. The head is updated in every setup; what separates the setups is how much of the body and the lookup table moves. The two runs are taken with the pretraining seed (20260218 and 20260219), the sets do not change.
Layers and the Weight Budget
On a distant endpoint the only thing measurable was the surface. On a model you host yourself, what can be measured is the numbers themselves: how many there are, which is in which layer, how many are saved.
# SIMULATOR -- FICTIONAL. The local model, vocabulary, records and labels are written # inside the lesson; no real model endpoint is called. Seed 20260218. from math import exp SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value def pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 def tokenize(m): # FICTIONAL tokenizer, a piece is at most five characters b = [] for s in m.lower().replace(",", " , ").split(): while len(s) > 5: b.append(s[:5]) s = s[5:] b.append(s) return b TOPICS = ["invoice", "breakdown", "reading", "tariff", "outage"] GENERAL = [("bu ayki fatura tutari gecen aya gore yuksek geldi", "invoice"), ("faturami odedim ama sistemde borc gorunuyor", "invoice"), ("evde su akmiyor bir ariza var galiba", "breakdown"), ("baglanti bozuk oldu ve hic calismiyor", "breakdown"), ("sayac okumasi yanlis girilmis endeks tutmuyor", "reading"), ("sayac degisimi icin randevu istiyorum", "reading"), ("hangi tarife basamagindayim ogrenmek istiyorum", "tariff"), ("birim ucret abone tipine gore degisiyor mu", "tariff"), ("mahallede kesinti var ne zaman gelecek", "outage"), ("planli kesinti duyurusu nereden takip edilir", "outage")] FIELD = [("fat sapma itiraz dosyasi acildi", "invoice"), ("tahak farki cikti fat duzelt", "invoice"), ("fat kalem hatasi bildirildi", "invoice"), ("fat iade sureci baslatildi", "invoice"), ("arz hat kesik ekip yonlendirildi", "breakdown"), ("pano arz kodu iki bir dort", "breakdown"), ("arz tekrar etti kalici cozum", "breakdown"), ("arz gecici onarim yapildi", "breakdown"), ("syc endeks sapmasi tespit", "reading"), ("syc degisim isi planlandi", "reading"), ("syc muhur kontrolu yapildi", "reading"), ("syc seri numarasi eslesmedi", "reading"), ("trf basamak gecisi talebi", "tariff"), ("trf birim degisimi islendi", "tariff"), ("trf sozlesme guncellemesi bekliyor", "tariff"), ("trf indirim kosulu incelendi", "tariff"), ("kes duyurusu bolgeye islendi", "outage"), ("kes suresi uzadi bilgilendirme", "outage"), ("kes sonrasi enerji verildi", "outage"), ("kes plani ertelendi yeni saat", "outage")] MISLEADING = [" fat kaydi da acildi", " arz notu eklendi", " syc formu istendi", " trf grubu kontrol edildi"] def make_set(pool, n, seed, decoy_share=0.0): r, k = rng(seed), [] for i in range(n): c = TOPICS[pick(r(), [0.24, 0.22, 0.18, 0.20, 0.16])] candidates = [x for x in pool if x[1] == c] m = candidates[int(r() * len(candidates))][0] if r() < decoy_share: # a suffix carrying the abbreviation of another topic m += MISLEADING[int(r() * 4)] k.append((m, c)) return k PRETRAIN = make_set(GENERAL, 150, SEED) # pretraining domain ADAPT = make_set(FIELD, 48, SEED + 7, 0.30) # field spelling, adaptation examples TEST = make_set(FIELD, 72, SEED + 11, 0.30) # field spelling, test VOCAB = {} for m in [x[0] for x in GENERAL + FIELD] + MISLEADING: for t in tokenize(m): VOCAB.setdefault(t, len(VOCAB)) V, H, C = len(VOCAB), 12, len(TOPICS) def encode(k): return [(sorted({VOCAB[t] for t in tokenize(m)}), TOPICS.index(c)) for m, c in k] PRE_E, ADAPT_E, TEST_E = encode(PRETRAIN), encode(ADAPT), encode(TEST) def build(seed): """Input lookup table, body matrix, head. The body starts around the identity matrix.""" r = rng(seed) return [[[(r() - 0.5) * 0.4 for _ in range(H)] for _ in range(V)], [[1.0 * (k == j) + (r() - 0.5) * 0.2 for j in range(H)] for k in range(H)], [[(r() - 0.5) * 0.4 for _ in range(C)] for _ in range(H)]] def multiply(inputs, W, n): """inputs: (row, value) pairs; sparse multiplication.""" h = [0.0] * n for i, v in inputs: Wi = W[i] for j in range(n): h[j] += v * Wi[j] return h def forward(p, tok, addon=None): """If addon is given, a low-rank update is added to every layer (second section).""" layer, mid = [(t, 1.0) for t in tok], [] for n in (0, 1): h, z = multiply(layer, p[n], H), None if addon: U, Vt = addon[n] z = multiply(layer, U, len(Vt)) for j, x in enumerate(multiply(list(enumerate(z)), Vt, H)): h[j] += x mid.append((layer, h, z)) layer = [(j, x) for j, x in enumerate(h) if x > 0.0] # rectification, sparse return mid, layer, multiply(layer, p[2], C) def backward(p, tok, y, addon, step, which): """which: full | head | add_on. The head is updated in every setup, the others depend on the choice.""" mid, layer, o = forward(p, tok, addon) mx = max(o) e = [exp(x - mx) for x in o] s = sum(e) d = [x / s for x in e] d[y] -= 1.0 B, da = p[2], [0.0] * H for k, v in layer: da[k] = sum(d[j] * B[k][j] for j in range(C)) for j in range(C): B[k][j] -= step * d[j] * v for n in (1, 0): inputs, h, z = mid[n] W, U, Vt = p[n], *(addon[n] if addon else (None, None)) dh = [da[j] if h[j] > 0.0 else 0.0 for j in range(H)] if n == 1: da = [sum(dh[j] * W[k][j] for j in range(H)) for k in range(H)] if addon: dz = [sum(dh[j] * Vt[q][j] for j in range(H)) for q in range(len(Vt))] for k in range(H): da[k] += sum(U[k][q] * dz[q] for q in range(len(Vt))) if which == "full": for i, v in inputs: for j in range(H): W[i][j] -= step * dh[j] * v elif which == "add_on": dz = [sum(dh[j] * Vt[q][j] for j in range(H)) for q in range(len(Vt))] for q in range(len(Vt)): for j in range(H): Vt[q][j] -= step * dh[j] * z[q] for i, v in inputs: for q in range(len(Vt)): U[i][q] -= step * dz[q] * v def accuracy(p, k, addon=None): return sum(max(range(C), key=lambda j: forward(p, tok, addon)[2][j]) == y for tok, y in k) / len(k) def pretrain(seed): p = build(seed) for e in range(30): for tok, y in PRE_E: backward(p, tok, y, None, 0.10, "full") return p BASE_A, BASE_B = pretrain(SEED), pretrain(SEED + 1) print(f"vocabulary {V} pieces , hidden width {H} , classes {C}") print(f"input {V * H} + body {H * H} + head {H * C} = {V * H + H * H + H * C} numbers") print("run pretraining domain field domain") for name, p in (("A", BASE_A), ("B", BASE_B)): print(f" {name} {accuracy(p, PRE_E):>18.4f} {accuracy(p, TEST_E):>12.4f}")
vocabulary 174 pieces , hidden width 12 , classes 5 input 2088 + body 144 + head 60 = 2292 numbers run pretraining domain field domain A 1.0000 0.2500 B 1.0000 0.0833
On the pretraining domain accuracy is 1.0000 in both runs; the model has learned its own domain. When it moves to field spelling, the same model gives 0.2500 and 0.0833. In a five-class task random choice is around 0.2000, so the base model knows nothing on field records; the 0.1667 gap between the two runs is the proof of this, what is measured is not knowledge but noise. Adaptation is unavoidable and the question is which numbers will move.
Updated-Parameter Share and Accuracy
The setups are run with the same forty-eight adaptation records, the same twenty-five loops and the same step size; the only thing that changes is which share backpropagation touches.
from copy import deepcopy def build_addon(rank, seed): """Each low-rank update is a pair of U (rows x rank) and Vt (rank x H). Vt starts from zero, so at the first moment of adaptation the update adds nothing and the base model is not disturbed.""" r = rng(seed) return [([[(r() - 0.5) * 0.3 for _ in range(rank)] for _ in range(n)], [[0.0] * H for _ in range(rank)]) for n in (V, H)] def adapt(base, which, rank, seed, loops=25, step=0.08): p = deepcopy(base) addon = build_addon(rank, seed + 3) if rank else None for e in range(loops): for tok, y in ADAPT_E: backward(p, tok, y, addon, step, which) return accuracy(p, TEST_E, addon), accuracy(p, PRE_E, addon) TOTAL = V * H + H * H + H * C SETUPS = [("full update", "full", 0), ("layer freezing", "head", 0)] + \ [(f"low-rank update r={r}", "add_on", r) for r in (1, 2, 3, 4)] print("setup updated share field A field B general A general B") print(f"{'no adaptation':<20} {0:>7} {0.0:.4f} {accuracy(BASE_A, TEST_E):>7.4f}" f" {accuracy(BASE_B, TEST_E):>7.4f} {accuracy(BASE_A, PRE_E):>9.4f}" f" {accuracy(BASE_B, PRE_E):>9.4f}") for name, which, rank in SETUPS: n = TOTAL if which == "full" else (H * C if which == "head" else rank * (V + H) + rank * 2 * H + H * C) a, b = adapt(BASE_A, which, rank, SEED), adapt(BASE_B, which, rank, SEED + 1) print(f"{name:<20} {n:>7} {n / TOTAL:.4f} {a[0]:>7.4f} {b[0]:>7.4f}" f" {a[1]:>9.4f} {b[1]:>9.4f}") print("\nadaptation to eight separate domains, number of stored numbers:") for name, n in (("full update", TOTAL), ("layer freezing", H * C), ("low-rank update r=2", 2 * (V + H) + 2 * 2 * H + H * C)): shared = 0 if n == TOTAL else TOTAL print(f" {name:<20} {shared} + 8 x {n} = {shared + 8 * n}") print("\nas the hidden width grows (independent of the run, count only):") print(" H full freezing r=2 update update share") for h in (H, 64, 256): full = V * h + h * h + h * C addon2 = 2 * (V + h) + 2 * 2 * h + h * C print(f" {h:<5} {full:>7} {h * C:>8} {addon2:>10} {addon2 / full:>12.4f}")
setup updated share field A field B general A general B no adaptation 0 0.0000 0.2500 0.0833 1.0000 1.0000 full update 2292 1.0000 1.0000 0.9861 1.0000 1.0000 layer freezing 60 0.0262 0.6944 0.7500 0.5133 0.1733 low-rank update r=1 270 0.1178 0.7639 0.7222 0.5133 0.2733 low-rank update r=2 480 0.2094 0.9583 0.9306 1.0000 0.5533 low-rank update r=3 690 0.3010 1.0000 0.9722 1.0000 1.0000 low-rank update r=4 900 0.3927 0.9861 1.0000 1.0000 1.0000 adaptation to eight separate domains, number of stored numbers: full update 0 + 8 x 2292 = 18336 layer freezing 2292 + 8 x 60 = 2772 low-rank update r=2 2292 + 8 x 480 = 6132 as the hidden width grows (independent of the run, count only): H full freezing r=2 update update share 12 2292 60 480 0.2094 64 15552 320 1052 0.0676 256 111360 1280 3164 0.0284
Full update moves all 2292 numbers and gives 1.0000 / 0.9861. Layer freezing works with 0.0262 of that, that is, sixty numbers, and stays at 0.6944 / 0.7500: forty-eight examples are enough to teach the head a new mapping, but because the body still does not recognize the field abbreviations, accuracy stays half-way. The low-rank update at rank two climbs to 0.9583 / 0.9306 with a share of 0.2094; its distance from full update is between 0.0417 and 0.0555, and its distance from layer freezing is between 0.1806 and 0.2639. Most of the gap is closed with two tenths of the updated share.
The rank ladder says where to stop. Rank one is not enough (0.7639 / 0.7222): a single direction cannot carry the shift between the two spellings. Rank three gives 1.0000 / 0.9722 and rank four 0.9861 / 1.0000; the means are 0.9861 and 0.9931, so the mean gain of raising the rank from three to four is 0.0070. The two-run gaps of the same rows are 0.0278 and 0.0139. The gain is below the gap, and in return 210 more numbers are stored per adaptation: the cost of the added structure is certain, its gain is unmeasurable.
The last two columns show the unexpected. Layer freezing, although it protects the body, drops accuracy on the previous domain to 0.5133 / 0.1733; the protected body does not help, because the head the two domains share was rewritten. Full update, on the other hand, keeps the previous domain at 1.0000: in the lookup table only the rows that appear in field records moved, and the rows of general spelling were not touched. Freezing a layer does not mean preserving that layer’s ability; what is preserved is the weights, what is lost is the behavior.
Stored Count and Scale
The row of adaptation to eight domains shows where the decision is paid for. In full update the base cannot be shared; eight copies come to 18336 numbers. In layer freezing the base is stored once and each domain adds only its own head: 2772. In the low-rank update the base is again stored once and each domain adds 480 numbers: 6132, that is, a third of full update.
The scale table says this ratio is not constant. When the rank is held fixed, the cost of the low-rank update grows linearly with the hidden width, and the cost of full update grows quadratically in the body. At width 12 the share of the low-rank update is 0.2094, at 64 0.0676, at 256 0.0284. This count is independent of the run, and it explains why the discussion of adaptation methods starts with large models.
Summary
- The fictional local model is 2292 numbers (input 2088, body 144, head 60), and its unadapted state stays at 0.2500 and 0.0833 on field spelling, that is, at the level of random choice.
- Full update moves all of it and gives 1.0000 / 0.9861; its cost is a full copy for each domain.
- Layer freezing stays at 0.6944 / 0.7500 with a 0.0262 share and drops accuracy on the previous domain to 0.5133 / 0.1733: the frozen weights are preserved, the behavior is not.
- The low-rank update gives 0.9583 / 0.9306 with a 0.2094 share; its distance from full update is between 0.0417 and 0.0555.
- The mean gain of raising the rank from three to four is 0.0070, the two-run gaps of the same rows are 0.0278 and 0.0139: the gain counts as unmeasured, the 210 extra numbers are certain.
- The stored count over eight domains is 6132 against 18336, and when the hidden width goes from 12 to 256 the share of the low-rank update falls from 0.2094 to 0.0284.
Next Step
In this lesson the weights of the large model were in our hands. Weights are not always in hand; most of the time the only thing in hand is the output of a large model. That output can be used like a label set and made the input for training a small model. The next lesson measures this: what does a small model trained with the labels a large model produced gain compared with a model of the same size trained only with human labels, how much of the teacher’s accuracy does it capture, and in which task does the gap not close.
To keep your progress and take notes, Log in
My notes
Log in to take notes.