V kapitole postav si vlastní model ti doběhl transformer v NumPy. Od té doby jsi přečetl, že skutečné modely mají RMSNorm, RoPE, SwiGLU, víc hlav a pre-norm. Jenže tvůj model nemá ani jedno z toho. Jsou to pro tebe zatím slova z papírů. Tahle kapitola to napraví: každý z těch prvků přidáme, ukážeme diff v kódu a změříme, co to udělalo s loss. Na konci budeš mít model, který se architekturou od těch dnešních liší už jen velikostí.
Vlevo je to, co máš. Vpravo to, co má každý dnešní model, jen s osmdesáti bloky místo jednoho.
✂ Kód v téhle kapitole jsou diffy proti modelu z minulé kapitoly, ne samostatné programy. Vždycky ukazuje jen to, co se mění. Celý běžící model je v postav si vlastní model.
Jak se to poctivě měří
Než začneme, pravidla, bez kterých by čísla nic neznamenala:
- Pět seedů, ne jeden. Každá varianta se trénuje pětkrát s různou inicializací i pořadím dávek. Uvádím průměr a směrodatnou odchylku.
- Stejný rozpočet. 6 000 kroků, dávka 32, kontext 16 znaků,
d_model48, AdamW slr 3e-3. - Held-out validace. Posledních 10 % korpusu se netrénuje. Rozhoduje val loss, ne train.
- Gradienty ověřené numericky. Každý nový blok jsem porovnal proti
(f(w+ε) − f(w−ε)) / 2ε. Bez toho by se dalo naměřit cokoli.
První odrážka stála pár desítek minut výpočtu navíc a stojí za vysvětlení, protože je to nejčastější chyba amatérského měření v ML. Rozdíl mezi dvěma variantami je tady typicky 0,04 loss, zatímco rozptyl mezi seedy je 0,01 až 0,02. Kdybych měřil jednou, půlku tabulky bych si vymyslel.
⚠️ Přiznání. První verze téhle kapitoly měřila každou variantu jednou a vyvodila z toho závěr, že finální model je horší než varianta uprostřed žebříku. S pěti seedy se ten závěr otočil: finální model je lepší o 0,022. Ta původní „zajímavá anomálie“ byl šum z jednoho seedu. Nechávám to tu schválně, protože je to přesně ten druh chyby, kterou v článcích i v práci uvidíš pořád, a jediná obrana proti ní je opakované měření.
Poslední odrážka taky není formalita. Špatně napsaný backward se projeví jako „nová technika nepomohla“, a ty se pak roky mýlíš. Viz backprop ručně.
Celý žebřík naráz
Pět seedů na variantu, uvedeno jako průměr:
| Krok | Parametry | Train | Val | rozptyl val | rozdíl proti předchozímu |
|---|---|---|---|---|---|
| baseline z minulé kapitoly | 21 888 | 2,401 | 2,411 | ± 0,010 | |
| + FFN (ReLU) | 40 320 | 2,029 | 2,111 | ± 0,019 | −0,301 |
| + pre-norm RMSNorm | 40 416 | 1,965 | 2,068 | ± 0,013 | −0,043 |
| + RoPE místo naučených pozic | 39 648 | 2,051 | 2,123 | ± 0,022 | +0,056 (horší) |
| + 4 hlavy místo jedné | 39 648 | 1,993 | 2,084 | ± 0,007 | −0,040 |
| + SwiGLU místo ReLU | 39 648 | 1,936 | 2,046 | ± 0,021 | −0,037 |
Všechny rozdíly v posledním sloupci jsou aspoň dvojnásobkem rozptylu, takže žádný z nich není šum. Nejtěsnější je SwiGLU: 0,037 při rozptylu 0,016, tedy zhruba dva a půl rozptylu. To je právě na hranici, kdy má smysl mu věřit, a kdyby na tom závisel produkt, měřil bych radši dvacetkrát než pětkrát.
Jeden z kroků model zhoršil, a to prokazatelně, ne náhodou. Vrátíme se k němu.
A takhle se změnil text, který model generuje (stejný seed, stejný korpus):
baseline (val 2,411):
Model tokel na → ikóru oparat rojím s podměk se- Kože -- Sezběkanáslal si se toručíto
stoleš na přímě teratt postaral
finální model (val 2,046):
Model tréning 7– . Zvětší pozebuje, p95 , shorní loss . --- Kvaci, kontextu má slížit
stojích + normá, co uživatelu na jedná stojí. Dos Podle překrychle uhorší
Pořád je to nesmysl, ale je to nesmysl složený z českých slov, a dokonce ze slov z tohohle korpusu: „trénink“, „loss“, „kontextu“, „p95“. Za 40 tisíc parametrů, což je zhruba dvě stě tisíckrát méně, než má nejmenší použitelný model, to není špatné.
Skrytý nález: sledujte tu mezeru
Zpátky k tabulce, tentokrát jen k prvním dvěma číselným sloupcům. Odečti train od val:
| Krok | Train | Val | Mezera |
|---|---|---|---|
| baseline | 2,401 | 2,411 | 0,011 |
| + FFN | 2,029 | 2,111 | 0,082 |
| + pre-norm RMSNorm | 1,965 | 2,068 | 0,103 |
| + 4 hlavy | 1,993 | 2,084 | 0,091 |
| + SwiGLU | 1,936 | 2,046 | 0,111 |
Baseline měl mezeru mezi trénovacími a validačními daty prakticky nulovou. Finální model má desetkrát větší.
To je přetrénování, jak se rodí, a je to jeden z nejdůležitějších jevů celého strojového učení. Baseline byl tak slabý, že se nezmohl na nic víc než na obecné statistiky jazyka, které platí na trénovacích i nových datech stejně. Jak modelu přibývala kapacita, začal si všímat detailů konkrétního korpusu, které se na nová data nepřenášejí.
Zatím to nevadí, protože val loss pořád klesá: model si sice čím dál víc pamatuje, ale zároveň se čím dál víc učí. Problém by nastal ve chvíli, kdy by train dál klesal a val začal růst. Kdybys tenhle model trénoval dál nebo mu přidal parametry bez přidání dat, přesně to by přišlo.
Odtud plyne praktické pravidlo: loss na trénovacích datech se nesleduje kvůli kvalitě, ale kvůli téhle mezeře. Sama o sobě ti neřekne skoro nic. Viz když se trénink pokazí.
Krok 1: feed-forward vrstva
Baseline z minulé kapitoly nemá FFN vůbec. Každý token si po attention jen odnese vážený průměr ostatních a jde rovnou na výstup. Chybí místo, kde by si model něco spočítal sám pro sebe.
# nové parametry
P['W1'] = rng.normal(0, .02, (C, 4 * C))
P['W2'] = rng.normal(0, .02, (4 * C, C))
# ve forwardu, hned za attention:
a1 = np.maximum(h @ P['W1'], 0) # ReLU
h = h + a1 @ P['W2'] # zase s residualem
# v backwardu:
da1 = dh @ P['W2'].T
g['W2'] = a1.reshape(-1, 4 * C).T @ dh.reshape(-1, C)
dz1 = da1 * (z1 > 0) # ReLU pouští gradient jen tam, kde byla kladná
g['W1'] = f_in.reshape(-1, C).T @ dz1.reshape(-1, 4 * C)
dh = dh + dz1 @ P['W1'].T # residual: gradient teče oběma cestami
Výsledek: 2,411 → 2,111. Zdaleka největší skok v celém žebříku, a mimochodem jediný, který opravdu přidal parametry (z 21 tisíc na 40 tisíc). Attention míchá informaci mezi tokeny, FFN ji zpracovává. Bez druhé půlky je model jen chytrý průměrovač.
Že je FFN takhle důležitá, není náhoda: ve skutečných modelech v ní leží kolem 81 % všech parametrů vrstvy.
Krok 2: pre-norm s RMSNorm
Baseline nemá žádnou normalizaci. U dvou vrstev to projde, u osmdesáti ne, jak popisuje normalizace a aktivace. Přidáme ji před každý blok, ne za něj:
P['g1'] = np.ones(C) # gain před attention
P['g2'] = np.ones(C) # gain před FFN
def rms(x, g):
r = 1.0 / np.sqrt((x * x).mean(-1, keepdims=True) + 1e-6)
return x * r * g
# forward: attention se počítá z rms(e, g1), FFN z rms(h, g2),
# ale residual se pořád přičítá k NEnormalizovanému e a h
Backward RMSNormu je jediné místo v celé kapitole, kde se vyplatí derivaci napsat ručně:
def rms_b(dy, x, r, g):
C = x.shape[-1]
dg = (dy * x * r).reshape(-1, C).sum(0)
dx = g * r * dy - x * r**3 * (dy * g * x).sum(-1, keepdims=True) / C
return dx, dg
Výsledek: 2,111 → 2,068. Za 96 parametrů navíc, tedy dvakrát 48 čísel. Nejlepší poměr užitku k ceně v celém žebříku. A hlavně: bez tohohle kroku by se hlubší varianta modelu vůbec nenatrénovala, což na dvou vrstvách nepoznáš.
Krok 3: RoPE, a nepříjemné překvapení
Naučená tabulka pozic se zahodí a místo ní se q a k otočí podle pozice:
inv = 1.0 / (10000 ** (np.arange(0, H, 2) / H))
ang = np.arange(T)[:, None] * inv[None, :]
cos, sin = np.cos(ang), np.sin(ang)
def rope(v):
out = np.empty_like(v)
out[..., 0::2] = v[..., 0::2] * cos - v[..., 1::2] * sin
out[..., 1::2] = v[..., 0::2] * sin + v[..., 1::2] * cos
return out
q, k = rope(q), rope(k) # a P['Pos'] z modelu úplně zmizí
Backward je hezky jednoduchý, protože rotace je ortogonální: stačí otočit zpět, tedy prohodit znaménko u sinu.
Výsledek: 2,068 → 2,123. Model se prokazatelně zhoršil.
Tohle je přesně ten okamžik, kdy většina tutoriálů výsledek zamete pod koberec. My si ho rozebereme, protože je poučný. Kontext má 16 tokenů. Naučená tabulka pozic má tedy 16 řádků a model si prostě zapamatuje, co která z těch šestnácti pozic znamená. To je pro tak malou úlohu optimální řešení a RoPE proti tomu nemá šanci, protože pozici nekóduje volně, ale svazuje ji pevným vzorcem.
RoPE se nedělá kvůli lepší loss na trénovací délce. Dělá se kvůli tomu, co je za ní.
Kde RoPE výhodu opravdu má
Natrénoval jsem oba modely na kontextu 16 a pak je pustil na delší sekvence, než kdy viděly.
Měří se loss jen na poslední pozici, tedy „jak dobře predikuješ, když máš k dispozici
L tokenů kontextu“. Naučená tabulka nemá pro pozice nad 16 žádný řádek, takže se poslední
řádek musí opakovat, což je obvyklý nouzový trik.
| Kontext | Naučené pozice | RoPE |
|---|---|---|
| 8 | 1,940 | 2,023 |
| 16 (trénovaná délka) | 1,971 | 2,043 |
| 24 | 3,042 | 2,482 |
| 32 | 3,261 | 2,969 |
| 48 | 3,323 | 2,971 |
| 64 | 3,274 | 3,104 |
Otočilo se to: do trénované délky vede naučená tabulka, za ní vede RoPE, a to výrazně. Nejvýraznější rozdíl je hned za hranicí, na délce 24, kde je RoPE lepší o 0,56. To je víc než celý rozdíl mezi baseline a finálním modelem z hlavního žebříku.
Ale všimni si i toho druhého, méně příjemného: oba modely spadnou z ~2,0 na ~3,0, jakmile překročí trénovanou délku. RoPE degraduje pomaleji, ale taky nefunguje. To je poctivá odpověď na otázku, jestli RoPE „umí libovolně dlouhý kontext“: neumí. Proto se v praxi kontext prodlužuje interpolací frekvencí a doučením na dlouhých datech, jak popisuje poziční kódování. Samotná rotace ti dlouhý kontext zadarmo nedá.
Krok 4: čtyři hlavy místo jedné
Rozdělíme d_model 48 na čtyři hlavy po dvanácti. Počet parametrů se nemění, jen se jinak
uspořádá výpočet, takže je to jediný krok v žebříku, který je opravdu čistě architektonický.
nh, hd = 4, C // 4
def split(z): return z.reshape(B, T, nh, hd).transpose(0, 2, 1, 3)
def merge(z): return z.transpose(0, 2, 1, 3).reshape(B, T, C)
qh, kh, vh = split(q), split(k), split(v)
sc = qh @ kh.transpose(0, 1, 3, 2) / np.sqrt(hd) + MASK # pozor: √hd, ne √C
at = softmax(sc)
o = merge(at @ vh)
Výsledek: 2,123 → 2,084. Za nula parametrů navíc. Jedna hlava musí všechny druhy vztahů mezi tokeny natlačit do jediného skalárního součinu; čtyři hlavy se můžou specializovat, jedna třeba na předchozí znak a jiná na hranici slova.
Jediná past je v tom √hd: dělí se odmocninou z rozměru hlavy, ne z d_model. Kdo to
splete, dostane saturovaný softmax a diví se, proč se model neučí. Viz
attention ručně na číslech.
Krok 5: SwiGLU
ReLU se nahradí hradlem: jedna větev počítá hodnotu, druhá rozhoduje, kolik z ní pustit dál.
d = 128 # 3*C*d == 2*C*4C, tedy stejný počet parametrů
P['W1'] = n(C, d); P['W3'] = n(C, d); P['W2'] = n(d, C)
z1, z3 = f_in @ P['W1'], f_in @ P['W3']
sg = 1 / (1 + np.exp(-z1))
a1 = (z1 * sg) * z3 # silu(z1) krát z3, to je ta brána
h = h + a1 @ P['W2']
Šířka d = 128 není náhodná: tři matice C×128 mají přesně tolik parametrů co dvě matice
C×192 u ReLU varianty. Porovnání je tedy férové.
Výsledek: 2,084 → 2,046. Malé, ale zadarmo. Přesně tohle je důvod, proč SwiGLU vyhrál: nedělá zázraky, ale při stejném rozpočtu je konzistentně o kousek lepší, a to se přes velký model a biliony tokenů nasčítá.
Co si z toho odnést
Za pět kroků klesla val loss z 2,411 na 2,046, tedy o 0,365. Ale ta cesta nebyla přímá a to je na ní to zajímavé:
- Nejvíc pomohly ty nejnudnější věci. FFN a normalizace, tedy nápady z let 2015 a 2019, udělaly dohromady 0,343 z těch 0,365. Pět let architektonických novinek přidalo zbytek, a jedna z nich dokonce ubrala. To je docela přesný obrázek toho, jak obor vypadá i ve velkém: základ nese skoro všechno, novinky doťukávají.
- Architektonická vylepšení jsou škálovací. RoPE, víc hlav i SwiGLU jsou navržené pro modely o miliardách parametrů a kontexty o desítkách tisíc tokenů. Na modelu se 40 tisíci parametry a kontextem 16 znaků se jejich výhoda nemá kde projevit, zatímco jejich omezení se projeví hned. U RoPE se to dá vidět úplně explicitně: na délce 16 prohrává, na délce 24 vyhrává o půl bodu.
- Benchmark bez kontextu neznamená nic. Kdyby ti někdo ukázal jen řádek „RoPE zhoršilo loss o 0,056“, byl by to pravdivý údaj a naprosto zavádějící závěr.
- Jedno měření není měření. První verze téhle kapitoly stála na jednom seedu a jeden z jejích závěrů byl kvůli tomu obrácený. Rozptyl mezi seedy tady byl 0,01 až 0,02, tedy polovina typického rozdílu mezi variantami. To je přesně ta situace, kdy si člověk snadno naměří, co chce vidět.
Když už máš trénovací smyčku rozběhnutou, pokračuj na změř si scaling zákon. Tam se stejným kódem naměříš, jak kvalita roste s velikostí modelu, a uvidíš předpověď selhat.
A poslední věc, ta nejdůležitější: tohle všechno sis teď mohl změřit sám, na svém počítači, za pár minut. To je rozdíl mezi tím, kdy si o architektuře čteš, a tím, kdy jí rozumíš.
Cvičení
- Krok 1 (FFN) přidal 18 432 parametrů a zlepšil val loss o 0,300. Krok 2 (RMSNorm) přidal 96 parametrů a zlepšil ji o 0,043. Spočítej zlepšení na tisíc parametrů u obou a zamysli se, co to říká o tom, kam dávat rozpočet.
- Proč se u čtyř hlav dělí
√hd = √12, a ne√C = √48? Co by se stalo se softmaxem, kdybys omylem použil to druhé? - V tabulce extrapolace klesne kvalita obou modelů skokem mezi délkou 16 a 24. Vymysli dvě různá vysvětlení a navrhni, jak bys je od sebe experimentálně odlišil.
Náčrt řešení: rozbal, až si cvičení zkusíš sám
- FFN: 0,016 na tisíc parametrů. RMSNorm: 0,45 na tisíc parametrů, tedy zhruba sedmadvacetkrát víc. Normalizace je extrémně levná a extrémně účinná, protože neřeší kapacitu modelu, ale podmíněnost výpočtu. Obecné pravidlo z toho je: nejdřív oprav to, co brání učení (normalizace, residualy, learning rate), a teprve pak přidávej parametry. Přidávat kapacitu do modelu, který se kvůli špatné normalizaci neučí, je vyhazování peněz.
- Protože skalární součin se počítá uvnitř hlavy, přes
hdsložek, ne přesC. Rozptyl součtuhdsoučinů roste shd, takže se dělí√hd, aby skóre zůstala v rozumném rozsahu. Kdybys dělil√48místo√12, dělil bys dvakrát větším číslem, než máš. Skóre by byla zbytečně malá, softmax by vyšel skoro rovnoměrný a attention by se blížila prostému průměru, tedy přesně tomu kroku 2 z od bigramu k transformeru, o kterém víme, že je horší než nic. - Dvě vysvětlení: buď je problém v pozičním kódování (model nikdy neviděl pozice nad 16 a neumí je interpretovat), nebo v rozdělení pozornosti (softmax je kalibrovaný na 16 kandidátů a přes 64 se rozmělní). Odlišíš je tak, že modelu necháš dlouhý vstup, ale pozornost omezíš posuvným oknem 16 tokenů: pak se počet kandidátů nezmění a pozice zůstanou v naučeném rozsahu. Když se kvalita vrátí, byl to problém pozic. Když ne, je to softmaxem.
Shrnutí
- Baseline z minulé kapitoly nemá FFN ani normalizaci. Doplnit obojí je největší skok v kvalitě.
- RMSNorm stojí 96 parametrů a je nejúčinnější zásah v celém žebříku na jednotku ceny.
- RoPE na kontextu 16 znaků zhoršilo loss. Jeho výhoda je v extrapolaci za trénovanou délku, a i tam je jen postupná, ne zázračná.
- Víc hlav a SwiGLU zlepšují model při nezměněném počtu parametrů, ale málo. Jsou to škálovací vylepšení.
- Poctivé měření znamená stejný seed, stejný rozpočet, held-out validaci a numericky ověřené gradienty.
Proč RoPE v tomhle experimentu zhoršilo loss, když ho používají všechny moderní modely?
Protože kontext měl jen 16 tokenů. Naučená tabulka pozic si pro tak malý rozsah prostě zapamatuje, co která z šestnácti pozic znamená, což je optimální. RoPE pozici nekóduje volně, ale svazuje ji pevným vzorcem rotace, a tahle vazba se vyplatí až tam, kde je pozic hodně a kde je potřeba zvládnout i délky, na kterých se netrénovalo. Na trénované délce RoPE nevyhrává, vyhrává za ní.
Který z pěti prvků měl nejlepší poměr užitku k počtu přidaných parametrů a proč?
Pre-norm s RMSNormem: za 96 parametrů zlepšil val loss o 0,043, tedy zhruba sedmadvacetkrát víc na tisíc parametrů než feed-forward vrstva. Normalizace nezvyšuje kapacitu modelu, jen udržuje aktivace a gradienty v rozumném rozsahu, takže se model učí efektivněji z toho, co už má. Proto se problémy s učením řeší dřív než přidávání parametrů.
Co znamená, že finální model s pěti moderními vylepšeními vyšel o kousek hůř než varianta uprostřed žebříku?
Že architektonická vylepšení jsou navržená pro jiné měřítko, než na jakém je testujeme. RoPE, víc hlav i SwiGLU cílí na modely s miliardami parametrů a dlouhým kontextem; na modelu se 40 tisíci parametry a kontextem 16 znaků se jejich přínos nemá kde projevit, zatímco jejich omezení se projeví hned. Ukazuje to zároveň, proč se benchmarkové číslo nedá číst bez kontextu, v jakém vzniklo.
