Danh sách bài viết

Bài 24: Early Stopping và Model Checkpoint

Sweet spot của training thường nằm ở một epoch nào đó giữa chừng, không phải epoch cuối. Train tiếp sau điểm đó, val loss đi lên còn train loss vẫn xuống — overfit. Early stopping theo dõi val loss, dừng training khi đã patience epoch không cải thiện, và song song lưu checkpoint của model tốt nhất để load lại sau. Bài này đi qua thuật toán, các tham số patience / min_delta / monitor, pattern save/load checkpoint với weights_only=True, custom EarlyStopping class, callback của PyTorch Lightning, resume training, và những pitfall thường gặp.

24/05/2026
12 phút đọc
1 lượt xem
1

Mục tiêu bài học

Sau bài học, bạn sẽ:

  • Hiểu sweet spot của training thường không trùng với epoch cuối, vì sao tiếp tục train sau đó dẫn tới overfit.
  • Cài đặt được thuật toán early stopping với patiencemin_delta.
  • Lưu và nạp lại checkpoint trong PyTorch đúng pattern, kèm weights_only=True cho production.
  • Viết được EarlyStopping class tái sử dụng, plug vào training loop.
  • Dùng được callback EarlyStopping của PyTorch Lightning khi cần stack giải pháp có sẵn.
  • Resume training từ checkpoint mà không phá vỡ trạng thái của optimizer và scheduler.

Bài này nối tiếp Bài 23 — Batch Normalization: BN giảm internal covariate shift để train ổn, còn early stopping quyết định khi nào dừng để không train quá tay. Sau bài này là Bài 25 — Learning Rate Scheduling — giảm learning rate theo thời gian, thường dùng chung cơ chế tracking val loss.

2

Vì sao cần early stopping

Khi train một neural network đủ capacity, curve điển hình của train loss và val loss có hình dạng sau:

  • Train loss giảm liên tục theo epoch — có thể tới gần 0 nếu mạng đủ lớn.
  • Val loss giảm cùng train loss trong giai đoạn đầu (model đang học pattern thực).
  • Tới một điểm chuyển, val loss bắt đầu đi ngang rồi đi lên, trong khi train loss vẫn tiếp tục giảm.

Khoảng cách mở rộng giữa train và val loss chính là dấu hiệu overfit. Sweet spot — model có generalization tốt nhất trên val — nằm ở quanh điểm chuyển này, không phải ở epoch cuối cùng.

Có hai lựa chọn:

  • Train cố định một số epoch lớn rồi tìm lại epoch tốt nhất từ log — tốn compute, vẫn phải save checkpoint mỗi epoch nếu muốn lấy lại weight.
  • Theo dõi val loss online, dừng training ngay khi val không còn cải thiện trong một số epoch nhất định, đồng thời lưu lại weight của epoch tốt nhất gặp đến giờ. Đây là early stopping.

Early stopping vừa là một dạng regularization (giới hạn dung lượng học bằng số bước update), vừa là cơ chế tiết kiệm compute. Goodfellow et al. (Deep Learning Book, 2016) gọi nó là "the most commonly used form of regularization in deep learning".

3

Thuật toán early stopping

Vòng lặp chuẩn:

  1. Track best_val_loss (giá trị val loss thấp nhất quan sát được). Khởi tạo bằng \( +\infty \).
  2. Sau mỗi epoch, tính val loss của epoch đó.
  3. So sánh với best_val_loss:
    • Nếu cải thiện (val loss mới < best − min_delta): cập nhật best_val_loss, save checkpoint, reset patience_counter = 0.
    • Nếu không: tăng patience_counter lên 1.
  4. Khi patience_counter ≥ patience: dừng training. Best checkpoint đã được lưu sẵn.

"Dừng" có thể là break khỏi vòng for, hoặc set flag để vòng ngoài biết kết thúc. Sau đó load lại best checkpoint trước khi đánh giá test hoặc đem inference.

Lưu ý: patience đếm theo epoch, không phải step. Một val pass thường chạy mỗi epoch, nên đơn vị tự nhiên là epoch. Nếu val pass chạy sau mỗi N step (dataset cực lớn), patience cũng tính theo lần val pass.

4

Ba tham số: patience, min_delta, monitor

  • patience: số epoch chờ trước khi dừng. Giá trị thường dùng 5–20. Nhỏ thì dừng sớm (có thể trước khi val loss đạt đáy thật); lớn thì tốn compute và đôi khi train quá vào vùng overfit nhẹ trước khi dừng.
  • min_delta: ngưỡng cải thiện tối thiểu để coi epoch mới là "tốt hơn". Mặc định 0 — bất kỳ cải thiện nào cũng tính. Khi val loss dao động nhỏ do randomness (batch sampling, dropout), đặt min_delta 1e-3 đến 1e-4 giúp tránh "fake improvement" liên tục reset counter làm thuật toán không bao giờ dừng.
  • monitor: tên metric track. Thường là val_loss (mode "min"), nhưng có thể là val_accuracy hay val_f1 (mode "max"). Lưu ý đổi chiều so sánh khi đổi mode.

Một số implementation thêm tham số:

  • mode: "min" hoặc "max" — quyết định "cải thiện" nghĩa là giảm hay tăng. Sai mode = thuật toán dừng ngay epoch đầu hoặc không bao giờ dừng.
  • baseline: chỉ bắt đầu đếm patience sau khi metric vượt một mức cơ sở. Tránh dừng ngay vài epoch đầu khi val còn rất tệ.
  • restore_best_weights: tự động load lại best weights khi dừng (Keras có, PyTorch Lightning có; PyTorch thuần phải tự code).
5

Pseudocode tối thiểu

Phiên bản inline, đủ dùng cho prototype:

best_val_loss = float("inf")
patience_counter = 0
patience = 10
min_delta = 1e-4

for epoch in range(max_epochs):
    train_epoch(model, train_loader, optimizer)
    val_loss = validate(model, val_loader)

    if val_loss < best_val_loss - min_delta:
        best_val_loss = val_loss
        save_checkpoint(model, "best_model.pth")
        patience_counter = 0
    else:
        patience_counter += 1
        if patience_counter >= patience:
            print(f"Early stop at epoch {epoch}, best val loss = {best_val_loss:.4f}")
            break

Hai nhánh tách rõ ràng:

  • Nhánh cải thiện: cập nhật best, save, reset counter.
  • Nhánh không cải thiện: tăng counter, kiểm tra ngưỡng dừng.

Hàm save_checkpointvalidate sẽ chi tiết hơn ở mục sau.

6

Save best checkpoint

Checkpoint không chỉ chứa weight. Để có thể resume hoặc reproduce, lưu đầy đủ trạng thái training:

torch.save({
    "epoch": epoch,
    "model_state_dict": model.state_dict(),
    "optimizer_state_dict": optimizer.state_dict(),
    "scheduler_state_dict": scheduler.state_dict() if scheduler else None,
    "val_loss": val_loss,
    "best_val_loss": best_val_loss,
}, "best_model.pth")

Giải thích từng trường:

  • epoch: vị trí dừng để resume biết train từ đâu.
  • model_state_dict(): dict ánh xạ tên parameter → tensor weight. Không lưu cả object model vì cấu trúc Python có thể đổi giữa các phiên bản code.
  • optimizer_state_dict(): chứa momentum buffer của SGD, moment estimate của Adam... Resume mà không có cái này thì optimizer khởi tạo lại từ 0, mất quán tính tích lũy.
  • scheduler_state_dict(): trạng thái của LR scheduler (last_epoch, last_lr) — cần khi resume với scheduler đang chạy.
  • val_loss / best_val_loss: lưu để verify khi load và để tiếp tục so sánh.

Nếu chỉ cần inference (không resume), lưu duy nhất model.state_dict() cũng đủ và nhẹ hơn nhiều.

7

Load checkpoint và weights_only=True

Pattern load đơn giản:

checkpoint = torch.load("best_model.pth", weights_only=True)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()

Tham số weights_only xuất hiện từ PyTorch 2.4 và bật mặc định bằng True từ PyTorch 2.6:

  • Bên trong, torch.load dùng pickle — vốn cho phép thực thi mã Python tùy ý khi unpickle. File .pth tải về từ nguồn không rõ có thể chứa payload độc hại.
  • weights_only=True bật unpickler hạn chế: chỉ accept tensor, scalar và một số kiểu cơ bản. Nếu file có object lạ, torch.load raise lỗi thay vì execute.
  • Production và môi trường multi-tenant nên luôn để weights_only=True. Với checkpoint tự mình lưu (chỉ chứa state_dict, scalar, dict), tham số này không gây vấn đề gì.

Nếu cần lưu object phức tạp (lớp tự định nghĩa) trong checkpoint, có thể tách ra config riêng (JSON / YAML) và chỉ lưu state_dict trong file .pth. Đây cũng là khuyến nghị chính thức của PyTorch sau khi weights_only=True trở thành default.

# Load đầy đủ trạng thái để resume
checkpoint = torch.load("best_model.pth", weights_only=True)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
if checkpoint["scheduler_state_dict"] is not None:
    scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
start_epoch = checkpoint["epoch"] + 1
best_val_loss = checkpoint["best_val_loss"]
8

Custom EarlyStopping class

Đóng gói logic vào một class giúp tái sử dụng và giữ training loop sạch:

import torch

class EarlyStopping:
    def __init__(self, patience=10, min_delta=0.0, path="best_model.pth"):
        self.patience = patience
        self.min_delta = min_delta
        self.path = path
        self.counter = 0
        self.best_loss = float("inf")
        self.early_stop = False

    def __call__(self, val_loss, model):
        if val_loss < self.best_loss - self.min_delta:
            self.best_loss = val_loss
            self.counter = 0
            torch.save(model.state_dict(), self.path)
        else:
            self.counter += 1
            if self.counter >= self.patience:
                self.early_stop = True

Cách dùng trong vòng lặp:

stopper = EarlyStopping(patience=10, min_delta=1e-4, path="best.pth")

for epoch in range(max_epochs):
    train_epoch(model, train_loader, optimizer)
    val_loss = validate(model, val_loader)
    stopper(val_loss, model)
    if stopper.early_stop:
        print(f"Early stop at epoch {epoch}, best val loss = {stopper.best_loss:.4f}")
        break

# Load best weights trước khi đánh giá test
model.load_state_dict(torch.load("best.pth", weights_only=True))

Ưu điểm:

  • State (counter, best_loss) đóng kín trong instance — không rò ra training loop.
  • Save best ngay trong nhánh cải thiện, không cần code thêm bên ngoài.
  • Có thể mở rộng: thêm mode, lưu cả optimizer, log số epoch đã chờ...

Có thể tách save ra ngoài class nếu muốn class chỉ chịu trách nhiệm tracking — dependency injection rõ ràng hơn nhưng dài dòng hơn.

9

PyTorch Lightning callback

PyTorch Lightning có sẵn callback EarlyStoppingModelCheckpoint — không cần tự code khi đã dùng framework này:

from lightning.pytorch.callbacks import EarlyStopping, ModelCheckpoint
import lightning.pytorch as L

early_stop = EarlyStopping(
    monitor="val_loss",
    patience=10,
    mode="min",
    min_delta=1e-4,
)

checkpoint_cb = ModelCheckpoint(
    monitor="val_loss",
    mode="min",
    save_top_k=1,           # chỉ giữ best
    save_last=True,         # ngoài best còn giữ last.ckpt
    filename="best-{epoch:02d}-{val_loss:.4f}",
)

trainer = L.Trainer(
    max_epochs=100,
    callbacks=[early_stop, checkpoint_cb],
)
trainer.fit(model, train_loader, val_loader)

Một số điểm cần biết:

  • monitor="val_loss" phải khớp với tên metric mà LightningModule.validation_step log qua self.log("val_loss", ...).
  • save_top_k giữ K checkpoint tốt nhất. Đặt 1 để chỉ giữ best, đặt -1 để giữ tất cả epoch.
  • save_last=True luôn giữ thêm last.ckpt ngoài best — tiện cho resume nếu job bị kill giữa chừng.
  • Lightning tự load best weights khi gọi trainer.test() nếu có ModelCheckpoint với monitor.

Với codebase PyTorch thuần, tự viết EarlyStopping như mục 8 đơn giản và đủ dùng. Lightning hợp khi đã chấp nhận framework để giảm boilerplate cho phần khác (train loop, distributed, mixed precision...).

10

Tradeoff khi chọn patience

  • Patience thấp (3–5): dừng nhanh, tiết kiệm compute. Rủi ro: val loss có thể giảm trở lại sau vài epoch đi ngang — dừng quá sớm bỏ lỡ vùng tốt hơn. Phù hợp khi dataset nhỏ, signal rõ, hoặc đang quét hyperparameter quy mô lớn.
  • Patience trung bình (10): lựa chọn mặc định trong nhiều paper và Keras/Lightning default. Cân bằng giữa risk và compute.
  • Patience cao (20+): an toàn không dừng nhầm khi val loss noisy. Tốn compute hơn. Hợp khi training một final model quan trọng, sẵn sàng đổi compute lấy chắc chắn.

Quy tắc thực hành: nếu val loss curve nhìn smooth (variance giữa các epoch nhỏ), patience 5–10 đủ. Nếu noisy (variance lớn, dao động lên xuống), tăng patience hoặc tăng min_delta, hoặc kết hợp moving average của val loss thay vì raw value.

Một số implementation cao cấp dùng smoothed val loss (exponential moving average) thay cho raw — giảm rủi ro dừng nhầm do noise. Đây là tinh chỉnh đáng cân nhắc khi val pass có cỡ batch nhỏ.

11

Tần suất save: best, last, rolling

Có vài chính sách lưu khác nhau, mỗi cái có lý do riêng:

  • Save mỗi epoch: file epoch_001.pth, epoch_002.pth... Lưu hết để chọn lại sau. Tốn ổ đĩa nhanh chóng — model 100MB × 100 epoch = 10GB. Hợp khi chạy ngắn hoặc cần phân tích chi tiết.
  • Save best + last: hai file best.pth (best val) và last.pth (epoch mới nhất). Best dùng cho test/inference, last dùng cho resume. Đây là pattern phổ biến và đủ dùng cho hầu hết case.
  • Rolling top-K: giữ K best (vd K=3) — nếu best đầu thực ra do noise, vẫn còn fallback. Lightning hỗ trợ trực tiếp qua save_top_k=3.
  • Save mỗi N epoch: ví dụ N=5, tiết kiệm IO so với mỗi epoch, vẫn có nhiều mốc để inspect. Hợp khi training rất dài (hàng nghìn epoch hoặc steps).

Nguyên tắc: best dùng cho user cuối, last dùng cho resume sau crash, top-K cho safety, mỗi-N-epoch cho audit. Thường stack hai trong số đó là đủ.

12

Naming convention cho checkpoint

  • best.pth — best val loss đến hiện tại. File này được overwrite mỗi khi tìm thấy val tốt hơn.
  • last.pth — epoch gần nhất. Overwrite mỗi epoch. Dùng cho resume khi crash.
  • epoch_{N}.pth hoặc epoch_{N:03d}.pth — checkpoint cố định ở epoch N. Tăng dần, không overwrite.
  • best-{epoch:02d}-{val_loss:.4f}.pth — encode epoch và val loss vào tên file, dễ chọn bằng mắt.
  • step_{N}.pth — dùng khi save theo step thay vì epoch (LLM training thường save sau N step do epoch quá lâu).

Đặt tất cả checkpoint trong một folder riêng theo run: checkpoints/run-2026-05-24-mlp-mnist/best.pth. Tách theo run giúp chạy nhiều thí nghiệm song song không đè lên nhau, và tiện đính kèm metadata (config, log) vào cùng folder.

13

Resume training từ checkpoint

Resume yêu cầu khôi phục đầy đủ trạng thái, không chỉ weight:

def load_for_resume(path, model, optimizer, scheduler=None):
    ckpt = torch.load(path, weights_only=True)
    model.load_state_dict(ckpt["model_state_dict"])
    optimizer.load_state_dict(ckpt["optimizer_state_dict"])
    if scheduler is not None and ckpt.get("scheduler_state_dict") is not None:
        scheduler.load_state_dict(ckpt["scheduler_state_dict"])
    start_epoch = ckpt["epoch"] + 1
    best_val_loss = ckpt.get("best_val_loss", float("inf"))
    return start_epoch, best_val_loss

start_epoch, best_val_loss = load_for_resume("last.pth", model, optimizer, scheduler)

for epoch in range(start_epoch, max_epochs):
    train_epoch(...)
    ...

Checklist khi resume:

  • Model architecture phải khớp — cùng nn.Module structure như khi save. Nếu đổi shape của layer, load_state_dict raise lỗi.
  • Optimizer phải cùng class — load Adam state vào SGD sẽ lỗi.
  • Scheduler cùng class và cùng max_epochs / T_max để LR curve đúng.
  • Random seed và DataLoader: nếu cần reproduce hoàn toàn, lưu thêm torch.random.get_rng_state(), numpy.random.get_state(), state của random module. Thường bỏ qua trong production vì phức tạp và không bắt buộc.
  • start_epoch = epoch_lưu + 1 — tránh chạy lại epoch đã train.
14

Phối hợp với LR scheduler

Early stopping thường được dùng kèm LR scheduler (chi tiết ở Bài 25). Hai pattern phổ biến:

  • Stop khi val plateau: chỉ dùng early stopping với patience nhỏ → dừng ngay khi val không cải thiện.
  • Giảm LR trước, stop sau: dùng ReduceLROnPlateau để giảm learning rate khi val plateau (patience nhỏ hơn, vd 3), giúp model "thoát" plateau cục bộ; nếu giảm vài lần mà val vẫn không cải thiện, lúc đó early stopping (patience lớn hơn, vd 10) mới dừng. Đây là pattern an toàn hơn — không từ bỏ training quá sớm.
from torch.optim.lr_scheduler import ReduceLROnPlateau

scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=3)
stopper = EarlyStopping(patience=10, min_delta=1e-4)

for epoch in range(max_epochs):
    train_epoch(model, train_loader, optimizer)
    val_loss = validate(model, val_loader)
    scheduler.step(val_loss)     # giảm LR khi plateau
    stopper(val_loss, model)     # dừng khi đã giảm LR vài lần mà vẫn không cải thiện
    if stopper.early_stop:
        break

Quy tắc: patience của scheduler phải nhỏ hơn patience của early stopping. Nếu hai bằng nhau, scheduler chưa kịp giảm LR thì training đã dừng.

15

Pitfall thường gặp

  • Quên save checkpoint trong nhánh cải thiện: chỉ tăng patience counter, không save weight. Khi dừng, model trong RAM là weight của epoch cuối (sau khi đã đi quá best). Symptom: test accuracy thấp hơn val tốt nhất quan sát được.
  • Save quá thường xuyên: save mỗi step thay vì mỗi epoch trên model 500MB → đĩa đầy sau vài giờ. Save mỗi epoch hoặc mỗi N step.
  • Patience quá nhỏ: val loss noisy, dao động ±1% giữa epoch. Patience 2–3 dừng ngay từ epoch 4–5 trước khi model thực sự bắt đầu học sâu. Tăng patience hoặc min_delta.
  • Quên weights_only=True: production load checkpoint từ object storage (S3, GCS) hoặc model hub — kẻ tấn công có thể inject payload. Bật weights_only=True hoặc nâng lên PyTorch ≥ 2.6 (default).
  • Sai mode: monitor val_accuracy nhưng vẫn so sánh kiểu min loss → counter tăng mỗi epoch acc tăng → dừng nhầm rất sớm. Đổi sang mode "max" hoặc đảo dấu metric.
  • Reset counter khi save best mà quên cập nhật best_loss: counter reset nhưng best_loss vẫn cũ → epoch sau so sánh sai → save lại không cần thiết. Hai dòng phải đi cùng nhau.
  • Quên load best trước khi test: dùng model đang trong RAM (weight của epoch dừng, không phải epoch tốt nhất) để báo cáo test metric. Phải model.load_state_dict(torch.load("best.pth")) sau khi vòng for kết thúc.
  • Sai thứ tự với mixed precision / DDP: với DDP, chỉ rank 0 nên save checkpoint, các rank khác chờ. Save từ mọi rank vào cùng đường dẫn = race condition, file hỏng.
16

Khi không cần early stopping

Early stopping không phải lúc nào cũng phù hợp. Một số trường hợp bỏ qua:

  • Train rất ngắn: vài epoch trên dataset nhỏ, không có thời gian val loss đi lên rõ ràng. Train cố định, lấy epoch cuối.
  • LR schedule đã định sẵn: ví dụ cosine annealing chạy trọn vẹn 100 epoch, mọi epoch trước khi schedule kết thúc đều ở LR cao hơn — dừng sớm bỏ lỡ giai đoạn fine-tune ở LR thấp cuối schedule. Trường hợp này train tới hết, lấy best checkpoint từ log thay vì dừng giữa chừng.
  • LLM pretraining: thường train theo số token cố định (vd 1T token), không có val set theo nghĩa truyền thống. Loss curve giảm đều, không có hiện tượng overfit rõ trong khoảng compute hợp lý. Dừng theo budget compute thay vì val plateau.
  • Train với regularization mạnh: dropout cao, weight decay lớn, data augmentation aggressive — overfit khó xảy ra, val loss có thể giảm rất chậm và đều đến hết schedule. Early stopping với patience nhỏ dễ dừng nhầm.
  • Khi val set không đại diện: val quá nhỏ, val có distribution khác train — val loss không phải tín hiệu tin cậy. Cần cải thiện val set trước, hoặc dùng cross-validation thay vì early stopping.

Tóm tắt: early stopping hợp với fine-tune và train từ đầu trên dataset cỡ vừa khi có val set tin cậy. Không bắt buộc cho mọi pipeline.

17

Code Python tổng hợp

Train MLP trên MNIST với EarlyStopping, save best + last, sau đó load lại verify:

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

torch.manual_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"

# 1. Data
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,)),
])
train_ds = datasets.MNIST("./data", train=True, download=True, transform=transform)
val_size = 6000
train_size = len(train_ds) - val_size
train_ds, val_ds = torch.utils.data.random_split(train_ds, [train_size, val_size])
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=2)
val_loader = DataLoader(val_ds, batch_size=256, num_workers=2)

# 2. Model
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(28 * 28, 256), nn.ReLU(),
            nn.Linear(256, 128), nn.ReLU(),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.net(x)

model = MLP().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

# 3. EarlyStopping
class EarlyStopping:
    def __init__(self, patience=5, min_delta=1e-4, path="best.pth"):
        self.patience = patience
        self.min_delta = min_delta
        self.path = path
        self.counter = 0
        self.best_loss = float("inf")
        self.early_stop = False
        self.best_epoch = -1

    def __call__(self, val_loss, model, epoch):
        if val_loss < self.best_loss - self.min_delta:
            self.best_loss = val_loss
            self.best_epoch = epoch
            self.counter = 0
            torch.save(model.state_dict(), self.path)
        else:
            self.counter += 1
            if self.counter >= self.patience:
                self.early_stop = True

# 4. Helpers
def train_epoch(model, loader, opt):
    model.train()
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        logits = model(x)
        loss = F.cross_entropy(logits, y)
        opt.zero_grad()
        loss.backward()
        opt.step()

@torch.no_grad()
def validate(model, loader):
    model.eval()
    total, n = 0.0, 0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        logits = model(x)
        loss = F.cross_entropy(logits, y, reduction="sum")
        total += loss.item()
        n += y.size(0)
    return total / n

# 5. Training loop
stopper = EarlyStopping(patience=5, min_delta=1e-4, path="best.pth")
max_epochs = 50

for epoch in range(max_epochs):
    train_epoch(model, train_loader, optimizer)
    val_loss = validate(model, val_loader)
    print(f"epoch {epoch:02d}  val_loss={val_loss:.4f}  patience={stopper.counter}/{stopper.patience}")

    stopper(val_loss, model, epoch)
    # Save last mỗi epoch cho resume
    torch.save({
        "epoch": epoch,
        "model_state_dict": model.state_dict(),
        "optimizer_state_dict": optimizer.state_dict(),
        "best_val_loss": stopper.best_loss,
    }, "last.pth")

    if stopper.early_stop:
        print(f"Early stop at epoch {epoch}. Best at epoch {stopper.best_epoch}, val_loss={stopper.best_loss:.4f}")
        break

# 6. Load best và verify
model.load_state_dict(torch.load("best.pth", weights_only=True))
val_loss = validate(model, val_loader)
print(f"Reloaded best.pth — val_loss={val_loss:.4f}")

Output điển hình (rút gọn):

epoch 00  val_loss=0.1842  patience=0/5
epoch 01  val_loss=0.1156  patience=0/5
epoch 02  val_loss=0.0961  patience=0/5
...
epoch 09  val_loss=0.0763  patience=0/5     # best
epoch 10  val_loss=0.0782  patience=1/5
epoch 11  val_loss=0.0795  patience=2/5
epoch 12  val_loss=0.0791  patience=3/5
epoch 13  val_loss=0.0801  patience=4/5
epoch 14  val_loss=0.0815  patience=5/5
Early stop at epoch 14. Best at epoch 9, val_loss=0.0763
Reloaded best.pth — val_loss=0.0763

Quan sát:

  • Val loss giảm liên tục đến epoch 9, sau đó đi ngang rồi tăng nhẹ — dấu hiệu overfit bắt đầu.
  • Sau 5 epoch (10–14) không cải thiện, early stopping kích hoạt.
  • Verify lại: load best.pth cho ra cùng val loss như khi save (0.0763), khẳng định checkpoint đúng.
18

Bài tập

  1. Implement EarlyStopping class với đầy đủ tham số patience, min_delta, mode ("min" / "max"), path. Test với chuỗi val loss giả lập [1.0, 0.8, 0.6, 0.5, 0.51, 0.52, 0.49, 0.53, 0.54, 0.55] và patience=3 — xác định epoch dừng và best epoch.
  2. Train MLP 3 layer trên Iris (sklearn) với patience=5, min_delta=1e-4. Lưu best.pthlast.pth. Sau training, load lại cả hai và verify val loss bằng giá trị lưu trong checkpoint.
  3. Demo resume: train MLP MNIST 5 epoch, lưu last.pth. Restart Python, load checkpoint, train tiếp 5 epoch nữa. Verify epoch counter tiếp tục từ 5, không khởi tạo lại optimizer.
  4. So sánh patience 3 vs 10 vs 20 trên cùng setup MLP MNIST. Ghi lại epoch dừng và val loss tốt nhất. Plot val loss curve để thấy patience nào dừng "đúng chỗ", patience nào dừng sớm/muộn.
  5. Test weights_only=True: lưu checkpoint chứa state_dict + một object lạ (vd một lambda). Load với weights_only=True — quan sát lỗi raise. Sau đó load với weights_only=False để hiểu rủi ro pickle.
  6. Kết hợp EarlyStopping với ReduceLROnPlateau: scheduler patience=3 (factor=0.5), stopper patience=10. Train MLP. Verify trong log rằng LR giảm trước khi stopper kích hoạt.
  7. Implement variant: theo dõi val accuracy thay vì val loss (mode="max"). Train MLP MNIST, dừng khi accuracy không tăng trong 5 epoch. So sánh kết quả với phiên bản theo val_loss — best epoch có khác không?
Đáp án ngắn
  1. Best epoch index 6 (val=0.49). Counter tăng từ epoch 7 (0.53 > 0.49 + min_delta), reaches 3 ở epoch 9 → dừng tại index 9.
  2. best.pth chứa weight ở val tốt nhất; last.pth chứa epoch cuối. Val loss của best ≤ last (thường nhỏ hơn rõ rệt nếu overfit xảy ra).
  3. Resume thành công khi optimizer state khớp — Adam moment buffer tiếp tục thay vì reset. Bug hay gặp: quên load optimizer_state_dict → train tiếp như khởi tạo lại Adam.
  4. Patience 3: dừng quá sớm (vd epoch 7), val loss còn có thể tốt hơn. Patience 20: dừng muộn (epoch 25–30), thêm 10 epoch không giảm best. Patience 10 thường là tối ưu cho MNIST.
  5. Lỗi UnpicklingError hoặc RuntimeError kèm thông báo về kiểu không cho phép. Đây là cơ chế bảo vệ.
  6. Log sẽ có dạng: epoch 7 LR=1e-3 → 5e-4 (giảm lần 1), epoch 11 LR=5e-4 → 2.5e-4 (giảm lần 2), epoch 15 dừng. Stopper patience=10 phải đợi sau khi scheduler đã thử giảm vài lần.
  7. Best epoch theo val_accuracy có thể khác best theo val_loss vài epoch — accuracy là step function (theo argmax), loss là smooth. Nếu hai metric mâu thuẫn, ưu tiên metric quan tâm cuối cùng (test accuracy thường là target).

Bài kế tiếp: Bài 25 — Learning Rate Scheduling — vì sao giữ một LR cố định không tối ưu, cosine annealing, step decay, warmup, và pattern phối hợp với early stopping vừa học.