12.2 Checkpoint és folytathatóság

12.2.1 Cél, előismeret és induló becslés

Ez az alfejezet azt a kérdést rendezi, mit jelent valójában „elmentettük a modellt”. A cél: megkülönböztetni a csak következtetésre alkalmas súlyállapotot a tanítás folytathatóságát biztosító teljes állapottól, és megadni, milyen feltételek mellett ad egy megszakított tanítás a megszakítás nélkülivel azonos következő frissítést. Előismeret: a 12.1. Tanítás, validáció és kiértékelési mód tanítási iterációja és eval-módja, a 11.2. nn.Module és paraméterregisztráció state_dict- és bufferfogalma. Induló becslés: elég-e a paraméterek mentése ahhoz, hogy a tanítás ott folytatódjon, ahol abbamaradt? Momentumos optimalizáló esetén a válasz nem, és ezt alább egy számpélda pontosan megmutatja.

12.2.2 Checkpoint, inference-súly és tanítási állapot

Definíció. Egy modell state_dict-je (állapotszótár) rendezett kulcs–érték leképezés, amely a modell összes Parameter és perzisztens (alapértelmezett, persistent=True jelzéssel regisztrált) buffer értékét tartalmazza; a persistent=False bufferek kimaradnak belőle. A kulcsok a modulhierarchiát tükrözik, például egy nn.Sequential(nn.Linear(3,4), nn.ReLU(), nn.Linear(4,1)) modellben:

BatchNorm-os modellben a bufferek is a state_dict-ben vannak: running_mean alakja (C,)(C,), running_var alakja (C,)(C,), num_batches_tracked egy int64 tenzor. A state_dict értékei tenzorok; a kulcsok stringek.

Inference-only állapot. Ha csak a modell state_dict-jét mentjük, a modell újra betölthető és model.eval() módban előre számítható, de a tanítási folyamat belső állapota elveszett. Az optimizer momentum- vagy Adam-pufferei, az ütemező számlálója és a véletlenszám-generátor állapota nincs benne.

Teljes tanítási állapot. A folytathatósághoz az alábbi komponensek együttes mentése szükséges:

  1. Modellállapot: model.state_dict() — tanuló paraméterek és nem tanuló bufferek.
  2. Optimalizáló állapot: optimizer.state_dict() — param_groups (például lr, betas, eps, weight_decay) és a paraméterenkénti belső puffer, például momentumos SGD-nél momentum_buffer, Adamnél exp_avg, exp_avg_sq és step.
  3. Ütemező állapot: ha van, scheduler.state_dict() — a tanulási ráta ütemezésének belső pozíciója.
  4. RNG-állapot (alapút, CPU): pontosan azoknak a véletlenforrásoknak az állapota, amelyeket a kód ténylegesen használ. A globális PyTorch CPU-generátorhoz (dropout, torch.randperm, inicializálás) torch.get_rng_state(); ha a DataLoader saját generátort kap (DataLoader(..., generator=g)), a g.get_state(); NumPy új API-nál (rng = np.random.default_rng(seed)) az rng.bit_generator.state, a régi globális API-nál np.random.get_state(); a Python random moduljánál random.getstate(). Amit a kód nem használ, azt nem kell menteni; amit használ, de nem mentünk, az a folytatás után más véletlen sorozatot ad.
  5. Pozíció: epoch, global_step, valamint a napló szerinti best_val_loss és best_step a legjobb modell kiválasztásához.
  6. Adatfolyam-pozíció, két külön esetben. (a) Full-batch tanításnál (egy lépés a teljes train halmaz) nincs sampler-pozíció; epochhatáron mentve elég, ha a következő epoch sorrendjét sorsoló generátor állapota a 4. pontban mentve van. (b) Minibatch-határon, epoch közben mentve az aktuális epoch indexsorrendjét is tárolni kell, vagy determinisztikusan rekonstruálni (az epoch elején mentett sampler-generátorállapotból újrasorsolva), és el kell menteni az elhasznált batch-ek számát; a folytatás ennyi batch kihagyásával indul. Ha num_workers>0, a workerek előre betöltött (prefetch), de még fel nem dolgozott batch-ei miatt az iterátor állapota nem menthető közvetlenül: az elhasznált batchszámból kell újraépíteni, a worker-seedeket pedig a mentett generátorállapotból rögzíteni.
  7. Bővített út (GPU, kevert pontosság): minden eszköz CUDA-generátorának állapota, torch.cuda.get_rng_state_all(); kevert pontosságnál a GradScaler állapota, scaler.state_dict() (aktuális skála, növekedési számláló). Enélkül a folytatás első lépései más skálával futnak, és más lépéseket dobnak el, mint a megszakítás nélküli futás.

A tankönyv CPU-s főútján az 1–6. pont a követelmény; a 7. pont csak a GPU-s, AMP-t használó bővített útra vonatkozik.

Az epoch nem tanult állapot, hanem pozíciószámláló: azt mondja meg, hányadik teljes adatbejárásnál tartunk. A best snapshot ettől különböző fogalom: a validációs veszteség szerint eddig legjobb modell rögzített, független másolata. A helyes másolat {k: v.detach().clone() for k, v in model.state_dict().items()} vagy copy.deepcopy(model.state_dict()). A sima referencia megtartása hiba, mert a state_dict() tenzorai a modell paramétereinek háttértárolójára mutatnak, így a következő optimizer.step() in-place frissítése a „legjobb” másolatot is megváltoztatja.

12.2.3 A folytathatósági lemma és a bitazonosság feltételei

Legyen egy tanítási lépés az

F:(wt,st,rt,gt,ηt)↦(wt+1,st+1,rt+1)F:\ (w_t, s_t, r_t, g_t, \eta_t)\mapsto (w_{t+1}, s_{t+1}, r_{t+1})

függvény, ahol wtw_t a paraméterek és bufferek együttese, sts_t az optimizer belső állapota, rtr_t az RNG-állapot, gtg_t a batch-gradiens, ηt\eta_t pedig az aktuális tanulási ráta.

Lemma (azonos következő frissítés platformon belül). Ha az FF által végrehajtott lebegőpontos műveletek sorrendje, dtype-ja és eszköze két futásban azonos, FF determinisztikus, és a wt,st,rt,gt,ηtw_t, s_t, r_t, g_t, \eta_t mindegyike bitazonos a két futásban, akkor a következő frissítés eredménye is bitazonos ugyanazon a platformon és könyvtárverzión.

Bizonyításvázlat. Ez a determinisztikus függvény definíciójából közvetlenül következik: azonos bemenet azonos kimenetet ad. A valódi, nem triviális tartalom a feltételrendszerben van. A float összeadás nem asszociatív, ezért a bitazonossághoz a műveletek azonos sorrendje is kell, nem csak az azonos matematikai összeg. A [D02] dokumentáció ezt a platformok közötti irányba is kiterjeszti: a seed önmagában nem garantál bitazonosságot más platformon, verzióban vagy kernelválasztásnál. A lemma tehát nem keresztplatformos ígéret; ez a tankönyv kísérleti főútján kis CPU-s, azonos környezetű futásokra vonatkozik.

A szükséges állapot és feltételek listája együtt adja a választ a fejezet levezetési kötelezettségére:

12.2.4 Kézi számpélda: miért nem elég a súly?

Momentumos SGD, PyTorch-konvenció: btb_t a momentum-puffer, gtg_t a gradiens, η\eta a tanulási ráta, μ\mu a momentum, dampening és weight decay nélkül:

bt+1=μbt+gt,wt+1=wt−ηbt+1.b_{t+1} = \mu b_t + g_t, \qquad w_{t+1} = w_t - \eta b_{t+1}.

Legyen w0=1w_0=1, η=0,5\eta=0{,}5, μ=0,9\mu=0{,}9, b0=0b_0=0. Az első batch gradiense g0=2g_0=2, a másodiké g1=1g_1=1.

  1. lépés: b1=0,9⋅0+2=2b_1=0{,}9\cdot 0+2=2, w1=1−0,5⋅2=0w_1=1-0{,}5\cdot 2=0.
  2. lépés helyes pufferrel: b2=0,9⋅2+1=2,8b_2=0{,}9\cdot 2+1=2{,}8, w2=0−0,5⋅2,8=−1,4w_2=0-0{,}5\cdot 2{,}8=-1{,}4.
  3. lépés csak w1=0w_1=0 mentésével, friss pufferrel b′1=0b'_1=0: b′2=1b'_2=1, w′2=0−0,5⋅1=−0,5w'_2=0-0{,}5\cdot 1=-0{,}5.

Az eltérés |w2−w′2|=0,9|w_2-w'_2|=0{,}9. A különbség a következő lépésekben tovább halmozódik. Ez az ellenpélda mutatja, hogy az inference-súly nem elégséges a tanítás folytatásához.

12.2.5 Labor: megszakított és megszakítás nélküli futás összehasonlítása

A labor munkakönyvtára lab/n046_checkpoint/. A cél annak kimutatása, hogy a teljes állapotból folytatott kis CPU-futás paramétereiben és veszteségében azonos a kontrollal. A script szándékosan nem használ DataLoader-t: teljes batch-tel dolgozik, így a [D08] iterátorállapot kérdése nem terheli a reprodukciót. Az adat 16 darab 3-dimenziós szintetikus bemenet, a modell a 11.2. nn.Module és paraméterregisztráció alfejezetből ismert 3-4-1 MLP, a loss MSE, az optimizer momentumos SGD.

import os
import torch
from torch import nn, optim

HERE = "lab/n046_checkpoint"
os.makedirs(HERE, exist_ok=True)

SEED = 20261020
DTYPE = torch.float32
DEVICE = torch.device("cpu")
LR = 0.5
MOMENTUM = 0.9
STEPS_A = 2
TOTAL = 4

torch.manual_seed(SEED)
torch.use_deterministic_algorithms(True)

X = torch.linspace(-1.0, 1.0, 48, dtype=DTYPE).reshape(16, 3)
Y = (1.5 * X[:, 0] + 0.5 * X[:, 1] - 0.7 * X[:, 2] + 0.2).unsqueeze(1)

def make_model():
    torch.manual_seed(SEED)
    return nn.Sequential(nn.Linear(3, 4), nn.ReLU(), nn.Linear(4, 1)).to(DEVICE)

def one_step(model, optimizer):
    model.train()
    optimizer.zero_grad()
    loss = torch.nn.functional.mse_loss(model(X), Y)
    loss.backward()
    optimizer.step()
    return loss.item()

# 1. Kontroll: négy lépés egyben.
model_cont = make_model()
opt_cont = optim.SGD(model_cont.parameters(), lr=LR, momentum=MOMENTUM)
losses_cont = [one_step(model_cont, opt_cont) for _ in range(TOTAL)]

# 2. Checkpoint-ág: két lépés, teljes állapot mentése.
model_a = make_model()
opt_a = optim.SGD(model_a.parameters(), lr=LR, momentum=MOMENTUM)
losses_a = [one_step(model_a, opt_a) for _ in range(STEPS_A)]
cp = {
    "model": model_a.state_dict(),
    "optimizer": opt_a.state_dict(),
    "epoch": 0,
    "step": STEPS_A,
    "rng": torch.get_rng_state(),
}
torch.save(cp, f"{HERE}/checkpoint.pt")

# 3. Új folyamat szimulálása: friss modell és optimizer, betöltés.
model_b = make_model()
opt_b = optim.SGD(model_b.parameters(), lr=LR, momentum=MOMENTUM)
cp2 = torch.load(f"{HERE}/checkpoint.pt", weights_only=False)
model_b.load_state_dict(cp2["model"])
opt_b.load_state_dict(cp2["optimizer"])
torch.set_rng_state(cp2["rng"])
losses_b = [one_step(model_b, opt_b) for _ in range(TOTAL - STEPS_A)]
losses_chk = losses_a + losses_b

# 4. Bitazonossági ellenőrzés a paramétereken.
max_abs = max((p1.detach() - p2.detach()).abs().max().item()
              for p1, p2 in zip(model_cont.parameters(), model_b.parameters()))
print("control_losses:", [f"{v:.6f}" for v in losses_cont])
print("resumed_losses:", [f"{v:.6f}" for v in losses_chk])
print(f"max_abs_diff={max_abs:.3e}")
assert max_abs < 1e-6

# 5. A referenciamásolat hibájának bemutatása.
snapshot_ref = model_b.state_dict()          # referencia, nem független másolat
one_step(model_b, opt_b)                      # in-place mutáció
print("reference_is_mutated:",
      snapshot_ref["0.weight"].equal(model_b[0].weight))

snapshot_best = {k: v.detach().clone() for k, v in model_b.state_dict().items()}
one_step(model_b, opt_b)
print("clone_is_independent:",
      not snapshot_best["0.weight"].equal(model_b[0].weight))

Futtatási parancs a munkakönyvtárból: python checkpoint_lab.py. Az átadandó eredmény a lab/n046_checkpoint/checkpoint.pt fájl, a control_losses és resumed_losses listák, a max_abs_diff érték, valamint a reference_is_mutated és clone_is_independent logikai értékek. Az alábbi kimenet a kód logikája alapján várt, nem e kéziratban elvégzett futtatás:

<table> <tr><th>Ellenőrzés</th><th>Várt érték</th><th>Értelmezés</th></tr> <tr><td><code>max_abs_diff</code></td><td><code>< 1e-6</code>, e felépítésben <code>0.000e+00</code></td><td>bitazonos folytatás azonos platformon</td></tr> <tr><td><code>reference_is_mutated</code></td><td><code>True</code></td><td>a referencia követi a mutációt</td></tr> <tr><td><code>clone_is_independent</code></td><td><code>True</code></td><td>a mély másolat független</td></tr> </table>

A weights_only=False itt azért szükséges, mert a teljes tanítási állapot nem csak tenzorokat tartalmaz: az optimizer state része int és float metaadatokat is tárol. [D09] hangsúlyozza, hogy nem megbízható forrásból származó adatnál a weights_only=True a biztonságos alapbeállítás; a labor saját, megbízható checkpointot állít elő, ezért a teljes objektum visszaállítása itt indokolt.

12.2.6 Feladatok

F1 (fogalmi, nehézség 1). Fogalmazd meg, mi a különbség az inference-only state_dict és a teljes tanítási állapot között, és sorolj fel legalább négy komponenst az utóbbiból. Elvárt átadandó: definíció, komponenslista és egy példa, amelyben a hiányzó komponens megváltoztatná a következő frissítést.

F2 (levezetési, nehézség 2). A momentumos SGD képleteivel számold ki a w0=1w_0=1, η=0,5\eta=0{,}5, μ=0,9\mu=0{,}9, b0=0b_0=0 sorozatot a g0=2g_0=2, g1=1g_1=1, g2=−0,5g_2=-0{,}5 gradiensekre. Add meg a 3. lépés előtti teljes állapotot, majd hasonlítsd össze a helyes és a csak w2w_2-ből indított hibás folytatást. Elvárt átadandó: képletek, minden közbülső érték és a két w3w_3 közötti eltérés.

F3 (implementációs, nehézség 2). Írj egy save_training_state(model, optimizer, scheduler, epoch, step, path) függvényt, amely a teljes állapotot menti, és egy load_training_state(path, build_fn) függvényt, amely friss modellt, optimizert és schedulert épít a build_fn hívással, majd betölti az állapotot. A labor MLP-jén ellenőrizd a betöltés utáni folytatást. Elvárt átadandó: futtatható kód, mentett fájl, max_abs_diff < 1e-6.

F4 (hibakeresési, nehézség 2). Adott a hiba: best_state = model.state_dict() elmentése a validáció után, majd további tanítási lépések, és a best_state a kiértékeléskor az új súlyokat tartalmazza. Lokalizáld az okot, írd le a következményt, add meg a javítást, és olyan ellenőrzést, amely a hiba jelenlétét kimutatja. Elvárt átadandó: ok–következmény–javítás–ellenőrzés lánc, data_ptr vagy torch.equal alapú teszt.

F5 (kísérleti/transzfer, nehézség 3). Tervezz kísérletet, amely 4 különböző seed mellett vizsgálja, hogy a checkpointból folytatott futás veszteséggörbéje statisztikailag egyenértékű-e a megszakítás nélküli kontrolléval. Adj protokollt: kérdés, adat, megfigyelési egység, baseline, változó, metrika, erőforrás, a CI értelmezése és a következtetési korlát. Elvárt átadandó: 8-10 lépéses terv, alternatív metrika indoklása és a [S02]-höz illeszkedő feltételes értelmezés.

12.2.7 Megoldások és indoklás

F1 megoldás. Az inference-only állapot a modell state_dict-je: paraméterek és bufferek, tehát a súlyok és a futó statisztikák. Predictionre elég, de a tanítási folyamat nem folytatható belőle. A teljes állapot ezen felül tartalmazza: (i) optimizer state_dict (momentum/Adam-pufferek, step, paramétercsoportok), (ii) scheduler state_dict, (iii) RNG-állapot, (iv) pozíció (epoch, global_step), szükség esetén (v) adatbetöltő-pozíció. Példa: ha csak a modell state_dict-jét mentjük momentumos SGD-nél, az újraindulás b0=0b_0=0 pufferrel számol, így az első folytatott lépés más irányú, mint a megszakítás nélküli futásban.

F2 megoldás. A frissítés: bt+1=μbt+gtb_{t+1}=\mu b_t+g_t, wt+1=wt−ηbt+1w_{t+1}=w_t-\eta b_{t+1}.

Eltérés: |w3−w′3|=1,26|w_3-w'_3|=1{,}26. Alakellenőrzés: minden mennyiség skalár, a gradiensek és a puffer azonos mértékegységű; a puffer elhagyása a gradiens előzményét nullázza.

F3 megoldás.

import os, torch
from torch import nn, optim

HERE = "lab/n046_checkpoint"
os.makedirs(HERE, exist_ok=True)

def save_training_state(model, optimizer, scheduler, epoch, step, path):
    state = {
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scheduler": scheduler.state_dict() if scheduler is not None else None,
        "epoch": epoch,
        "step": step,
        "rng": torch.get_rng_state(),
    }
    torch.save(state, path)

def load_training_state(path, build_fn):
    state = torch.load(path, weights_only=False)
    model, optimizer, scheduler = build_fn()
    model.load_state_dict(state["model"])
    optimizer.load_state_dict(state["optimizer"])
    if scheduler is not None and state["scheduler"] is not None:
        scheduler.load_state_dict(state["scheduler"])
    torch.set_rng_state(state["rng"])
    return model, optimizer, scheduler, state["epoch"], state["step"]

A build_fn a labor make_model()-jét és az SGD-konstrukciót adja vissza. Az ellenőrzés a labor 3-4. lépését ismétli: a visszatöltött modellen két további lépés után a paraméterek max abszolút eltérése a kontrolltól <10−6<10^{-6}.

F4 megoldás. Ok: a model.state_dict() shallow másolat, az értéktenzorok a modell paramétereinek háttértárolójára mutatnak; az optimizer.step() jellemzően in-place (add_) frissít, ezért a tárolt best_state is mutálódik. Következmény: a „legjobb” modell nem a kiválasztási pillanat súlyait őrzi, hanem a későbbi, rosszabb validációs állapotot, tehát a tesztkiértékelés optimistává vagy torzítottá válik. Javítás: copy.deepcopy(model.state_dict()) vagy {k: v.detach().clone() for k, v in model.state_dict().items()}. Ellenőrzés: a másolat data_ptr()-e térjen el a paraméterétől, vagy egy in-place lépés után snapshot["0.weight"] ne legyen torch.equal a model[0].weight-tel. A labor 5. pontja éppen ezt a negatív és pozitív tesztet mutatja.

F5 megoldásminta. (1) Kérdés: a teljes állapotból folytatott futás végső val loss-ja azonos eloszlású-e a megszakítás nélküli kontrolléval. (2) Adat: GlyphLab data/images.npy, data/labels.npy, train split osztályonkénti jelenet-struktúrával; a validálás a val spliten, scene-szintű felosztással. (3) Megfigyelési egység: egy seed által definiált teljes futás; a képek nem függetlenek a scene-ek miatt. (4) Baseline: megszakítás nélküli 4 epoch. (5) Kezelés: 2 epoch után mentés, új modell/optimizer építés, betöltés, további 2 epoch. (6) Metrika: seedenként párosított különbség a kontroll és a folytatott futás végső val loss-ában, és max_abs_diff a paramétereken; a bitazonosság egy seeden belül, a statisztikai egyenértékűség a seedek közötti páros különbségek átlagával vizsgálható. (7) Intervallum: a páros különbségek átlagára tt-alapú CI a [S02] feltételes, ismételt mintavételi értelmezésével; 4 seed kis minta, ezért az intervallum széles és csak iránymutató. (8) Erőforrás: CPU, kis MLP, előtanított súly nélkül; a négy seed ismétlése becsült helyi futásidővel. (9) Következtetési korlát: az eredmény csak a vizsgált platformra, verzióra és adatra vonatkozik; nem bizonyít keresztplatformos bitazonosságot, és nem általánosít más architektúrára.

12.2.8 Tipikus tévedés és önellenőrzés

A klasszikus hiba a best_state = model.state_dict() referencia tárolása. A fenti labor 5. pontja méri: egy további lépés után reference_is_mutated igaz, mert a tárolt tenzor ugyanarra a memóriaterületre mutat. Önellenőrző kérdések: (1) Miért nem elegendő a súlymentés momentumos SGD-nél? (2) A betöltésnél miért kell az optimizer state_dict-je is? (3) A seed-platform bitazonosságot garantál? Válaszok: (1) a momentum-puffer az előző gradiensek állapotát hordozza; (2) mert a frissítés pufferből indul; (3) nem, a [D02] szerint a seed nem garantál platformok közötti bitazonosságot. Visszahívás: a 11.2. nn.Module és paraméterregisztráció state_dict-je a paramétereket és puffereket együtt kezeli; a 8.2. Véges hipotézisosztály egyenletes korlátja konfidenciaintervallum-logikája pedig arra figyelmeztet, hogy a seedek közötti szórás és a betanított modell kockázatának bizonytalansága külön fogalom, a bitazonosság egyetlen seeden belül sem statisztikai garancia a generalizációra.

12.2.9 Rövid összefoglaló

A checkpoint nem egyszerűen a súlyok mentése. A csak következtetési state_dict a paramétereket és buffereket tartalmazza, de a tanítás folytatásához az optimizer pufferei, az ütemező állapota, az RNG-állapot és a pozíció is szükséges. A folytathatósági lemma szerint azonos platformon, dtype-on és műveletsorrend mellett a teljes állapot bitazonossága azonos következő frissítést ad; platformok között ez nem garantált. A labor kétlépéses megszakítással és újraépített modellel ellenőrzi a paraméterek azonosságát, és kimutatja, hogy a state_dict referencia hiba, a detach().clone() független másolat.

12.2.10 Következő alkalmazás

A 12.3. Sanity check és kis mintára túlillesztés sanity check ezt a folytathatóságot használja fel: a mentett állapotból kiindulva kis mintára való túlillesztéssel ellenőrzi, hogy a modell és az optimizer együtt valóban képes tanulni, mielőtt hosszabb kísérletet indítanánk.

12.2.11 Felhasznált források