Huấn luyện một mạng nhỏ bằng NumPy
Xem thửMinh đã có đủ các mảnh: forward cho 280 đơn, backward cho 257 tham số, hàm mất mát BCE tính an toàn. Giờ là lúc ghép chúng lại để mạng tự học từ dữ liệu. Anh dùng 40.000 đơn đầu tiên (từ tháng 1 tới giữa tháng 5) để huấn luyện và giữ 10.000 đơn cuối để kiểm tra, đúng cách chia theo thời gian ở Chương 9.
Đối thủ cần vượt qua là logistic regression của Chương 7 trên cùng 6 đặc trưng thô (quãng đường, số món, trời mưa, express, giờ đặt, kinh nghiệm shipper): loss BCE trên tập kiểm tra 0,2597, AUC 0,9336. Mạng của Minh có một lớp ẩn 16 nơ-ron ReLU.
Hãy ghi ra hai dự đoán trước khi đọc tiếp: mạng 6-16-1 có thắng logistic regression trên tập kiểm tra không, và nếu thắng thì nhờ đặc trưng nào? Nếu Minh đặt learning rate bằng 50 thay vì 0,5 để "học nhanh hơn", loss sau một epoch sẽ ra sao?
Sau bài này, bạn có thể
- Viết vòng lặp huấn luyện mini-batch bằng NumPy: xáo dữ liệu, forward, loss, backward, update.
- Đọc đường loss qua các epoch trên tập huấn luyện và tập kiểm tra, và giải thích khoảng cách giữa hai đường.
- So sánh mạng với logistic regression bằng loss và AUC, và chỉ ra mạng học được gì mà mô hình tuyến tính không học được.
- Nhận ra learning rate quá lớn và dữ liệu chưa chuẩn hóa qua đường loss và số nơ-ron ReLU chết.
Cần biết trước: bốn bài trước của chương, bài Gradient descent và learning rate (Chương 5) và bài Chia dữ liệu theo thời gian và cross-validation đúng cách (Chương 9).
Mức độ: Trung bình · Thời gian: khoảng 20 phút · Từ khóa: vòng lặp huấn luyện, mini-batch, epoch, learning rate, SGD, ReLU chết
Vòng lặp bốn bước
Huấn luyện là lặp lại một chu trình ngắn cho tới khi loss không giảm thêm. Mỗi lần lặp lấy một nhóm nhỏ đơn, gọi là mini-batch, rồi làm bốn việc:
- Forward: tính xác suất trễ
pcho các đơn trong batch, lưu giá trị trung gian. - Loss: đo BCE giữa
pvà nhãn thật. - Backward: tính gradient của loss theo mọi tham số.
- Update: dịch mỗi tham số một bước ngược hướng gradient:
W mới = W - lr × dW.
lr là learning rate (tốc độ học), độ dài mỗi bước. Một lượt đi hết toàn bộ dữ liệu huấn luyện gọi là một epoch. Với 40.000 đơn và batch 64 đơn, một epoch là 625 bước update. Trước mỗi epoch, thứ tự các đơn được xáo ngẫu nhiên để mỗi batch là một mẫu đại diện, không phải toàn đơn tháng 1 rồi toàn đơn tháng 2. Cách cập nhật theo từng mini-batch này gọi là SGD (stochastic gradient descent): mỗi bước dùng gradient ước lượng từ 64 đơn thay vì cả 40.000, nên nhanh hơn nhiều nhưng có nhiễu.
Mô hình tư duy của bài: huấn luyện là vòng lặp forward, loss, backward, update; mọi thứ khác là chi tiết của bốn bước này. Sơ đồ sau cho thấy hai vòng lồng nhau.
Đang tải sơ đồ…
Cách đọc hình: ô cyan bắt đầu một epoch bằng việc xáo dữ liệu. Vòng trong (ô xanh dương, hai ô tím, ô cam đầu) chạy một bước cho mỗi batch: 625 lần mỗi epoch. Vòng ngoài (ô cam thứ hai) quyết định có chạy thêm epoch không. Chỉ khi xong mọi epoch, mạng mới được đo trên tập kiểm tra ở ô xanh lá; tập kiểm tra không bao giờ đi vào vòng lặp.
Animation dưới đây cho một mạng 6-16-1 chạy vòng lặp này trên 10.000 đơn đầu, 8 epoch, đúng số liệu của ví dụ ở bước 2 phần Làm cùng mình. Hãy xem loss sau mỗi epoch, rồi bấm nút biến thể để đổi learning rate thành 50.
Đang tải Animation…
Với learning rate 0,5, loss rơi từ 0,573 xuống 0,211 ngay sau epoch đầu rồi gần như phẳng. Với learning rate 50, cùng code, cùng dữ liệu, loss tăng lên 2,242, nhảy tới 5,965, và cuối cùng 13 trên 16 nơ-ron ReLU không bao giờ bật lại. Bước quá dài không làm học nhanh hơn; nó ném tham số ra xa khỏi vùng có loss nhỏ.
Làm cùng mình
Bước 1: huấn luyện mạng 6-16-1 trên 40.000 đơn, so với logistic regression.
def train(lr=0.5, hidden=16, epochs=30, batch=64, seed=0, report=(1, 2, 5, 10, 20, 30)):
rng = np.random.default_rng(seed)
P = {"W1": rng.normal(0, np.sqrt(2 / 6), (6, hidden)), "b1": np.zeros(hidden),
"W2": rng.normal(0, np.sqrt(1 / hidden), (hidden, 1)), "b2": np.zeros(1)}
for epoch in range(1, epochs + 1):
order = rng.permutation(len(Z_tr)) # xáo dữ liệu mỗi epoch
for start in range(0, len(order), batch):
idx = order[start:start + batch]
p, (X, z1, h) = forward(P, Z_tr[idx]) # 1. forward
dz2 = (p - y_tr[idx])[:, None] / len(idx) # 2-3. loss BCE và gradient ở đầu ra
dz1 = (dz2 @ P["W2"].T) * (z1 > 0) # 3. backward qua ReLU
grads = {"W2": h.T @ dz2, "b2": dz2.sum(0), "W1": X.T @ dz1, "b1": dz1.sum(0)}
for k in P:
P[k] -= lr * grads[k] # 4. update
if epoch in report:
print(f"epoch {epoch:2d} train loss {bce(forward(P, Z_tr)[0], y_tr):.4f}"
f" test loss {bce(forward(P, Z_te)[0], y_te):.4f}")
return P
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
rng.normal(0, np.sqrt(2 / 6), (6, hidden)) | Khởi tạo W1 ngẫu nhiên theo số đầu vào | Mạng bắt đầu từ trọng số khác nhau để các nơ-ron học những điều khác nhau |
rng.permutation(len(Z_tr)) | Xáo thứ tự 40.000 đơn | Dữ liệu sắp theo thời gian; không xáo thì batch cuối epoch toàn đơn tháng 5 |
dz2 = (p - y_tr[idx])[:, None] / len(idx) | Gradient ở đầu ra | Công thức gọn của sigmoid ghép BCE, chia cho 64 đơn |
(dz2 @ P["W2"].T) * (z1 > 0) | Đi ngược qua W2 và cổng ReLU | Đúng ba quy tắc của bài Backpropagation |
P[k] -= lr * grads[k] | Update mọi tham số | Bước 4 của vòng lặp, lr = 0.5 |
Phần sau hàm train trong file chuẩn hóa dữ liệu bằng trung bình và độ lệch chuẩn của tập huấn luyện, huấn luyện 30 epoch, rồi so với LogisticRegression của scikit-learn trên cùng dữ liệu đã chuẩn hóa. Dòng cuối thử một đơn cố định (express, 7 km, 3 món, trời khô, shipper 12 tháng) ở mọi giờ đặt từ 7 tới 21 giờ.
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, scikit-learn 1.9, CPU x86_64, ngày 04/11/2026:
epoch 1 train loss 0.2167 test loss 0.2656
epoch 2 train loss 0.2168 test loss 0.2634
epoch 5 train loss 0.2109 test loss 0.2547
epoch 10 train loss 0.2074 test loss 0.2508
epoch 20 train loss 0.2029 test loss 0.2499
epoch 30 train loss 0.2048 test loss 0.2523
model test loss AUC
base rate 0.178 0.5290 0.5
logistic regression 0.2597 0.9336
MLP 6-16-1 0.2523 0.9395
hour 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
MLP 0.21 0.31 0.42 0.52 0.61 0.66 0.55 0.43 0.43 0.61 0.74 0.83 0.83 0.63 0.37
logistic 0.43 0.44 0.45 0.47 0.48 0.50 0.51 0.53 0.54 0.55 0.57 0.58 0.60 0.61 0.62
Đọc kết quả theo ba phần.
Đường loss: loss huấn luyện đi từ 0,2167 xuống quanh 0,203 đến 0,205, còn loss kiểm tra từ 0,2656 xuống tốt nhất 0,2499 ở epoch 20, rồi nhích lên 0,2523 ở epoch 30. Với learning rate 0,5, mỗi bước SGD còn nhiễu nên loss dao động nhẹ giữa các epoch. Loss kiểm tra luôn cao hơn loss huấn luyện, một phần vì data card ghi rõ: xác suất mưa tăng từ 15% lên 50% sau tháng 4, nên 10.000 đơn cuối mưa nhiều hơn, trễ nhiều hơn (21,8% so với 17,8%) và khó đoán hơn.
Bảng so sánh: đoán mọi đơn bằng tỉ lệ trễ chung 0,178 cho loss 0,5290. Logistic regression giảm còn 0,2597. Mạng giảm tiếp còn 0,2523 và AUC tăng từ 0,9336 lên 0,9395. Câu trả lời cho câu hỏi mở bài: mạng thắng, nhưng chỉ khoảng 2,9% loss.
Đường theo giờ: đây là chỗ mạng thắng. Logistic regression chỉ vẽ được một đường đơn điệu, từ 0,43 lúc 7 giờ tăng đều tới 0,62 lúc 21 giờ. Mạng học được hai đỉnh: 0,66 lúc 12 giờ, tụt về 0,43 lúc 14 và 15 giờ, rồi 0,83 lúc 18 đến 19 giờ. Đó chính là quy luật giờ cao điểm trong data card, và chính là lý do của bài Perceptron: một đường thẳng không biểu diễn được "lên, xuống, rồi lên".
Bước 2: tình huống thất bại với learning rate và chuẩn hóa. File learning_rate_compare.py dùng cùng vòng lặp trên 10.000 đơn đầu, 8 epoch, chỉ đổi learning rate và việc có chuẩn hóa hay không.
print("loss after epoch 0 (before training) to 8:")
for lr, std in [(0.01, True), (0.5, True), (50.0, True), (0.5, False)]:
losses, dead = run(lr, std)
print(f"lr={lr:<4} std={'yes' if std else 'no ':3} " + " ".join(f"{v:.3f}" for v in losses) + f" dead ReLU {dead}/16")
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, CPU x86_64, ngày 04/11/2026:
loss after epoch 0 (before training) to 8:
lr=0.01 std=yes 0.573 0.439 0.382 0.345 0.319 0.299 0.283 0.270 0.259 dead ReLU 0/16
lr=0.5 std=yes 0.573 0.211 0.210 0.209 0.213 0.209 0.210 0.203 0.205 dead ReLU 0/16
lr=50.0 std=yes 0.573 2.242 2.679 5.965 2.679 2.679 3.713 2.679 1.571 dead ReLU 13/16
lr=0.5 std=no 2.366 0.449 0.479 0.390 0.406 0.454 0.419 0.445 0.385 dead ReLU 14/16
| Cấu hình | Điều quan sát được | Giải thích |
|---|---|---|
| lr 0,01 | Loss giảm đều nhưng chậm, 0,259 sau 8 epoch | Bước quá ngắn; cần nhiều epoch hơn hẳn |
| lr 0,5 | 0,211 sau epoch 1, rồi ổn định quanh 0,205 | Bước vừa phải |
| lr 50 | Loss cao hơn lúc chưa học, nhảy tới 5,965, 13/16 ReLU chết | Bước quá dài đẩy bias của nhiều nơ-ron xuống rất âm, z âm với mọi đơn, gradient bằng 0 mãi mãi |
| lr 0,5, không chuẩn hóa | Loss ban đầu 2,366, sau 8 epoch vẫn 0,385, 14/16 ReLU chết | Cột kinh nghiệm shipper (0 tới 36) và giờ (7 tới 21) cho z rất lớn, cùng learning rate trở thành quá lớn |
Dòng cuối là lời nhắc từ Chương 3: chuẩn hóa không chỉ quan trọng cho khoảng cách, mà cho cả việc mạng có học được hay không.
Bước 3: một đặc trưng tốt so với một mạng.
base = ["distance_km", "items", "is_rain", "is_express", "shipper_exp_months"]
for name, cols in [("raw hour", base + ["hour"]), ("is_peak flag", base + ["is_peak"])]:
X = orders[cols].to_numpy(dtype=np.float64)
mean, std = X[:40000].mean(axis=0), X[:40000].std(axis=0)
Z = (X - mean) / std
model = LogisticRegression(max_iter=1000).fit(Z[:40000], y[:40000])
p = model.predict_proba(Z[40000:])[:, 1]
print(f"logistic + {name:13} test loss {log_loss(y[40000:], p):.4f} AUC {roc_auc_score(y[40000:], p):.4f}")
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, scikit-learn 1.9, CPU x86_64, ngày 04/11/2026:
logistic + raw hour test loss 0.2597 AUC 0.9336
logistic + is_peak flag test loss 0.2308 AUC 0.9487
Thêm một cột is_peak lấy từ hiểu biết nghiệp vụ, logistic regression đạt loss 0,2308 và AUC 0,9487, tốt hơn hẳn mạng 6-16-1 với giờ thô (0,2523 và 0,9395). Mạng phải tự học hai đỉnh từ 40.000 đơn có nhiễu; đặc trưng tốt đưa thẳng đáp án cho mô hình. Trên dữ liệu bảng nhỏ, đó thường là cách rẻ và chắc hơn. Mạng nơ-ron thực sự cần thiết khi quy luật quá nhiều để viết tay, như với văn bản ở mô-đun 02.
Áp dụng vào Giao Hàng Anh Tí
Minh trình bày cho Bình ba kết luận, mỗi kết luận kèm số đã chạy. Thứ nhất, mạng 6-16-1 viết bằng NumPy chạy được, học được và thắng logistic regression trên giờ thô (0,2523 so với 0,2597), nhờ học được hai giờ cao điểm. Thứ hai, logistic regression có cột is_peak còn tốt hơn (0,2308), nên bản đưa vào vận hành tuần này là mô hình đơn giản đó. Thứ ba, mạng vẫn có giá trị: khi Giao Hàng Anh Tí mở thêm quận mới mà chưa ai biết giờ cao điểm ở đó, mạng tự tìm ra, còn logistic regression cần người viết đặc trưng mới.
Lab của chương yêu cầu bạn tự viết lại toàn bộ vòng lặp này thành một module có test và so sánh với logistic regression.
Hiểu lầm thường gặp
Hiểu lầm 1: "Mạng nơ-ron luôn tốt hơn mô hình tuyến tính." Trên dữ liệu này, logistic regression với một đặc trưng đúng thắng mạng rõ rệt. Mạng chỉ thắng khi được cho cùng đặc trưng thô, và chỉ khoảng 2,9%.
Hiểu lầm 2: "Learning rate lớn thì học nhanh hơn." Với lr 50, loss sau epoch 1 cao gấp gần 4 lần lúc chưa học và 13/16 nơ-ron chết vĩnh viễn. Learning rate có một khoảng hợp lý; ngoài khoảng đó, lớn hơn là tệ hơn.
Hiểu lầm 3: "Loss huấn luyện thấp là đủ để kết luận mô hình tốt." Ở epoch 30, loss huấn luyện 0,2048 nhưng loss kiểm tra 0,2523, và loss kiểm tra đã tăng từ 0,2499 ở epoch 20 trong khi loss huấn luyện gần như không đổi. Chỉ loss trên dữ liệu mô hình chưa thấy mới nói mô hình tốt hơn hay không; Chương 11 dùng đúng điều này để dừng sớm.
| Triệu chứng | Nguyên nhân | Cách kiểm chứng | Cách sửa |
|---|---|---|---|
| Loss sau epoch 1 cao hơn lúc chưa học, nhảy loạn | Learning rate quá lớn | Chạy lại với lr nhỏ hơn 10 lần, so đường loss | Giảm lr; Chương 11 có lịch giảm lr tự động |
| Loss giảm rất chậm, đều đều | Learning rate quá nhỏ | Đường loss vẫn dốc ở epoch cuối | Tăng lr hoặc tăng số epoch |
| Nhiều nơ-ron ReLU luôn ra 0 | lr quá lớn hoặc đặc trưng chưa chuẩn hóa | Đếm cột của h có giá trị lớn nhất bằng 0 | Chuẩn hóa đặc trưng, giảm lr, khởi tạo lại |
| Loss kiểm tra cao hơn hẳn loss huấn luyện | Phân phối dữ liệu đổi theo thời gian, hoặc overfitting | So tỉ lệ trễ, tỉ lệ mưa giữa hai tập | Cập nhật dữ liệu huấn luyện, regularization (Chương 11) |
Tự kiểm tra (có đáp án)
Câu 1 (giải thích). Vì sao phải xáo thứ tự đơn trước mỗi epoch với dữ liệu này?
Đáp án: đơn được sắp theo thời gian, và tháng sau mưa nhiều hơn tháng trước. Không xáo thì các batch cuối epoch toàn đơn mùa mưa, mỗi epoch kết thúc bằng một chuỗi update kéo mạng về phía đơn mưa. Xáo làm mỗi batch là một mẫu đại diện cho cả tập.
Câu 2 (dự đoán). Với 10.000 đơn và batch 64, một epoch có bao nhiêu bước update?
Đáp án: 157 bước: 156 batch đủ 64 đơn và một batch cuối 16 đơn, vì range(0, 10000, 64) có 157 điểm bắt đầu.
Câu 3 (vận dụng). Đường loss kiểm tra tốt nhất ở epoch 20 (0,2499) rồi tăng ở epoch 30 (0,2523). Bạn sẽ chọn trọng số nào để đưa vào vận hành và cần lưu ý gì?
Đáp án: trọng số ở epoch có loss thấp nhất trên một tập kiểm định tách riêng, không phải tập kiểm tra cuối cùng; nếu chọn bằng chính tập kiểm tra thì con số báo cáo sẽ lạc quan. Cần chia thêm tập validation theo thời gian như Chương 9, và lưu trọng số mỗi khi loss validation tốt hơn.
Tự tay làm
Trong train_late_mlp.py, gọi train ba lần với seed bằng 0, 1, 2 và in loss kiểm tra của mỗi lần. Trước khi chạy, dự đoán: ba con số có giống nhau không, và có lần nào thua logistic regression (0,2597) không?
Kết quả mong đợi để tự kiểm: ba con số khác nhau ở chữ số thứ hai hoặc thứ ba sau dấu phẩy, vì seed đổi cả trọng số ban đầu lẫn thứ tự xáo. Ghi lại khoảng dao động và viết một câu: chênh lệch giữa mạng và logistic regression có lớn hơn khoảng dao động do seed không. Nếu không, kết luận "mạng tốt hơn" chưa đủ chắc chắn; đây là cùng tinh thần với khoảng tin cậy ở Chương 4.
Điều cần nhớ
- Huấn luyện là vòng lặp forward, loss, backward, update trên từng mini-batch; một epoch là một lượt qua toàn bộ dữ liệu, có xáo thứ tự.
- Learning rate có khoảng hợp lý: quá nhỏ thì chậm, quá lớn thì loss tăng và ReLU chết. Đặc trưng chưa chuẩn hóa làm cùng một learning rate trở thành quá lớn.
- Mạng 6-16-1 thắng logistic regression trên giờ thô nhờ học được hai giờ cao điểm, nhưng một đặc trưng
is_peakcho mô hình tuyến tính thắng mạng. Luôn so với baseline mạnh nhất. - Đọc loss kiểm tra, không chỉ loss huấn luyện. Chương tiếp theo làm vòng lặp này ổn định hơn: optimizer SGD có quán tính và Adam, regularization, khởi tạo và lịch learning rate.
Nguồn tham khảo
- Stanford CS231n, Neural Networks Part 3 (vòng lặp huấn luyện, chọn learning rate, đọc đường loss): https://cs231n.github.io/neural-networks-3/
- PyTorch,
torch.optim.SGD: https://docs.pytorch.org/docs/stable/generated/torch.optim.SGD.html - scikit-learn,
LogisticRegression: https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html - He và cộng sự (2015), Delving Deep into Rectifiers (khởi tạo theo căn bậc hai của 2 chia số đầu vào): https://arxiv.org/abs/1502.01852
- AI Engineering from Scratch (MIT), phase 03, Mini framework: https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/phases/03-deep-learning-core/10-mini-framework