Mở đầu: model 95% train accuracy — tin mừng hay tin dữ?

Bảy bài trước dạy bạn cách khiến loss giảm về gần $0$. Bài này dạy một sự thật khó chịu hơn: loss train thấp không đảm bảo điều gì về việc model có dùng được ngoài đời hay không. Một mạng đủ lớn có thể học thuộc lòng từng điểm dữ liệu huấn luyện — kể cả những điểm bị gán nhãn sai — trong khi hoàn toàn bất lực trước một điểm dữ liệu mới chưa từng thấy. Đây gọi là overfitting (quá khớp), và nó là lý do số $1$ khiến model "chạy tốt trên giấy" nhưng thất bại khi triển khai thật.

Bài này verify từng cạm bẫy bằng số thật, dùng đúng mạng MLP + autograd của Bài 6/Bài 7: bạn sẽ thấy một mạng tự "học thuộc lòng" 20 điểm dữ liệu nhiễu, thấy chính xác epoch nào nó bắt đầu quá khớp, và thấy 2 cách chữa kinh điển (L2, dropout) hoạt động — cùng với cạm bẫy dropout khiến model "say rượu" nếu quên đúng 1 dòng code lúc phục vụ thật.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 6 (MLP) và Bài 7 (autograd — mọi phép huấn luyện trong bài này dùng backward() thật, không viết tay). Tài nguyên ngoài: Google ML Crash Course — Overfitting, PyTorch — nn.Dropout.

1. Train / Validation / Test: 3 tập, 3 vai trò không được lẫn lộn

Chia dữ liệu thành $3$ phần tách biệt, mỗi phần một nhiệm vụ duy nhất:

split_data.js (đúng cách chia trong file tải về — không xáo trộn ngẫu nhiên để tái lập được)
const all = makeMoons(60, 0.3, 0.15, 11);  // 60 diem, seed CO DINH - tai lap duoc
const train = all.slice(0, 20);   // cap nhat trong so
const val   = all.slice(20, 40);  // chon sieu tham so (capacity, lambda, epoch dung)
const test  = all.slice(40, 60);  // CHI danh gia 1 LAN duy nhat, sau khi da chot moi lua chon
Tập Dùng để làm gì KHÔNG được dùng để làm gì
Train Cập nhật trọng số (gradient descent chạy trên tập này)
Validation Chọn siêu tham số (capacity, $\lambda$ L2, khi nào dừng) Cập nhật trọng số trực tiếp
Test Báo cáo kết quả cuối — CHỈ nhìn một lần, sau khi đã chốt mọi lựa chọn Chọn bất kỳ thứ gì (siêu tham số, kiến trúc, thời điểm dừng)
⚠️ Cạm bẫy chết người nhất nghề ML: tinh chỉnh theo test set = rò rỉ dữ liệu

Nếu bạn thử nhiều lựa chọn (kiến trúc, seed, siêu tham số) rồi chọn cái nào cho điểm test đẹp nhất, con số đó không còn đáng tin — bạn đã ngầm "tối ưu hoá" cho đúng nhiễu riêng của tập test đó. Verify bằng số cụ thể: train $25$ mô hình cùng kiến trúc, chỉ khác seed khởi tạo ngẫu nhiên, trên cùng bộ dữ liệu $2$ trăng (moons) nhiễu:

Cách chọn model để báo cáo Test accuracy báo cáo
Đúng quy trình: chọn seed có val accuracy tốt nhất, báo test 1 LẦN $0{,}84$
RÒ RỈ: chọn seed có test accuracy tốt nhất trực tiếp (trong $25$ seed) $0{,}92$ — nhưng đây là số cao nhất của $25$ lần thử, không phải hiệu năng thật
Test accuracy TRUNG BÌNH trên cả $25$ seed (không chọn lọc gì) $0{,}837$ — gần với $0{,}84$ (đúng quy trình), rất xa $0{,}92$

Con số $0{,}92$ "đẹp" không sai về mặt tính toán — nó chỉ đơn giản là số cao nhất trong $25$ lần thử ngẫu nhiên, đúng hiệu ứng thống kê gọi là "multiple comparisons" (so sánh nhiều lần): thử càng nhiều, càng dễ tình cờ trúng một lần ăn may khớp với nhiễu riêng của bộ test đó. Đem model "$0{,}92$" này ra phục vụ dữ liệu thật (chưa từng xuất hiện trong toàn bộ quá trình), điểm số sẽ rơi về gần mức trung bình thật — "đẹp trên giấy, sập ngoài đời" đúng nghĩa đen. Quy tắc sống còn: test set chỉ được nhìn ĐÚNG MỘT LẦN, sau khi mọi quyết định (kiến trúc, $\lambda$, epoch dừng) đã chốt xong bằng validation set.

2. Bias–variance: 4 hình dạng loss curve kinh điển

Vẽ loss train và loss validation trên cùng một biểu đồ qua từng epoch — hình dạng của cặp đường này chẩn đoán chính xác vấn đề đang gặp phải. Dùng đúng mạng $2\to H\to1$ (Bài 6), dữ liệu $2$ trăng $20$ điểm train / $20$ điểm validation, $15\%$ nhãn bị nhiễu có chủ đích:

Hình dạng Train loss Val loss Chẩn đoán
Underfit ($H=1$, epoch 1800) $0{,}1182$ (plateau CAO) $0{,}2914$ (plateau CAO, gần train) Model quá đơn giản — không đủ sức học ngay cả dữ liệu train
Vừa đủ ($H=64$, epoch 10) $0{,}1820$ $0{,}2272$ (ĐÁY — thấp nhất từng đạt) Cả hai còn đang giảm, khoảng cách nhỏ — điểm tốt nhất để DỪNG
Overfit ($H=64$, epoch 5800) $0{,}0411$ (rất thấp) $0{,}4808$ (tăng NGƯỢC LẠI, tệ hơn cả epoch 0) Model học thuộc lòng nhiễu của tập train, khoảng cách 2 đường doãng rộng

Chú ý điều phản trực giác nhất bảng: cùng một mạng $H=64$, chỉ khác thời điểm dừng huấn luyện, cho ra $2$ hình dạng hoàn toàn khác nhau — "vừa đủ" ở epoch $10$, "overfit nặng" ở epoch $5800$. Val loss đạt đáy tại epoch $\approx 10$ rồi tăng liên tục từ đó — nghĩa là mọi epoch huấn luyện sau điểm đó chỉ làm model "thuộc lòng" thêm dữ liệu train mà không học thêm điều gì tổng quát. Đây chính là cơ sở của kỹ thuật early stopping (Mục 4): theo dõi val loss, dừng ngay khi nó ngừng giảm.

3. L2 Regularization / Weight Decay: phạt trọng số lớn

Trực giác: model overfit thường có trọng số lớn và "gồ ghề" — cần trọng số cực lớn để uốn boundary ôm sát từng điểm nhiễu riêng lẻ. L2 regularization phạt trực tiếp độ lớn trọng số bằng cách cộng thêm vào loss:

$$L_{total} = L_{data} + \frac{\lambda}{2}\sum_i w_i^2$$

Đạo hàm của số hạng phạt theo $w_i$ đơn giản là $\lambda w_i$ — nên trong code, L2 chỉ là cộng thêm $\lambda \cdot w$ vào gradient trước khi cập nhật (không cần đưa qua computation graph autograd, rẻ hơn nhiều):

l2_weight_decay.js (đúng cách sgdStep() trong file tải về làm)
function sgdStep(params, lr, l2) {
  for (const k in params) {
    const p = params[k];
    for (let i = 0; i < p.size; i++) {
      const g = p.grad[i] + l2 * p.data[i];   // weight decay: cong them lambda*w vao gradient
      p.data[i] -= lr * g;
    }
  }
}

Quét $\lambda$ trên đúng mạng $H=64$ đã overfit nặng ở Mục 2 (huấn luyện $5800$ epoch — cùng số epoch đã cho train loss $0{,}0411$/val loss $0{,}4808$ khi $\lambda=0$):

$\lambda$ Train acc Val acc Nhận xét
$0$ $0{,}950$ $0{,}400$ Overfit nặng — val TỆ HƠN đoán ngẫu nhiên ($0{,}5$)
$0{,}01$ $0{,}900$ $0{,}550$ Bắt đầu cải thiện
$0{,}05$ $0{,}850$ $0{,}600$ Cân bằng tốt nhất trong bảng — 2 số gần nhau hơn
$0{,}1$ (quá liều) $0{,}550$ $0{,}600$ Phạt QUÁ MẠNH — train acc SẬP theo, giờ lại underfit

$\lambda$ không phải "càng lớn càng tốt" — quá liều biến bài toán từ overfit sang underfit (train accuracy chính nó cũng sập, từ $95\%$ xuống còn $55\%$, gần như đoán ngẫu nhiên). L2 gần như mặc định được bật trong mọi model thật (PyTorch: tham số weight_decay= của optimizer) chính vì chi phí gần như $0$ (chỉ 1 phép cộng) nhưng hiệu quả chống overfit rõ rệt ở mức $\lambda$ vừa phải.

4. Dropout & Early Stopping

Dropout: trong lúc huấn luyện, tắt ngẫu nhiên mỗi neuron với xác suất $p$ ở mỗi bước — buộc mạng không được "ỷ lại" vào bất kỳ neuron đơn lẻ nào, tương đương huấn luyện ngầm một ensemble của $2^n$ mạng con (mỗi tổ hợp bật/tắt neuron là 1 mạng con khác nhau) rồi lấy trung bình chúng. Cài đặt "inverted dropout" chuẩn: neuron còn sống được nhân bù $1/(1-p)$ để giữ nguyên tổng kỳ vọng đầu ra:

dropout.js (đúng hàm forwardWithDropout() trong file tải về)
function dropoutMask(n, H, p, rng) {
  const keepScale = 1 / (1 - p);
  const rows = [];
  for (let i = 0; i < n; i++) {
    const row = [];
    for (let h = 0; h < H; h++) row.push(rng() < p ? 0 : keepScale);  // tat voi xac suat p, con lai NHAN BU
    rows.push(row);
  }
  return Tensor.fromNested(rows);
}
// a1_dropped = mul(a1, mask) — dung DUNG phep mul() da co san, khong can op moi
⚠️ Cạm bẫy: quên tắt dropout lúc inference — model "say rượu" khi phục vụ thật

Dropout CHỈ được bật lúc huấn luyện — lúc dự đoán thật (inference), phải dùng toàn bộ neuron (không mask). Quên bước này verify bằng số: huấn luyện $1$ mạng với dropout $p=0{,}5$, rồi gọi dự đoán $2$ lần trên CÙNG một input:

Cách eval Lần gọi 1 Lần gọi 2 Ổn định?
ĐÚNG (tắt dropout) $[0{,}4347,\ 0{,}9475,\ 0{,}0693,\ \ldots]$ $[0{,}4347,\ 0{,}9475,\ 0{,}0693,\ \ldots]$ Giống hệt nhau tuyệt đối
SAI (quên tắt dropout) $[0{,}430,\ 0{,}973,\ 0{,}016,\ \ldots]$ $[0{,}503,\ 0{,}690,\ 0{,}053,\ \ldots]$ Dao động rõ rệt mỗi lần gọi

Accuracy đo trên cùng $1$ tập test, cùng $1$ model đã đóng băng, với eval SAI cách dao động $[0{,}85,\ 0{,}85,\ 0{,}80,\ 0{,}80,\ 0{,}75]$ qua $5$ lần gọi liên tiếp — cùng một model, cùng dữ liệu, điểm số vẫn nhảy loạn xạ tuỳ may rủi neuron nào bị tắt lần đó. PyTorch giải quyết bằng đúng $2$ dòng: model.train() (bật dropout) trước vòng lặp huấn luyện, model.eval() (tắt dropout, dùng toàn bộ neuron) trước khi dự đoán thật — quên gọi .eval() là lỗi cực kỳ phổ biến khi mới triển khai model.

🔢 Kiểm chứng bằng số: dropout có thực sự giữ nguyên kỳ vọng đầu ra?
Lấy 2 neuron có giá trị kích hoạt cố định $(3{,}0,\ 5{,}0)$, áp inverted dropout $p=0{,}5$ (tắt với xác suất $50\%$, giữ lại thì nhân bù $\times 2$) lặp lại $200{,}000$ lần rồi lấy trung bình (mô phỏng Monte Carlo, đã kiểm chứng bằng Python): kỳ vọng đầu ra đo được $\approx (2{,}996,\ 4{,}992)$ — sát gần như tuyệt đối với giá trị gốc $(3{,}0,\ 5{,}0)$ khi KHÔNG có dropout. Đây là bằng chứng số cho lời giải thích lý thuyết ở trên: hệ số bù $1/(1-p)$ không phải một con số tuỳ tiện — nó được chọn chính xác để $\mathbb{E}[\text{đầu ra có dropout}] = \text{đầu ra không dropout}$, nghĩa là mạng "nhìn thấy" cùng một quy mô tín hiệu trung bình dù đang bật hay tắt dropout, chỉ khác là lúc train có thêm nhiễu ngẫu nhiên xung quanh giá trị kỳ vọng đó.

Early stopping — "regularization miễn phí" đơn giản nhất: theo dõi val loss mỗi vài epoch, lưu lại bộ trọng số tại điểm val loss THẤP NHẤT, dừng huấn luyện khi val loss không cải thiện sau một số epoch liên tiếp (patience). Áp lên đúng ví dụ Mục 2: val loss đạt đáy $0{,}2272$ ở epoch $\approx 10$; early stopping sẽ dừng quanh đó và LƯU LẠI bộ trọng số của epoch $10$ — thay vì chạy tiếp tới epoch $5800$ và kết thúc với val loss tệ hơn gấp đôi ($0{,}4808$). Không cần công thức toán học nào — chỉ cần biết dừng đúng lúc.

🧠 "Patience" tiết kiệm bao nhiêu công sức tính toán thật?
Thuật toán patience đơn giản: sau mỗi lần kiểm tra val loss, nếu nó không cải thiện so với mức tốt nhất từng thấy sau $N$ lần kiểm tra liên tiếp (patience $=N$), dừng huấn luyện và khôi phục lại bộ trọng số của lần tốt nhất. Áp vào đúng 2 điểm mốc đã kiểm chứng ở trên: val loss tốt nhất tại epoch $10$ ($0{,}2272$), sau đó liên tục xấu đi cho tới epoch $5800$ ($0{,}4808$) — nghĩa là suốt $5790$ epoch liên tiếp, val loss không hề cải thiện lại lần nào. Với patience $= 50$ (một giá trị thực hành phổ biến), thuật toán sẽ dừng ngay quanh epoch $60$–$70$ — sớm hơn gần $100$ lần so với chạy hết $5800$ epoch, mà vẫn khôi phục đúng bộ trọng số tốt nhất (epoch $10$). Đây là lý do early stopping không chỉ là "kỹ thuật chống overfit" mà còn là công cụ tiết kiệm chi phí tính toán khổng lồ trong thực hành — không cần đoán trước số epoch tối ưu, thuật toán tự phát hiện và dừng.

5. Thực hành: capacity + L2 — xem boundary từ mượt sang "ôm từng điểm nhiễu"

Chọn độ rộng mạng ($H$) và $\lambda$ L2, bấm Train để huấn luyện live trên đúng bộ dữ liệu $2$ trăng nhiễu của Mục 2 — quan sát boundary bên trái và cặp loss curve bên phải tách nhau đúng lúc overfit bắt đầu:

📉 Demo — Capacity & L2: boundary và cặp loss curve train/val live
Capacity (H): L2 (λ):
Đang tải…

Chấm đặc = điểm train, chấm viền = điểm validation. Thử $H=64$, $\lambda=0$: xem đường val (đỏ) tách khỏi đường train (xanh) và ĐI LÊN sau vài trăm epoch đầu. Bật $\lambda=0{,}05$ rồi Reset + Train lại: khoảng cách 2 đường thu hẹp rõ rệt.

Đối chiếu công nghiệp: 2 dòng PyTorch dễ quên nhất, đúng cạm bẫy dropout Mục 4:

train_vs_eval.py (chạy ngoài browser — cần cài PyTorch)
# Doi chieu 1-1 voi cam bay dropout Muc 4 - cai dat: pytorch.org/get-started/locally
import torch.nn as nn

model = nn.Sequential(nn.Linear(2, 64), nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, 1), nn.Sigmoid())
optimizer = torch.optim.SGD(model.parameters(), lr=0.3, weight_decay=0.05)  # L2 co san trong optimizer

model.train()          # BAT dropout - dung luc huan luyen
# ... vong lap huan luyen, loss.backward(), optimizer.step() ...

model.eval()            # TAT dropout - BAT BUOC truoc khi du doan that
with torch.no_grad():
    pred = model(x_new)  # thieu dong nay -> dung ket qua giong bang "SAI" o Muc 4

Tóm lược

  • Train/val/test có $3$ vai trò tách biệt — test set chỉ nhìn ĐÚNG $1$ lần; tinh chỉnh theo test là rò rỉ dữ liệu (verified: $0{,}92$ "đẹp" vs $0{,}837$ trung bình thật, chênh gần $9$ điểm phần trăm).
  • 4 hình dạng loss curve: underfit (cả 2 cao, gần nhau), vừa đủ (cả 2 thấp, gần nhau — điểm dừng lý tưởng), overfit (train thấp, val TĂNG TRỞ LẠI) — verified trên cùng 1 mạng $H=64$ chỉ khác thời điểm dừng (epoch $10$ vs $5800$).
  • L2/weight decay phạt trọng số lớn bằng $\lambda w$ cộng vào gradient — vừa đủ thì giảm overfit rõ rệt, quá liều thì gây underfit ngược (verified: train acc sập từ $95\%$ xuống $55\%$ ở $\lambda=0{,}1$).
  • Dropout = ensemble ngầm $2^n$ mạng con, PHẢI tắt lúc inference — quên tắt làm dự đoán dao động không kiểm soát được (verified: accuracy nhảy $0{,}75$–$0{,}85$ trên cùng model, cùng dữ liệu).
  • Early stopping: theo dõi val loss, dừng và lưu trọng số tại điểm THẤP NHẤT — quy tắc chống overfit không cần công thức toán nào.

Tải file code thực hành minh họa bài học

File JavaScript gồm dữ liệu 2 trăng (moons), MLP + L2 + dropout (forward dùng NeuroJS, backward tự động qua autograd Bài 7), với self-test đối chiếu mọi con số trích dẫn trong bài (chạy node overfitting_demo.js):

Tải về overfitting_demo.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 7: Backpropagation & autograd Bài 9: Tối ưu hoá nâng cao Quay lại Lộ trình Series Trí Tuệ Nhân Tạo

Bình luận