Mở đầu: quên đúng 1 dòng shuffle, accuracy sập từ 91% xuống gần mức đoán bừa
Chín bài trước dùng dữ liệu ĐỒ CHƠI — sinh bằng công thức, sạch tuyệt đối, không có gì bất ngờ. Bài này đổi sang dữ liệu THẬT: $2.000$ ảnh viết tay chữ số MNIST (200 ảnh/chữ số), nguồn kinh điển nhất của toàn ngành thị giác máy tính. Bạn sẽ ghép lại MỌI kỹ năng của cả Chặng 2 — tensor (Bài 5), MLP (Bài 6), autograd (Bài 7), chống overfit (Bài 8), optimizer (Bài 9) — cộng thêm 1 mảnh mới: softmax + cross-entropy cho phân loại $10$ lớp.
Nhưng bài học đắt giá nhất không nằm ở kiến trúc mạng — nó nằm ở pipeline dữ liệu. Verify bằng số thật: cùng $1$ kiến trúc, cùng optimizer, cùng learning rate, CHỈ khác đúng $1$ điều — có shuffle dữ liệu mỗi epoch hay không — validation accuracy chênh nhau từ $91\%$ xuống còn $15{,}75\%$ (gần mức đoán bừa $10\%$ của $10$ lớp). Không phải lỗi công thức, không phải optimizer tệ — chỉ đơn giản là quên xáo trộn dữ liệu.
1. Dữ liệu thật khác dữ liệu đồ chơi: NHÌN trước khi train
File mnist-subset.bin (vendor sẵn trong repo, không fetch ngoài) chứa $2.000$ ảnh $28\times
28$ pixel xám ($0$–$255$), $200$ ảnh/chữ số, trích tất định từ tập MNIST gốc rồi xáo trộn $1$ lần bằng
seed cố định — mọi con số trong bài tái lập được. Trước khi viết bất kỳ dòng code train nào, luôn
nhìn dữ liệu trước: lưới ảnh mẫu bên dưới hiển thị trực tiếp từ file thật, cùng phân bố
nhãn:
Đang tải dữ liệu…
Nhìn lưới ảnh, vài điều lộ ra ngay mà KHÔNG cần code: nét vẽ tay dày/mỏng khác nhau giữa các mẫu, một số chữ số nghiêng hoặc lệch tâm nhẹ, độ tương phản không đồng đều. Đây chính là điều khiến MNIST "thật" hơn hẳn dữ liệu đồ chơi Chặng 1 — và cũng là gốc rễ của cạm bẫy domain shift ở Mục 5.
Bỏ qua bước "nhìn dữ liệu" là cách nhanh nhất để không phát hiện nhãn lệch (class imbalance), ảnh hỏng (toàn đen/toàn trắng), hay lỗi encode (ảnh bị lật/xoay sai). Với subset này, phân bố nhãn gần như đều ($200$/chữ số ở file gốc; sau khi chia train/val ở Mục 2, mỗi lớp vẫn giữ tỉ lệ tương đối cân bằng — xem thanh phân bố phía trên) — nhưng KHÔNG có gì đảm bảo điều đó đúng với dữ liệu thật bạn sẽ gặp trong công việc. Vài giây nhìn lưới ảnh + biểu đồ phân bố có thể tiết kiệm hàng giờ debug sau này.
2. Pipeline: chuẩn hoá, flatten, và cạm bẫy shuffle
Ba bước biến $2.000$ ảnh thô thành input huấn luyện được:
// 1. Chuan hoa: pixel [0,255] -> [0,1] (cung khoang voi weight init Bai 9)
for (let p = 0; p < 784; p++) pixels[p] = rawByte[p] / 255;
// 2. Flatten: anh 28x28 -> vector 784 chieu (MLP khong biet "khong gian 2D",
// chi biet vector - Bai 11 (Conv2D) moi khai thac cau truc luoi that su)
const X = Tensor.fromNested(batch.map((s) => s.pixels)); // shape (N, 784)
// 3. Mini-batch + SHUFFLE MOI EPOCH (xem cam bay ben duoi)
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
$2.000$ mẫu chia $1.600$ train / $400$ validation (đúng nguyên tắc Bài 8). Bước dễ bỏ sót nhất: shuffle thứ tự mẫu trước mỗi epoch. File gốc lưu mẫu theo đúng thứ tự sinh ra (200 ảnh chữ số $0$ liên tiếp, rồi $200$ ảnh chữ số $1$, …) — nếu KHÔNG xáo trộn, mỗi mini-batch $32$ mẫu sẽ chỉ chứa ĐÚNG $1$ chữ số trong phần lớn thời gian.
Verify bằng số thật: train CÙNG kiến trúc $784\to128\to10$, CÙNG optimizer (SGD+Momentum, $\eta=0{,}1$), CÙNG $15$ epoch, CHỈ khác việc có shuffle mỗi epoch hay không (không shuffle = sắp mẫu tuần tự theo nhãn, batch nào cũng thuần $1$ chữ số):
| Cách nạp dữ liệu | Validation accuracy | Độ biến thiên loss (epoch 1) |
|---|---|---|
| Shuffle lại thứ tự mỗi epoch (đúng) | $91{,}00\%$ | độ lệch chuẩn $0{,}480$ |
| KHÔNG shuffle (mẫu xếp tuần tự theo nhãn) | $15{,}75\%$ — gần mức đoán bừa $10\%$ của $10$ lớp | độ lệch chuẩn $4{,}304$ — gấp $9$ lần |
Xem 2 đường loss ($50$ bước đầu, cùng scale) — đường xanh (shuffle) giảm mượt; đường cam (không shuffle) nhảy dựng đứng mỗi khi batch chuyển sang chữ số mới:
Cơ chế: mỗi batch thuần $1$ chữ số kéo mạng "học thuộc lòng" đúng lớp đó trong vài bước (loss của BATCH ĐÓ rơi gần $0$) — rồi batch tiếp theo chuyển sang chữ số khác, mạng "quên" gần hết những gì vừa học (loss vọt lên hàng chục), một dạng catastrophic forgetting thu nhỏ lặp lại theo từng batch. Shuffle phá vỡ chuỗi tuần tự đó — mỗi batch là 1 mẫu ĐẠI DIỆN của toàn bộ phân bố $10$ lớp, nên gradient trung bình mỗi bước phản ánh đúng bài toán thật, không phải "bài toán của riêng $1$ lớp trong khoảnh khắc đó".
3. Train MLP 784→128→10 với softmax + cross-entropy
Phân loại $10$ lớp cần $2$ mảnh mới cộng vào NeuroJS: softmax (nén $10$ logit thành $10$
xác suất cộng lại bằng $1$) và cross-entropy (phạt theo $-\log$ xác suất gán cho lớp đúng
— nếm trước ở Bài 3 cho $2$ lớp, nay tổng quát hoá cho $10$). Cài
GỘP thành $1$ op nguyên khối softmaxCrossEntropy() thay vì ghép rời exp()/log()
— lý do kép: ổn định số học (trừ max trước exp tránh tràn số), và gradient rút gọn về đúng $1$ dòng đẹp:
$$\frac{\partial L}{\partial \text{logits}} = \frac{\text{softmax(logits)} - y_{\text{one-hot}}}{N}$$
Verify công thức bằng gradient checking (so khớp sai phân hữu hạn, giống kỷ luật Bài 7) trên batch $4$ mẫu $\times$ $5$ lớp ngẫu nhiên — sai lệch tối đa dưới $10^{-3}$, đúng "vùng vàng" đã học. Kiến trúc MLP không đổi từ Bài 6, chỉ tầng cuối giờ có $10$ neuron thay vì $1$:
const params = initParams(seed, 784, 128, 10); // He init (Bai 9)
const opt = new Adam(Object.values(params), 0.001); // Adam (Bai 9)
for (let epoch = 0; epoch < 15; epoch++) {
shuffle(order); // BAT BUOC - xem cam bay Muc 2
for (let b = 0; b < order.length; b += 32) {
const batch = toBatch(train, order.slice(b, b + 32));
zeroGradAll(params);
const logits = forward(params, batch.X); // z2, CHUA qua softmax
const L = softmaxCrossEntropy(logits, batch.Y); // gop softmax + CE
L.backward();
opt.step();
}
}
Kết quả sau đúng $15$ epoch ($750$ bước, seed cố định, tái lập $100\%$): train accuracy $100\%$, validation accuracy $89{,}5\%$. Khoảng cách $10{,}5$ điểm phần trăm giữa train và val là dấu hiệu overfit quen thuộc từ Bài 8 — hoàn toàn dự đoán được với $1.600$ mẫu và $101.770$ tham số, không phải lỗi. Bấm nút Train ở demo Mục 4 để xem đúng quá trình này chạy live, tái lập chính xác $2$ con số trên.
- $H=16$: train $\approx 96{,}5\%$, val $\approx 87{,}8\%$ — khoảng cách $\approx 8{,}8$ điểm.
- $H=32$: train $\approx 98{,}6\%$, val $\approx 89{,}8\%$ — khoảng cách $\approx 8{,}9$ điểm.
- $H=128$ (mặc định bài): train $100\%$, val $\approx 91\%$ — khoảng cách $\approx 9$ điểm.
- $H=256$: train $100\%$, val $\approx 90{,}5\%$ — khoảng cách $\approx 9{,}5$ điểm, val còn nhích xuống nhẹ.
4. Đọc sâu hơn accuracy: confusion matrix
$89{,}5\%$ là một con số DUY NHẤT — nó giấu kín việc model SAI Ở ĐÂU. Confusion matrix $10\times10$ (hàng = nhãn thật, cột = dự đoán) lộ ra chính xác cặp chữ số nào hay bị nhầm, đo trên $400$ mẫu validation:
Bấm Train ở demo Mục 4 bên dưới để bảng này tự render từ model vừa train.
784 chiều input làm mỗi bước chậm hơn hẳn các demo trước — train chạy nền, không khoá trang, có thể bấm Dừng bất kỳ lúc nào rồi Train lại từ đầu.
Đọc bảng thật (sau khi train ở demo trên) sẽ lộ vài điều: chữ số $6$ không sai lần nào ($35/35$) — nét vòng+móc của $6$ khá đặc trưng, ít nhầm với số khác. Ngược lại, chữ số $2$ hay bị đoán thành $4$ ($4$ lần) và thành $3$ ($3$ lần trên $46$ mẫu) — dễ hiểu khi nhìn ảnh: nét cong của $2$ viết ẩu bỏ mất phần đáy ngang có thể trông giống nửa trên của $4$ hoặc thân cong của $3$. Chữ số $8$ rải lỗi đều sang nhiều lớp khác ($3$, $5$, $6$, $9$) — hợp lý vì $8$ về hình dạng giao thoa với gần như mọi chữ số có nét cong.
"Model đạt $89{,}5\%$ accuracy" nghe ổn — nhưng nó che mất việc digit $2$ sai tới $8$ trên $46$ lần ($\approx17\%$ lỗi riêng lớp đó), gần gấp $3$ lần tỉ lệ lỗi tổng thể ($10{,}5\%$). Nếu ứng dụng thật của bạn cần đọc chính xác SỐ TÀI KHOẢN NGÂN HÀNG mà chữ số $2$ hay lẫn với $4$/$3$, con số $89{,}5\%$ không nói cho bạn biết rủi ro thật nằm ở đâu — luôn xem confusion matrix trước khi tin vào $1$ con số accuracy tổng.
5. Demo đinh: vẽ chữ số bằng chuột — và vì sao model 89,5% vẫn đoán sai chữ BẠN vẽ
Vẽ $1$ chữ số bằng chuột/ngón tay vào canvas bên dưới, model (đã train ở demo Mục 4) sẽ đoán live kèm xác suất cả $10$ lớp. Thử vẽ VÀ để ý: chữ số bạn vẽ hiếm khi nằm CHÍNH GIỮA canvas như ảnh MNIST gốc (MNIST đã được căn giữa theo trọng tâm nét vẽ khi tạo dataset) — đây chính là domain shift: phân bố dữ liệu THẬT (nét bạn vẽ) khác phân bố dữ liệu ĐÃ TRAIN (MNIST đã chuẩn hoá).
Bỏ tick "Căn giữa trước khi đoán" và vẽ $1$ chữ số lệch hẳn về góc canvas — verify bằng số đo trên chính tập validation (không phải cảm tính): lấy $50$ ảnh MNIST model đang đoán ĐÚNG ($100\%$ baseline), dịch mỗi ảnh $5$ pixel theo cả $2$ trục (mô phỏng nét vẽ lệch tâm) rồi đoán lại KHÔNG chỉnh gì:
| Cách xử lý ảnh | Accuracy trên 50 ảnh (vốn đúng 100% khi căn giữa) |
|---|---|
| Ảnh gốc, đã căn giữa (như MNIST train) | $100\%$ (đúng theo định nghĩa — đây là baseline) |
| Dịch $(5, 5)$ pixel, KHÔNG chỉnh gì | $2\%$ — gần như sập hoàn toàn |
| Dịch $(5, 5)$ pixel, rồi CĂN GIỮA lại theo trọng tâm (center of mass) | $82\%$ — phục hồi phần lớn |
Chỉ dịch chuyển vị trí — KHÔNG đổi nét vẽ, KHÔNG thêm nhiễu, KHÔNG đổi độ dày — đã làm accuracy sập từ $100\%$ xuống $2\%$. MLP học từ pixel THÔ, không có khái niệm "bất biến vị trí" (translation invariance) — nó học đúng VỊ TRÍ pixel nào thường sáng cho từng lớp, nên dịch chuyển ảnh phá vỡ hoàn toàn mẫu đó dù mắt người vẫn nhận ra ngay là cùng $1$ chữ số. Đây chính là lý do Bài 11 (CNN) tồn tại: tích chập chia sẻ trọng số theo cách MANG TÍNH BẤT BIẾN vị trí, không cần "may mắn" căn giữa như MLP. Với MLP, cách giảm nhẹ thực tế: căn giữa theo trọng tâm nét vẽ TRƯỚC khi đưa vào model (đúng cách MNIST gốc đã chuẩn hoá) — verify trên chính bảng số phía trên, phục hồi $82\%$ trong $100\%$ khả dĩ, không hoàn hảo nhưng đủ dùng.
- Dịch $1$ pixel: KHÔNG chỉnh $\approx 66\%$ — đã sụt đáng kể chỉ với $1$ pixel duy nhất.
- Dịch $2$ pixel: KHÔNG chỉnh $\approx 22\%$.
- Dịch $3$ pixel: KHÔNG chỉnh $\approx 0\%$ — sập hoàn toàn.
- Dịch $5$ pixel, có căn giữa lại: $\approx 80\%$ (khớp cỡ với con số $82\%$ đã nêu — chênh lệch nhỏ do khác biệt ngẫu nhiên giữa 2 lần chạy độc lập).
- Dịch $10$ pixel, có căn giữa lại: chỉ còn $\approx 36\%$ — recentering cũng bắt đầu thất bại, vì nét vẽ bị dịch quá xa khiến một phần chữ số bị "cắt cụt" ở rìa canvas, làm trọng tâm tính ra sai lệch.
Đối chiếu công nghiệp: PyTorch DataLoader tự động hoá đúng vòng lặp Mục 2-3:
# Doi chieu 1-1 voi pipeline Muc 2-3 - cai dat: pytorch.org/get-started/locally
import torch, torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True) # shuffle=True = Muc 2!
model = nn.Sequential(nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10))
loss_fn = nn.CrossEntropyLoss() # gop softmax+CE giong het Muc 3 (khong tu them Softmax)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
for epoch in range(15):
for xb, yb in loader: # DataLoader tu shuffle + chia batch moi epoch
optimizer.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
optimizer.step()
Tóm lược
- ✅ Luôn nhìn dữ liệu trước khi train — lưới ảnh mẫu + phân bố nhãn lộ ra vấn đề (nhãn lệch, ảnh hỏng) mà không cần chạy dòng code train nào.
- ✅ Shuffle mỗi epoch là bắt buộc, không phải tuỳ chọn — verified: không shuffle làm validation accuracy sập từ $91\%$ xuống $15{,}75\%$ (gần đoán bừa), độ biến thiên loss gấp $9$ lần, do dữ liệu xếp tuần tự theo nhãn gây "quên" lặp lại mỗi batch.
- ✅ Softmax + cross-entropy cài GỘP thành $1$ op với gradient rút gọn $(\text{softmax}-y)/N$ — verified bằng gradient checking; MLP $784\to128\to10$ đạt train $100\%$/val $89{,}5\%$ sau $15$ epoch, tái lập chính xác nhờ seed cố định.
- ✅ Confusion matrix lộ ra chỗ model yếu mà accuracy tổng giấu kín — verified: digit $6$ hoàn hảo ($35/35$), digit $2$ hay nhầm thành $4$/$3$, digit $8$ rải lỗi rộng khắp.
- ✅ Domain shift: model accuracy cao trên tập test KHÔNG đảm bảo đúng trên dữ liệu người dùng thật — verified: dịch ảnh $5$px làm accuracy sập $100\%\to2\%$, căn giữa theo trọng tâm phục hồi $82\%$ — MLP không có bất biến vị trí, lý do CNN (Bài 11) ra đời.
Tải file code thực hành minh họa bài học
File JavaScript đọc mnist-subset.bin, train MLP thật (NeuroJS: Tensor/Adam/softmax-CE),
verify shuffle + domain shift bằng số đo được (chạy node mnist_project_demo.js, mất khoảng
$30$ giây vì train $3$ mô hình để đối chiếu):
Bình luận