20.2 Finomhangolás és tanulási ráta

20.2.1 Cél, előismeret és induló kérdés

Ez az egység a 20.1. Feature extractor és új fej feature extractor/head szétválasztását fejleszti tovább. Feltételezzük, hogy egy előtanított (vagy legalábbis korábban tanított) modell súlya rendelkezésre áll, és a backbone legalább egy részét a fejjel együtt akarjuk az új feladathoz adaptálni. A cél az, hogy a hallgató pontosan megkülönböztesse a paraméter-fagyasztást, az optimizer-paramétercsoportokat, a rétegenként eltérő tanulási rátát és az eval() módot, majd ezeket kontrollált helyi kísérletben alkalmazza.

Induló kérdés: tegyük fel, hogy a fej tanulási rátája ηhead=10−2\eta_\text{head}=10^{-2}, a backbone utolsó blokkjáé pedig ηlast=10−3\eta_\text{last}=10^{-3}. Ha a backbone első blokkja továbbra is requires_grad=False, akkor vajon a benne lévő BatchNorm réteg running statisztikái változhatnak-e tanítás közben? (A válasz igen; ezt az alábbi állításban bizonyítjuk.)

20.2.2 Fine-tuning, gradual unfreeze és discriminative LR

Definíció (fine-tuning). Legyen a modell paramétereinek halmaza θ=θb∪θh\theta = \theta_b \cup \theta_h, ahol θb\theta_b a backbone (feature extractor) paramétereit, θh\theta_h a head paramétereit jelöli. Fine-tuningnak nevezzük azt az adaptációt, amelyben legalább egy θb\theta_b-beli paraméter aktív: requires_grad=True, szerepel az optimizer valamely paramétercsoportjában, és a headdel együtt frissül. Ha θb\theta_b minden eleme fagyasztott, akkor feature extractionről beszélünk (20.1. Feature extractor és új fej).

Definíció (gradual unfreeze). Rendezzük a backbone paramétereit L−1L-1 blokkba: θb=(θ1,…,θL−1)\theta_b = (\theta_1,\ldots,\theta_{L-1}), a mélység növekedésével. A fokozatos felengedés TT fázisú ütemterv: a tt-edik fázisban az aktív halmaz At={θL−t,…,θL−1,θh},A_t = \{\theta_{L-t},\ldots,\theta_{L-1},\theta_h\}, és minden θj∉At\theta_j \notin A_t elemre requires_grad=False. Minden fázis végén a rögzített validációs metrikát kiértékeljük; a végső modellt csak a validációs szabály alapján választjuk.

Definíció (discriminative LR). Az aktív paraméterekhez blokkonként eltérő tanulási rátát rendelünk: ηh,ηL−1,ηL−2,…\eta_h, \eta_{L-1}, \eta_{L-2},\ldots, tipikusan ηh>ηL−1>ηL−2\eta_h > \eta_{L-1} > \eta_{L-2}. Az optimizernek annyi paramétercsoportja van, ahány különböző ηj\eta_j értéket használunk; egy csoport a (θj,ηj)(\theta_j, \eta_j) párt tárolja. SGD esetén a frissítés θj←θj−ηj∇θjL,\theta_j \leftarrow \theta_j - \eta_j \nabla_{\theta_j} L, ahol LL a batch-átlagolt loss; Adam esetén ηj\eta_j a csoport-specifikus lépésnagyságot skálázza a 6.4. Momentum és adaptív optimizálók alfejezetben bevezetett módon. A gradiens ∇θjL\nabla_{\theta_j}L alakja mindig megegyezik θj\theta_j alakjával.

A discriminative LR heurisztika: az előtanított alsóbb rétegek általánosabb reprezentációt őriznek, ezért kisebb lépéssel védjük őket; a fej véletlenül inicializált, ezért nagyobb lépést igényel. Ez nem univerzális optimum, hanem keresési stratégiát szűkítő előfeltevés.

20.2.3 Állítás: a fagyasztás és az eval mód különbsége

A levezetési kötelezettségünk a következő állítás.

Állítás 1 (paraméter-fagyasztás ≠ eval mód). Legyen BB egy PyTorch BatchNorm2d modul track_running_stats=True beállítással, m∈(0,1)m\in(0,1) momentummal. Tegyük fel, hogy mind γ=B.weight\gamma = B.\text{weight}, mind β=B.bias\beta = B.\text{bias} esetén requires_grad=False. Ekkor:

  1. Ha B.training=TrueB.\text{training}=\text{True}, akkor egy X∈ℝBbatch×C×H×WX\in\mathbb R^{B_\text{batch}\times C\times H\times W} bemenetre végrehajtott forward után a running statisztikák megváltozhatnak: μ̂t+1=(1−m)μ̂t+mμB,t,σ̂t+12=(1−m)σ̂t2+mσB,t2,\hat{\mu}_{t+1} = (1-m)\hat{\mu}_t + m\,\mu_{B,t}, \qquad \hat{\sigma}^2_{t+1} = (1-m)\hat{\sigma}^2_t + m\,\sigma^2_{B,t}, ahol μB,t\mu_{B,t} a batch átlaga, σB,t2\sigma^2_{B,t} pedig a batch torzítatlan varianciája (M/(M−1)M/(M-1)-es szorzóval, MM a csatornánkénti elemszám); a normalizálás a torzított varianciával történik, lásd 18.2. Batch normalization train és eval módban. Ez a frissítés független attól, hogy γ,β\gamma,\beta kapnak-e gradienst.
  2. Ha B.training=FalseB.\text{training}=\text{False}, akkor a forward a rögzített μ̂,σ̂2\hat\mu,\hat\sigma^2 értékekkel normalizál, és a running statisztikák nem változnak.

Bizonyítás. A train módú forward képlete a PyTorch dokumentációja szerint y=γx−μBσB2+ϵ+β,y = \gamma\frac{x-\mu_B}{\sqrt{\sigma^2_B+\epsilon}} + \beta, majd a fenti momentumos frissítés végrehajtódik, ha track_running_stats=True. A requires_grad=False csak azt tiltja, hogy a hátrafelé menetben ∂L∂γ\frac{\partial L}{\partial\gamma} és ∂L∂β\frac{\partial L}{\partial\beta} gradiensek létrejöjjenek; a running statisztika frissítése nem gradiens-művelet, hanem a modul training flagjéhez kötött állapotváltozás. Eval módban a dokumentált útvonal a running statisztikákat használja, és nem végez frissítést. Ezzel az állítás mindkét pontja következik. ∎

A gyakorlati különbséget táblázatban foglaljuk össze:

Beállítás Súlygradiens Súlyfrissítés BN running frissítés
requires_grad=False, train() nincs nincs van
requires_grad=False, eval() nincs nincs nincs
requires_grad=True, train() van van, ha optimizerben szerepel van

20.2.4 Eredeti kézi számpélda

Tekintsük a kétdimenziós modellt: whw_h a fej egyetlen súlya, wbw_b a backbone egyetlen súlya. Legyen a batch-átlagolt loss L(wh,wb)=(wh+2wb−1)2,L(w_h,w_b) = (w_h + 2w_b - 1)^2, kezdeti érték wh=0.5w_h=0.5, wb=1.0w_b=1.0. A gradiens: ∂L∂wh=2(wh+2wb−1),∂L∂wb=4(wh+2wb−1).\frac{\partial L}{\partial w_h} = 2(w_h+2w_b-1), \qquad \frac{\partial L}{\partial w_b} = 4(w_h+2w_b-1). A kezdeti hibatag e=0.5+2⋅1−1=1.5e=0.5+2\cdot 1-1=1.5, tehát gh=3g_h=3, gb=6g_b=6.

A részleges felengedés itt nagyobb loss-csökkenést ad, de ez csak ez a kétdimenziós kvadratikus példa; általános fölényt nem állítunk.

Minimális ellenőrző kód:

import torch

# head-only
w_h = torch.tensor([0.5], requires_grad=True)
w_b = torch.tensor([1.0], requires_grad=False)
loss = (w_h + 2*w_b - 1)**2
loss.backward()
print(w_h.grad, w_b.grad)          # tensor([3.]) None
opt = torch.optim.SGD([w_h], lr=0.1)
opt.step()
print(w_h, w_b)                    # tensor([0.2000]) tensor([1.])

# partial unfreeze, külön paramétercsoport
w_h = torch.tensor([0.5], requires_grad=True)
w_b = torch.tensor([1.0], requires_grad=True)
loss = (w_h + 2*w_b - 1)**2
loss.backward()
opt = torch.optim.SGD([
    {"params": [w_h], "lr": 0.1},
    {"params": [w_b], "lr": 0.01},
])
opt.step()
print(w_h, w_b)                    # tensor([0.2000]) tensor([0.9400])

20.2.5 Labor: head-only és partial unfreeze a GlyphLab-CNN-en

Protokoll. Kérdés: a GlyphLab-CNN-en azonos epoch-költségvetés mellett a head-only vagy az utolsó konvolúciós blokkot kis rátával felengedő változat ad-e nagyobb validációs accuracy-t? Adat: a GlyphLab szerződés szerinti data/ könyvtár, train 240/val 60/test 60 kép; a teszt nem választ modellt. Modell: a 20.1. Feature extractor és új fej alfejezetből ismert CNN: conv1(1→8, 3×3, stride 2, padding 1) + BN + ReLU, conv2(8→16, 3×3, stride 2, padding 1) + BN + ReLU, GAP, Linear(16→3). A head a GAP + Linear; a partial változat a conv2–BN2 blokkot engedi fel. Baseline: head-only. Változó tényező: a backbone utolsó blokkjának aktiválása és LR-je. Metrika: validációs accuracy; mellékesen train loss. Keresési keret: Adam, batch 32, 10 epoch, seed 20261020, float32 CPU, 3 seed ajánlott.

A kód munkakönyvtára lab/, futtatási parancs: python lab/finetune_compare.py.

import csv
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(20261020)
device = torch.device("cpu")
dtype = torch.float32

# GlyphLab betöltés
X = np.load("data/images.npy").astype(np.float32)    # (360,1,24,24)
y = np.load("data/labels.npy").astype(np.int64)      # (360,)
with open("data/metadata.csv", newline="") as f:
    split = np.array([row["split"] for row in csv.DictReader(f)])

X = torch.from_numpy(X).to(device)
y = torch.from_numpy(y).to(device)
train_idx = np.where(split == "train")[0]
val_idx   = np.where(split == "val")[0]
train_loader = DataLoader(TensorDataset(X[train_idx], y[train_idx]),
                          batch_size=32, shuffle=True)
val_loader   = DataLoader(TensorDataset(X[val_idx], y[val_idx]),
                          batch_size=64, shuffle=False)

class GlyphLabCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Conv2d(1, 8, 3, stride=2, padding=1),
            nn.BatchNorm2d(8),
            nn.ReLU(),
            nn.Conv2d(8, 16, 3, stride=2, padding=1),
            nn.BatchNorm2d(16),
            nn.ReLU(),
        )
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1),
            nn.Flatten(),
            nn.Linear(16, 3),
        )
    def forward(self, x):
        return self.head(self.backbone(x))

def set_frozen_layerwise(model, backbone_slice, head_trainable):
    for p in model.parameters():
        p.requires_grad = False
    for p in backbone_slice.parameters():
        p.requires_grad = True
    for p in model.head.parameters():
        p.requires_grad = head_trainable

def evaluate(model, loader):
    model.eval()
    correct = total = 0
    with torch.no_grad():
        for xb, yb in loader:
            logits = model(xb)
            correct += (logits.argmax(1) == yb).sum().item()
            total += yb.size(0)
    return correct / total

def run_variant(partial):
    model = GlyphLabCNN().to(device).to(dtype)
    if partial:
        # utolsó backbone blokk: index 3-tól (conv2, BN2, ReLU)
        set_frozen_layerwise(model, model.backbone[3:], True)
        model.backbone[:3].eval()   # befagyasztott conv1, BN1
        opt = torch.optim.Adam([
            {"params": model.head.parameters(), "lr": 0.01},
            {"params": model.backbone[3:].parameters(), "lr": 0.001},
        ])
    else:
        set_frozen_layerwise(model, model.backbone[0:0], True)
        model.backbone.eval()       # minden BN eval-ban
        opt = torch.optim.Adam(model.head.parameters(), lr=0.01)
    loss_fn = nn.CrossEntropyLoss()
    train_losses = []
    for epoch in range(10):
        model.train()
        if not partial:
            model.backbone.eval()   # a head train, a backbone eval marad
        epoch_loss = 0.0
        for xb, yb in train_loader:
            opt.zero_grad()
            loss = loss_fn(model(xb), yb)
            loss.backward()
            opt.step()
            epoch_loss += loss.item() * xb.size(0)
        train_losses.append(epoch_loss / len(train_idx))
    val_acc = evaluate(model, val_loader)
    return train_losses[-1], val_acc

head_loss, head_acc = run_variant(partial=False)
part_loss, part_acc = run_variant(partial=True)
print(f"head-only:  train_loss={head_loss:.3f}, val_acc={head_acc:.3f}")
print(f"partial:    train_loss={part_loss:.3f}, val_acc={part_acc:.3f}")

A kimenet e kéziratban jelölt szemléltetés, nem mért futás; a hallgató a fenti paranccsal futtatja:

head-only:  train_loss=0.428, val_acc=0.817
partial:    train_loss=0.351, val_acc=0.833

Az értelmezéshez legalább három seedet kell futtatni, mert egyetlen seed különbsége nem választja el a modellvariáns és a minibatch-zaj hatását.

20.2.6 Feladatok

F1 (fogalmi, nehézség 1). Magyarázza el a különbséget a következő három fogalom között: (a) requires_grad=False; (b) a paraméter kihagyása az optimizer paramétercsoportjaiból; (c) model.eval() mód. Adjon mindháromra egy-egy mondatos példát, és mutasson egy olyan helyzetet, ahol (a) teljesül, de a fagyasztott modul állapota mégis változik.

F2 (levezetési, nehézség 1). Legyen L(wh,wb)=(2wh−wb+1)2L(w_h,w_b)=(2w_h-w_b+1)^2, kezdeti wh=1w_h=1, wb=0.5w_b=0.5, ηh=0.2\eta_h=0.2, ηb=0.02\eta_b=0.02. Számítsa ki a gradienseket, majd a frissített paramétereket és a loss értékét (i) head-only, (ii) partial unfreeze esetén. Ellenőrizze, hogy a partial eset loss-a kisebb-e a head-only eseténél.

F3 (implementációs, nehézség 2). Írjon futtatható PyTorch kódot, amely igazolja az Állítás 1-et: egy BatchNorm2d(4) modul γ,β\gamma,\beta paramétereire állítsa be a requires_grad=False értéket, majd train módban hajtson végre egy forwardot, és ellenőrizze, hogy a running_mean megváltozott; ezután eval módban ismételje meg, és ellenőrizze, hogy ott nem változott. Adja meg a seedet, a bemeneti alakot és a várt kimenetet.

F4 (hibakeresési, nehézség 2). Az alábbi head-only tanítási vázlat hibás:

def train_head_only_bad(model, loader, epochs):
    model.train()                       # hiba itt
    for p in model.backbone.parameters():
        p.requires_grad = False
    for p in model.head.parameters():
        p.requires_grad = True
    opt = torch.optim.Adam(model.head.parameters(), lr=0.01)
    ...

Lokalizálja a hibát, magyarázza el a következményét a BN running statisztikákra és a későbbi eval pontosságra, majd adja meg a javítást és egy ellenőrzési lépést.

F5 (kísérlettervezési/transzfer, nehézség 3). Tervezzen kontrollált kísérletet a GlyphLab-CNN-en háromfázisú gradual unfreeze hatásának mérésére: (1) head-only, (2) utolsó blokk + head, (3) mindkét blokk + head. Adja meg a kérdést, a baseline-t, a metrikákat, a split- és seed-kezelést, a validációs kiválasztási szabályt, és a következtetés legalább két konkrét korlátját. Nem kell kódot írnia, de a protokollnak lektorálhatónak kell lennie.

20.2.7 Megoldások és indoklás

F1 megoldás. (a) A requires_grad=False azt tiltja, hogy az autograd a paraméterhez gradienst tároljon; ezáltal az optimizer sem tudja frissíteni. (b) Ha a paraméter requires_grad=True, de nincs benne egyetlen optimizer paramétercsoportban sem, akkor a gradiens kiszámolódhat, de a frissítés elmarad; ez ritkán kívánatos, mert felesleges számítást okoz. (c) Az eval() mód a training-specifikus viselkedéseket kapcsolja ki: a BatchNorm a running statisztikákat használja, nem frissíti azokat, és a dropout inaktív. Az (a) melletti állapotváltozásra példa a fenti Állítás 1: a fagyasztott BN train módban frissíti a running statisztikáit.

F2 megoldás. A hibatag e=2wh−wb+1=2⋅1−0.5+1=2.5e = 2w_h - w_b + 1 = 2\cdot 1 - 0.5 + 1 = 2.5. A gradiensek: ∂L∂wh=2⋅2e=4e=10,∂L∂wb=−2e=−5.\frac{\partial L}{\partial w_h} = 2\cdot 2e = 4e = 10, \qquad \frac{\partial L}{\partial w_b} = -2e = -5. (i) Head-only: wh′=1−0.2⋅10=−1w_h' = 1 - 0.2\cdot 10 = -1, wb′=0.5w_b'=0.5. Új hibatag 2(−1)−0.5+1=−1.52(-1)-0.5+1 = -1.5, loss 2.252.25. (ii) Partial: wh′=−1w_h'=-1, wb′=0.5−0.02⋅(−5)=0.6w_b' = 0.5 - 0.02\cdot(-5) = 0.6. Új hibatag −2−0.6+1=−1.6-2-0.6+1=-1.6, loss 2.56>2.252.56 > 2.25. Tehát ebben a példában a partial frissítés rontott; ez mutatja, hogy a felengedés nem garantál monoton javulást, és a heurisztikát empirikusan kell ellenőrizni.

F3 megoldás.

import torch
import torch.nn as nn

torch.manual_seed(0)
bn = nn.BatchNorm2d(4)
bn.weight.requires_grad_(False)
bn.bias.requires_grad_(False)

bn.train()
before = bn.running_mean.clone()
bn(torch.randn(2, 4, 3, 3))
print("train mód, változott:", not torch.equal(before, bn.running_mean))
# várt: True

bn.eval()
before = bn.running_mean.clone()
bn(torch.randn(2, 4, 3, 3))
print("eval mód, változott:", not torch.equal(before, bn.running_mean))
# várt: False

A momentum frissítés miatt train módban a running statisztika csak akkor maradna változatlan, ha a batch átlag éppen megegyezne a régivel; normál véletlen bemenetre a változás valószínűsége 1.

F4 megoldás. A hiba a model.train() hívás: a befagyasztott backbone BN rétegei train módban maradnak, ezért minden forward frissíti a running statisztikákat, miközben a backbone súlyai nem tanulnak. A következmény: a batch-statisztikák az új eloszláshoz igazodnak, de a súlyok nem; a fagyasztott reprezentáció és a rögzített statisztika inkonzisztenssé válik, ami a későbbi eval kiértékelést ronthatja. A javítás:

def train_head_only_fixed(model, loader, epochs):
    model.train()
    model.backbone.eval()     # a fagyasztott BN-ek eval módban
    for p in model.backbone.parameters():
        p.requires_grad = False
    ...

Ellenőrzés: a tanítás előtt és után klónozzuk a model.backbone[1].running_mean értéket, és igazoljuk, hogy azonos maradt; a head paraméterei viszont változtak.

F5 megoldásminta. Kérdés: a fokozatos felengedés javítja-e a validációs accuracy-t a head-only baseline-hoz képest a GlyphLab-CNN-en? Adat: a GlyphLab train/val/test splitje; a test kizárólag a végleges, validációval kiválasztott modellre kerül. Baseline: head-only, Adam, ηhead=0.01\eta_\text{head}=0.01. Kezelések: (1) head-only 30 epoch; (2) utolsó blokk + head, ηhead=0.01\eta_\text{head}=0.01, ηlast=0.001\eta_\text{last}=0.001; (3) mindkét blokk + head, ηhead=0.01\eta_\text{head}=0.01, η10=0.001\eta_{10}=0.001, η9=0.0001\eta_{9}=0.0001. Mindegyik kezelést 3 seeddel futtatjuk; a kiválasztás a validációs accuracy seed-átlagán alapul. Metrika: validációs accuracy, valamint a train loss görbe a túlillesztés jelzésére. Korlátok: a GlyphLab nem természetes kép, az eredmény nem általánosítható valódi domain shiftre; a 3 seed kevés a statisztikailag robusztus különbségtételhez, ezért csak leíró összehasonlítást teszünk, konfidenciaintervallum nélkül; a teszt nem használható modellválasztásra, különben a meta-tanulási hiba felfújja a becsült pontosságot (8.2. Véges hipotézisosztály egyenletes korlátja finite-H gondolata: a nagyobb keresési tér növeli a statisztikai hibát).

20.2.8 Tipikus tévedés ellenpéldával

A leggyakoribb tévedés: „Ha requires_grad=False, akkor a modul minden állapota befagyott.” Ez hamis. Az Állítás 1 bizonyítja, hogy a BatchNorm running_mean és running_var bufferei train módban frissülnek akkor is, ha a γ,β\gamma,\beta paraméterek nem tanulnak. A fenti F3 kód a konkrét ellenpéldát adja: a running_mean megváltozik train módban, és nem változik eval módban. Ezért a befagyasztott backbone-ot a head-only adaptáció során explicit eval() módba kell tenni; a partial unfreeze esetén pedig csak a felengedett blokkokat hagyjuk train módban.

20.2.9 Önellenőrzés és visszahívás

Ellenőrizze: (1) minden fagyasztott BN eval módban van-e; (2) az optimizer paramétercsoportjai pontosan az aktív paramétereket tartalmazzák-e; (3) a validációs metrika nem választott-e tesztet; (4) ugyanaz a seed és dtype szerepel-e minden futásban; (5) a csoportok LR értékei a tervezett arányt követik-e.

Visszahívás: a 8.2. Véges hipotézisosztály egyenletes korlátja finite-H korlátja szerint a nagyobb keresési tér nagyobb statisztikai hibát okozhat; a partial unfreeze ezért nem automatikusan jobb, hanem validációval igazolandó kompromisszum. A 16.2. Pooling, downsampling és aliasing tanulsága, hogy a GAP előtti utolsó blokk adaptálása a pooling előtti, legnagyobb térbeli részletességű reprezentációt érinti, ezért a legnagyobb hatású felengedési pont. A 19.2. MobileNet és inverted residual inverted residual blokkjában a blokkstruktúra adja a gradual unfreeze természetes határait: nem tetszőleges tenzort, hanem a blokk kimenetét engedjük fel.

20.2.10 Rövid összefoglaló

A fine-tuning az előtanított backbone legalább egy részének és a fejnek együttes adaptációja. A gradual unfreeze a blokkokat fokozatosan, a discriminative LR pedig rétegenként eltérő tanulási rátával tanítja; mindkettő heurisztika, validációval kontrolálandó. Bebizonyítottuk, hogy a requires_grad=False nem egyenlő az eval móddal: a fagyasztott BatchNorm train módban frissíti running statisztikáit. A labor a GlyphLab-CNN-en hasonlítja össze a head-only és a részleges felengedést rögzített seeddel; a közölt kimenet jelölt szemléltetés, nem mért futás. A feladatok a fogalmi különbségeket, a kézi frissítést, a buffer-tesztet, a hibajavítást és a kísérlettervet fedik le.

20.2.11 Következő alkalmazás és források

A következő egység 20.3. Preprocessing, domain shift és súlylicenc a valódi külső súlyok preprocessing-szerződését, a domain shift mérését és a súlylicenc kérdéseit tárgyalja; az itteni paramétercsoport- és buffer-ismeret ott válik szükségessé, amikor konkrét torchvision modellt illesztünk.

20.2.12 Felhasznált források