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
,
a backbone utolsó blokkjáé pedig
.
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.)
Definíció (fine-tuning). Legyen a modell
paramétereinek halmaza
,
ahol
a backbone (feature extractor) paramétereit,
a head paramétereit jelöli. Fine-tuningnak nevezzük azt az adaptációt,
amelyben legalább egy
-beli
paraméter aktív: requires_grad=True, szerepel az optimizer
valamely paramétercsoportjában, és a headdel együtt frissül. Ha
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
blokkba:
,
a mélység növekedésével. A fokozatos felengedés
fázisú ütemterv: a
-edik
fázisban az aktív halmaz
és minden
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: , tipikusan . Az optimizernek annyi paramétercsoportja van, ahány különböző értéket használunk; egy csoport a párt tárolja. SGD esetén a frissítés ahol a batch-átlagolt loss; Adam esetén 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 alakja mindig megegyezik 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.
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
egy PyTorch BatchNorm2d modul
track_running_stats=True beállítással,
momentummal. Tegyük fel, hogy mind
,
mind
esetén requires_grad=False. Ekkor:
Bizonyítás. A train módú forward képlete a PyTorch
dokumentációja szerint
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
és
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 |
Tekintsük a kétdimenziós modellt: a fej egyetlen súlya, a backbone egyetlen súlya. Legyen a batch-átlagolt loss kezdeti érték , . A gradiens: A kezdeti hibatag , tehát , .
Head-only: , fagyasztott. Frissítés: Az új hibatag , az új loss (kezdeti ).
Partial unfreeze, discriminative LR: , . Frissítés: Az új hibatag , az új loss .
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])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.
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 , kezdeti , , , . 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
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.
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 . A gradiensek: (i) Head-only: , . Új hibatag , loss . (ii) Partial: , . Új hibatag , loss . 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: FalseA 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, . Kezelések: (1) head-only 30 epoch; (2) utolsó blokk + head, , ; (3) mindkét blokk + head, , , . 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).
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
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.
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.
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.
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.