Vòng lặp huấn luyện chuẩn: train, eval và checkpoint
Xem thửMinh huấn luyện xong mô hình dự đoán thời gian giao (lớp DeliveryMLP của bài trước, thêm Dropout 10%) và cài vào ứng dụng thử nghiệm nội bộ. Bình mở một đơn của khách, thấy dự kiến giao 80,3 phút. Anh tải lại trang: 78,5 phút. Tải lần nữa: 74,2 phút. Trọng số không đổi, dữ liệu đơn không đổi, nhưng mỗi lần một con số.
Chưa hết. Tối hôm trước, máy của Minh tự ngủ ở epoch 7 trên 8, và anh phải chạy lại từ đầu. Anh định lần sau chỉ lưu trọng số mỗi epoch rồi chạy tiếp từ đó.
Trước khi đọc tiếp, hãy dự đoán: vì sao cùng một đơn ra ba con số khác nhau? Và nếu chỉ lưu trọng số rồi chạy tiếp, mô hình sau 4 epoch có giống hệt mô hình chạy liền 4 epoch không?
Sau bài này, bạn có thể
- Viết vòng huấn luyện chuẩn gồm pha train và pha đánh giá, giải thích việc của từng dòng.
- Giải thích
model.train()vàmodel.eval()đổi hành vi của Dropout ra sao, và vì sao đánh giá cần thêmtorch.no_grad(). - Lưu checkpoint tốt nhất theo val MAE, và chạy tiếp từ checkpoint cho kết quả trùng khớp với chạy liền một mạch.
Cần biết trước: bài nn.Module, Dataset và DataLoader (bài trước) và bài Regularization: dropout, weight decay và early stopping (Chương 11).
Mức độ: Trung bình · Thời gian: khoảng 20 phút · Từ khóa: training loop, model.train, model.eval, no_grad, checkpoint, resume
Một epoch có hai pha
Vòng huấn luyện chuẩn của PyTorch không có phép màu nào: nó là các mảnh bạn đã biết, xếp theo thứ tự cố định. Mỗi epoch (một lượt đi hết tập train) gồm:
- Pha học: bật chế độ train, rồi với mỗi batch làm năm việc: xóa gradient cũ, forward, tính loss, backward, cập nhật tham số.
- Pha đo: bật chế độ eval, tắt ghi đồ thị, tính metric trên tập validation, rồi quyết định có lưu checkpoint (bản lưu trạng thái huấn luyện) hay không.
Sơ đồ dưới là toàn bộ vòng lặp. Hãy để ý rằng việc lưu nằm sau một quyết định, không phải lần nào cũng lưu.
Đang tải sơ đồ…
Cách đọc hình: vòng bắt đầu ở ô tím model.train(), đi qua ô xanh dương là năm việc lặp cho mỗi batch, rồi sang ô tím thứ hai để chuyển chế độ đo. Val MAE (ô cyan) được đưa vào ô cam đầu tiên: chỉ khi tốt hơn mức tốt nhất trước đó mới ghi best.pt. Ô cam thứ hai quay vòng về đầu nếu còn epoch. Khi kết thúc, mô hình dùng để dự đoán là bản trong best.pt, không nhất thiết là bản của epoch cuối.
model.train() và model.eval() đổi hành vi, không đổi trọng số
Đây là mô hình tư duy chính của bài. model.train() và model.eval() chỉ lật một cờ model.training trên mô hình và mọi lớp con. Trọng số không đổi. Nhưng một số lớp đọc cờ đó và hành xử khác hẳn:
- Dropout (bỏ ngẫu nhiên): ở chế độ train, mỗi lần forward tắt ngẫu nhiên tỉ lệ
pnơ-ron (ở đâyp = 0,1) và nhân phần còn lại với1 / (1 - p)để giữ độ lớn trung bình. Ở chế độ eval, Dropout không làm gì cả. - BatchNorm (chuẩn hóa theo batch, Chương 11): ở chế độ train dùng trung bình của batch hiện tại và cập nhật số liệu chạy; ở chế độ eval dùng số liệu chạy đã lưu. Mô hình của bài không có BatchNorm, nhưng quy tắc gọi
eval()là như nhau.
torch.no_grad() là chuyện khác: nó tắt việc ghi đồ thị autograd, nên đo nhanh hơn và không giữ các giá trị trung gian trong bộ nhớ. Nó không tắt Dropout. Hai thứ trả lời hai câu hỏi khác nhau:
| Công cụ | Thay đổi điều gì | Không thay đổi điều gì | Dùng khi |
|---|---|---|---|
model.train() | Bật Dropout, BatchNorm dùng thống kê của batch | Không bật hay tắt autograd | Đầu pha học |
model.eval() | Tắt Dropout, BatchNorm dùng số liệu đã lưu | Không tắt autograd | Đo validation, dự đoán thật |
torch.no_grad() | Không ghi đồ thị, requires_grad của kết quả là False | Không đổi chế độ của Dropout | Mọi đoạn code không cần gradient |
Làm cùng mình
Bước 1: hai hàm cho hai pha. Cả chương dùng chung hai hàm này trong _model.py:
def train_one_epoch(model, loader, optimizer, loss_fn) -> float:
model.train() # bật dropout
total, count = 0.0, 0
for xb, yb in loader:
optimizer.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
optimizer.step()
total += loss.item() * len(yb) # .item() lấy số Python, không giữ đồ thị
count += len(yb)
return total / count
@torch.no_grad()
def evaluate(model, X: torch.Tensor, y: torch.Tensor) -> float:
model.eval() # tắt dropout
return (model(X) - y).abs().mean().item()
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
model.train() | Bật cờ train cho mọi lớp | Dropout chỉ hoạt động khi học |
optimizer.zero_grad() | Xóa .grad của mọi tham số | Gradient cộng dồn (bài 1) |
loss.backward(), optimizer.step() | Tính gradient rồi cập nhật bằng Adam | Hai việc của bài 1, giờ optimizer làm hộ cho 673 tham số |
loss.item() * len(yb) | Lấy số Python, nhân số đơn của batch | .item() cắt khỏi đồ thị; nhân len(yb) vì batch cuối nhỏ hơn |
@torch.no_grad() | Tắt ghi đồ thị cho cả hàm | Đo không cần gradient |
model.eval() trong evaluate | Tắt Dropout trước khi đo | Đo đúng mô hình sẽ được dùng |
Bước 2: vòng 8 epoch với checkpoint tốt nhất.
config = {"n_features": len(FEATURES), "hidden": 32, "dropout": 0.1, "out_bias": round(float(np.median(y_train)), 1)}
model = DeliveryMLP(**config)
optimizer = torch.optim.Adam(model.parameters(), lr=3e-3)
loss_fn = torch.nn.L1Loss()
ckpt = Path(__file__).parent / "artifacts" / "best.pt"
ckpt.parent.mkdir(exist_ok=True)
best = float("inf")
for epoch in range(1, 9):
train_loss = train_one_epoch(model, loader, optimizer, loss_fn)
val_mae = evaluate(model, Xv, yv)
saved = ""
if val_mae < best:
best = val_mae
torch.save({"epoch": epoch, "config": config, "model": model.state_dict(),
"optimizer": optimizer.state_dict(), "val_mae": val_mae}, ckpt)
saved = "saved"
print(f"epoch {epoch} train L1 {train_loss:6.2f} val MAE {val_mae:6.2f} {saved}")
state = torch.load(ckpt, weights_only=True)
print(f"best checkpoint: epoch {state['epoch']}, val MAE {state['val_mae']:.2f}, keys {sorted(state)}")
Kết quả [Đã chạy] Python 3.12, PyTorch 2.15 (bản CPU), NumPy 2.5, pandas 3.1, CPU Intel Core i5-6200U, ngày 04/11/2026:
baseline val MAE (median of train): 23.70 min
epoch 1 train L1 22.17 val MAE 21.87 saved
epoch 2 train L1 18.98 val MAE 17.24 saved
epoch 3 train L1 14.35 val MAE 13.22 saved
epoch 4 train L1 11.21 val MAE 11.36 saved
epoch 5 train L1 10.34 val MAE 11.08 saved
epoch 6 train L1 10.28 val MAE 11.01 saved
epoch 7 train L1 10.26 val MAE 11.03
epoch 8 train L1 10.22 val MAE 10.99 saved
best checkpoint: epoch 8, val MAE 10.99, keys ['config', 'epoch', 'model', 'optimizer', 'val_mae']
Đọc kết quả: baseline đoán trung vị cho mọi đơn có MAE 23,70 phút; sau 8 epoch mô hình còn 10,99 phút. Epoch 7 có val MAE 11,03, kém epoch 6 (11,01), nên không ghi; epoch 8 tốt hơn nên ghi đè. Checkpoint chứa năm khóa: config, epoch, model, optimizer, val_mae, đủ để dựng lại mô hình và chạy tiếp. Val MAE cao hơn train L1 không hẳn vì overfitting: tập validation là sáu tuần cuối, có 54,6% đơn trời mưa so với 17,5% ở tập train, đúng sự dịch chuyển phân phối được cài sẵn trong bộ dữ liệu (Chương 1).
Animation dưới đây đi lại tám epoch này theo từng pha, rồi cho bạn bật biến thể "quên model.eval()" để so trên cùng các con số.
Đang tải Animation…
Bước 3: quên model.eval() thì sao? Ví dụ sau chạy đúng vòng huấn luyện ở bước 2, nhưng mỗi epoch đo hai lần trên cùng trọng số: một lần đúng, một lần để mô hình ở chế độ train. torch.random.fork_rng() giữ cho lần đo thứ hai không làm lệch các số ngẫu nhiên của lần học sau, nên cột bên trái trùng khớp bước 2.
for epoch in range(1, 9):
train_one_epoch(model, loader, opt, torch.nn.L1Loss())
right = evaluate(model, Xv, yv) # model.eval() bên trong
with torch.random.fork_rng(), torch.no_grad(): # không làm lệch số ngẫu nhiên của lần train sau
model.train() # mô phỏng việc quên model.eval()
wrong = (model(Xv) - yv).abs().mean().item()
best["eval"] = min(best["eval"], (right, epoch))
best["train"] = min(best["train"], (wrong, epoch))
print(f"epoch {epoch} val MAE eval mode {right:6.2f} forgot eval() {wrong:6.2f}")
print("best epoch by eval mode:", best["eval"][1], "| by forgotten eval():", best["train"][1])
one = Xv[:1]
with torch.no_grad():
model.train()
print("train mode, same order 3 times:", [round(model(one).item(), 1) for _ in range(3)])
model.eval()
print("eval mode, same order 3 times: ", [round(model(one).item(), 1) for _ in range(3)])
print("requires_grad without no_grad:", model(one).requires_grad)
Kết quả [Đã chạy] Python 3.12, PyTorch 2.15 (bản CPU), NumPy 2.5, pandas 3.1, CPU Intel Core i5-6200U, ngày 04/11/2026:
epoch 1 val MAE eval mode 21.87 forgot eval() 21.87
epoch 2 val MAE eval mode 17.24 forgot eval() 17.27
epoch 3 val MAE eval mode 13.22 forgot eval() 13.32
epoch 4 val MAE eval mode 11.36 forgot eval() 11.50
epoch 5 val MAE eval mode 11.08 forgot eval() 11.30
epoch 6 val MAE eval mode 11.01 forgot eval() 11.22
epoch 7 val MAE eval mode 11.03 forgot eval() 11.23
epoch 8 val MAE eval mode 10.99 forgot eval() 11.19
best epoch by eval mode: 8 | by forgotten eval(): 8
train mode, same order 3 times: [80.3, 78.5, 74.2]
eval mode, same order 3 times: [77.5, 77.5, 77.5]
requires_grad without no_grad: True
Câu trả lời cho câu hỏi đầu tiên ở mở bài: ứng dụng của Minh dự đoán khi mô hình vẫn ở chế độ train. Mỗi lần gọi, Dropout tắt một nhóm nơ-ron khác, nên cùng một đơn ra 80,3, 78,5, 74,2 phút. Ở chế độ eval, ba lần đều là 77,5. Không có lỗi nào được báo. Khi đo validation, sai lầm này làm MAE cao hơn khoảng 0,2 phút; ở ví dụ này epoch tốt nhất vẫn là 8, nhưng với Dropout lớn hơn hoặc các epoch sát nhau, nó có thể chọn sai checkpoint. Dòng cuối nhắc thêm: không có no_grad, kết quả vẫn mang đồ thị autograd dù đang ở eval mode.
Bước 4: chạy tiếp từ checkpoint. Để trả lời câu hỏi thứ hai, ví dụ sau huấn luyện liền 4 epoch, rồi so với hai cách chạy tiếp từ checkpoint lưu sau epoch 2: có khôi phục trạng thái optimizer và không khôi phục.
def loader_for(epoch): # thứ tự xáo trộn chỉ phụ thuộc số epoch
return DataLoader(ds, batch_size=256, shuffle=True, generator=torch.Generator().manual_seed(epoch))
def run(epochs, model, opt, start=0):
for epoch in range(start, epochs):
loss = train_one_epoch(model, loader_for(epoch), opt, torch.nn.L1Loss())
return loss
setup(0)
model = DeliveryMLP(len(FEATURES), out_bias=60.0)
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
straight = run(4, model, opt)
ref = model.net[0].weight.detach().clone()
setup(0)
model = DeliveryMLP(len(FEATURES), out_bias=60.0)
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
run(2, model, opt)
torch.save({"epoch": 2, "model": model.state_dict(), "optimizer": opt.state_dict()}, ckpt)
for keep_optimizer in (True, False):
state = torch.load(ckpt, weights_only=True)
model = DeliveryMLP(len(FEATURES))
model.load_state_dict(state["model"])
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
if keep_optimizer:
opt.load_state_dict(state["optimizer"]) # khôi phục momentum của Adam
resumed = run(4, model, opt, start=state["epoch"])
diff = (model.net[0].weight - ref).abs().max().item()
print(f"resume with optimizer state={keep_optimizer!s:5s}: last-epoch loss {resumed:.4f} vs {straight:.4f}, max weight diff {diff:.2e}")
print("optimizer state keys:", sorted(state["optimizer"]["state"][0]))
Kết quả [Đã chạy] Python 3.12, PyTorch 2.15 (bản CPU), NumPy 2.5, pandas 3.1, CPU Intel Core i5-6200U, ngày 04/11/2026:
resume with optimizer state=True : last-epoch loss 19.7644 vs 19.7644, max weight diff 0.00e+00
resume with optimizer state=False: last-epoch loss 20.4164 vs 19.7644, max weight diff 9.07e-02
optimizer state keys: ['exp_avg', 'exp_avg_sq', 'step']
Có trạng thái optimizer, chênh lệch trọng số lớn nhất là 0: chạy tiếp giống hệt chạy liền. Chỉ lưu trọng số, loss epoch cuối là 20,4164 thay vì 19,7644 và trọng số lệch tới 0,0907. Lý do nằm ở dòng cuối: Adam giữ cho mỗi tham số một trung bình động của gradient (exp_avg), một trung bình động của bình phương gradient (exp_avg_sq) và bộ đếm bước (step), đúng phần "quán tính" của Chương 11. Tạo Adam mới là xóa quán tính đó, như đẩy xe đang chạy về vạch xuất phát. Ví dụ còn làm thêm một việc: thứ tự xáo trộn của mỗi epoch chỉ phụ thuộc vào số epoch (manual_seed(epoch)), nên chạy tiếp từ epoch 3 nhận đúng thứ tự xáo của epoch 3.
Áp dụng vào Giao Hàng Anh Tí
Với mô hình dự đoán thời gian giao, Minh sửa hai chỗ. Trong ứng dụng, sau khi nạp mô hình gọi model.eval() một lần và dự đoán trong torch.no_grad() (bài 5 dùng torch.inference_mode(), chặt hơn một chút). Trong script huấn luyện, mỗi epoch lưu hai file: last.pt để chạy tiếp khi máy dừng, best.pt để dùng thật. Mỗi file chứa trọng số, trạng thái optimizer, số epoch, cấu hình mô hình và metric.
Checklist đọc một vòng huấn luyện của người khác: có model.train() ở đầu pha học không; có zero_grad trước backward không; pha đo có cả model.eval() và no_grad không; loss cộng dồn bằng .item() hay bằng tensor; checkpoint có trạng thái optimizer không.
Hiểu lầm thường gặp
Hiểu lầm 1: "model.eval() tắt gradient." Sai ở chỗ eval() chỉ đổi hành vi của Dropout và BatchNorm. Kết quả vẫn có requires_grad=True như dòng cuối của bước 3. Tắt gradient là việc của torch.no_grad().
Hiểu lầm 2: "Đã có torch.no_grad() thì không cần model.eval()." Sai ở chỗ bước 3 đo trong no_grad mà Dropout vẫn chạy: cùng một đơn ra ba số khác nhau.
Hiểu lầm 3: "Checkpoint chỉ cần trọng số." Đủ để dự đoán, không đủ để chạy tiếp. Thiếu trạng thái Adam, trọng số sau khi chạy tiếp lệch 0,0907 so với chạy liền.
Hiểu lầm 4: "Cộng total += loss cho gọn." Sai ở chỗ loss là tensor mang đồ thị; cộng dồn tensor giữ lại đồ thị của mọi batch trong epoch và bộ nhớ tăng dần. Dùng loss.item().
| Triệu chứng | Nguyên nhân | Cách kiểm chứng | Cách sửa |
|---|---|---|---|
| Cùng một đơn, mỗi lần gọi một dự đoán | Mô hình có Dropout đang ở chế độ train | In model.training ngay trước khi dự đoán | model.eval() sau khi nạp mô hình |
| Val MAE đo lại bằng script khác thấp hơn số trong log | Pha đo của vòng huấn luyện quên eval() | Đo một checkpoint bằng hai cách | Gọi model.eval() trong hàm evaluate |
| Chạy tiếp xong loss nhảy lên rồi mới giảm lại | Không khôi phục trạng thái optimizer | So trọng số giữa chạy liền và chạy tiếp | Lưu và nạp optimizer.state_dict() |
| Bộ nhớ tăng dần trong một epoch | Cộng dồn tensor loss thay vì số Python | Kiểm tra type(total) | total += loss.item() * len(yb) |
Tự kiểm tra (có đáp án)
Câu 1 (giải thích). Vì sao Dropout ở chế độ train nhân các nơ-ron còn lại với 1 / (1 - p)?
Đáp án: khi tắt tỉ lệ p nơ-ron, tổng tín hiệu vào lớp sau giảm trung bình p lần. Nhân phần còn lại với 1 / (1 - p) giữ độ lớn trung bình như khi không tắt, nên ở chế độ eval (không tắt gì) mô hình không cần chỉnh lại.
Câu 2 (dự đoán). Bỏ dòng model.eval() trong evaluate nhưng giữ @torch.no_grad(). Gọi evaluate hai lần liền trên cùng mô hình có Dropout 10%, hai con số có bằng nhau không?
Đáp án: không. Mỗi lần forward Dropout chọn một nhóm nơ-ron khác để tắt, nên MAE dao động quanh giá trị cao hơn bản đo đúng khoảng 0,2 phút.
Câu 3 (vận dụng). Máy dừng sau epoch 5. File last.pt có model, optimizer, epoch = 5. Bạn viết gì để chạy tiếp epoch 6 đến 8?
Đáp án: dựng lại DeliveryMLP(**state["config"]), gọi load_state_dict cho mô hình; tạo Adam mới trên tham số của mô hình đó rồi load_state_dict cho optimizer; chạy for epoch in range(state["epoch"] + 1, 9), và dùng cùng cách tạo thứ tự xáo cho từng epoch.
Tự tay làm
Thêm early stopping (dừng sớm) vào train_loop.py: đếm số epoch liên tiếp không cải thiện, dừng khi số đó đạt patience = 1. Trước khi chạy, dùng kết quả của bước 2 để dự đoán vòng lặp dừng ở epoch nào và best.pt chứa epoch nào.
Kết quả mong đợi để tự kiểm: vòng lặp in đúng 7 dòng epoch rồi dừng (epoch 7 có 11,03, không tốt hơn 11,01), dòng cuối báo best checkpoint: epoch 6, val MAE 11.01. Với patience = 2, vòng lặp chạy đủ 8 epoch vì epoch 8 cải thiện. Viết một câu về đánh đổi khi chọn patience nhỏ.
Điều cần nhớ
- Một epoch có hai pha:
model.train()và năm việc cho mỗi batch, rồimodel.eval()trongtorch.no_grad()để đo. train()vàeval()đổi hành vi của Dropout, BatchNorm;no_grad()tắt autograd. Cần cả hai khi đo và khi dự đoán.- Lưu
best.pttheo metric validation vàlast.ptđể chạy tiếp; checkpoint chạy tiếp được phải có trạng thái optimizer, epoch và cách tái tạo thứ tự dữ liệu. - Mọi con số trong bài đo trên CPU. Bài tiếp theo hỏi: chuyển sang GPU và dùng mixed precision thì nhanh hơn bao nhiêu, và thời gian thật sự đi đâu?
Nguồn tham khảo
- PyTorch Tutorials, Optimizing model parameters (training loop): https://docs.pytorch.org/tutorials/beginner/basics/optimization_tutorial.html
- PyTorch Tutorials, Saving and loading models (checkpoint để chạy tiếp): https://docs.pytorch.org/tutorials/beginner/saving_loading_models.html
- PyTorch Docs,
torch.nn.Dropout: https://docs.pytorch.org/docs/stable/generated/torch.nn.Dropout.html - PyTorch Docs,
torch.no_grad: https://docs.pytorch.org/docs/stable/generated/torch.no_grad.html - PyTorch Docs, Reproducibility: https://docs.pytorch.org/docs/stable/notes/randomness.html
- AI Engineering from Scratch (MIT), phase 19, Checkpoint save and resume: https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/phases/19-capstone-projects/47-checkpoint-save-resume