~/organiccode.net

devlogg

Hva er LoRA, og hvorfor bruker alle det?

En kort, bildedrevet intro til Low-Rank Adaptation — trikset som gjør finjustering av store modeller mulig på én GPU.

lora ml intro finjustering

Du har et stort, forhåndstrent nevralt nettverk. Hundrevis av millioner parametere. Det er nesten riktig for oppgaven din — men ikke helt. Du vil lære det noe nytt: et nytt språk, en ny stil, et nytt domene. Og helst uten å leie en rack med A100-er.

Det er problemet LoRA løser. Kort for Low-Rank Adaptation, introdusert i en Microsoft-artikkel fra 2021, i dag standardmetoden for finjustering av store modeller i praktisk talt alle open-source-verktøykasser. Den norske stemmen for CosyVoice 3 jeg nettopp slapp? Trent med LoRA på en enkelt RTX 3090. Hele modellen har ~500 millioner parametere; delen jeg faktisk trente er ~13 millioner. Det forholdet er grunnen til at dette i det hele tatt går an på én forbruker-GPU.

Kjerneideen

Rett til poenget: istedenfor å endre modellens vekter direkte, lærer LoRA en liten tilleggsmatrise til vektene, uttrykt som produktet av to tynne matriser.

Wd × kfrossen+Bd × r·Ar × ktrenbar=W + BAeffektivvekt

Hvis en vektmatrise WW et sted inne i modellen har form d×kd \times k — si 4096×40964096 \times 4096 — er det naive valget under finjustering å oppdatere alle dkd \cdot k tallene. Det er rundt 16 millioner parametere per lag, og en ekte modell har dusinvis av lag.

LoRA parameteriserer endringen som et produkt av to tynne matriser:

ΔW=BA,BRd×r,  ARr×k\Delta W = B \cdot A, \qquad B \in \mathbb{R}^{d \times r},\; A \in \mathbb{R}^{r \times k}

der ranken rr er liten — typisk 8, 16 eller 24.

Når du multipliserer BB og AA får du noe med samme form som WW, men parameterisert av bare r(d+k)r \cdot (d + k) tall istedenfor dkd \cdot k. For r=8r = 8 på en 4096×40964096 \times 4096-matrise er det rundt 65 tusen tall istedenfor 16 millioner — omtrent 250× færre.

Under trening er WW frossen, og bare AA og BB får gradient-oppdateringer. Under inferens kan du enten holde dem separate som y=Wx+BAxy = Wx + BAx, eller flette dem én gang inn i en ny effektiv vekt W=W+BAW' = W + BA og kjøre modellen som vanlig.

Et konkret eksempel: rank-1 på en bitteliten matrise

Tallene over er enklere å tro på hvis man ser dem rulle ut én gang. Si at WW er en 4×44 \times 4-matrise — 16 tall hvis vi skulle finjustert den naivt. Vi parameteriserer istedenfor en rank-1 LoRA (r=1r = 1):

B=[2101],A=[1012]B = \begin{bmatrix} 2 \\ 1 \\ 0 \\ -1 \end{bmatrix}, \qquad A = \begin{bmatrix} 1 & 0 & -1 & 2 \end{bmatrix}

Til sammen 8 trenbare tall. Produktet BABA blir et ytre produkt:

ΔW=BA=[2024101200001012]\Delta W = B \cdot A = \begin{bmatrix} 2 & 0 & -2 & 4 \\ 1 & 0 & -1 & 2 \\ 0 & 0 & 0 & 0 \\ -1 & 0 & 1 & -2 \end{bmatrix}

16 tall ut — samme form som WW — men hver eneste rad er en skalert kopi av AA. Skaleringen for rad ii er ganske enkelt BiB_i. Det er hva rank 1 betyr: hele matrisen ligger på én enkelt linje gjennom origo i radrommet. 8 parametere koder for 16 verdier, men de 16 verdiene er bundet sammen — du kan ikke sette dem uavhengig av hverandre.

Skru opp rr og du gir LoRA-en flere uavhengige retninger å bevege seg langs. Med r=2r = 2 får du summen av to slike rank-1-matriser, med r=24r = 24 (det jeg brukte for CosyVoice) får du 24. Hver økning i rr koster (d+k)(d + k) ekstra parametere; til gjengjeld øker uttrykksevnen.

Hvorfor funker det?

Den empiriske observasjonen bak LoRA: når du finjusterer en forhåndstrent modell, viser endringen i vektene seg å være sterkt strukturert. Selv om finjustering i prinsippet kan endre hver eneste parameter, oppfører den faktiske endringen seg som om den har mye lavere rank enn de opprinnelige vektene. Du utforsker ikke alle 16 millioner dimensjoner av endringsrommet — du beveger deg langs en håndfull meningsfulle retninger.

Så du baker den low-rank-antagelsen direkte inn i parameteriseringen. Det er ikke perfekt for alle oppgaver, men overraskende nært full finjustering for en brøkdel av parametrene.

Hva det gir deg i praksis

full finjustering~500M parametereLoRA (r=24)~13M parametere≈ 2,6 % av modellen blir faktisk trent

Besparelsene stables:

  • Minne. Du trenger bare gradienter og optimizer-state for LoRA-matrisene, ikke hele modellen. På en halvmilliardparameter-modell med r = 24 er det forskjellen på å trenge 40+ GB VRAM og å få det til å passe komfortabelt på et 24 GB consumer-kort.
  • Treningshastighet. Færre parametere → færre gradientberegninger → raskere steg.
  • Bittesmå output-filer. En LoRA-adapter for en 500M-modell er rundt 50 MB istedenfor 2 GB. Du kan dele dem, bytte mellom dem, stable flere oppå samme basemodell.
  • Plugin-stil finjustering. Fordi de opprinnelige vektene er urørte, kan du holde én basemodell i minnet og bytte LoRA-er inn og ut etter behov — forskjellige stemmer, forskjellige domener, forskjellige språk.

Kostnaden er reell, men som regel liten: du gir slipp på litt kapasitet. Det finnes oppgaver der full finjustering slår LoRA tydelig, særlig når måldistribusjonen ligger langt unna det modellen opprinnelig ble forhåndstrent på. For de fleste tilpasningsoppgaver — inkludert å lære en flerspråklig TTS-modell et nytt språk — er byttet bra.

Hva jeg brukte for CosyVoice 3

For den norske bokmåls-LoRAenFun-CosyVoice3-0.5B-2512:

  • Mål: Qwen2-0.5B-språkmodell-frontenden. Flow-matcher-dekoderen nedstrøms er urørt.
  • Rank: r = 24.
  • Dekning: anvendt på alle 24 transformer-blokker av LLM-en.
  • Trenbare parametere: ~13,2 millioner av ~500 millioner totalt — cirka 2,6 % av modellen.

Det er delen av modellen som lærer å mappe norsk tekst til semantiske tale-tokens. Dekoderen nedstrøms vet allerede hvordan disse tokensene skal renderes til lyd; det som trengte å læres var “hvordan høres norsk ut” i steget før det.