Một mạng nơ-ron chạy được lần đầu không có nghĩa là bạn đã biết huấn luyện deep learning cho ổn định. Chỉ cần đổi learning rate từ 0,001 sang 0,1, cùng mô hình, cùng dữ liệu, độ chính xác có thể rơi từ 96% xuống 62%. Đổi hạt giống ngẫu nhiên là kết quả nhích vài phần trăm. Mạng sâu một chút mà khởi tạo hơi lớn là loss thành NaN. Bài này đi qua bốn đòn bẩy quyết định việc huấn luyện deep learning có ổn định hay không: learning rate, optimizer, lịch giảm learning rate (schedule) và regularization, cộng thêm gradient clipping và early stopping. Mỗi khẳng định đều có số đo thật, chạy 5 hạt giống và báo cáo trung bình ± độ lệch chuẩn.
Bài nối tiếp PyTorch cơ bản: tensor, autograd và vòng lặp huấn luyện (vòng lặp zero_grad, backward, step) và Mạng nơ-ron từ con số 0: forward, backpropagation và hàm kích hoạt. Phần nền về gradient descent nằm ở Hồi quy tuyến tính và gradient descent: tự cài đặt bằng NumPy; cách đọc các chỉ số trên tập kiểm tra xem ở Đánh giá mô hình phân loại: precision, recall, ROC và dữ liệu lệch. Môi trường: laptop Apple Silicon, 16 GB RAM, Python 3.12, PyTorch, scikit-learn và NumPy, chạy trên CPU. Các API dùng trong bài (AdamW, OneCycleLR, LambdaLR, clip_grad_norm_) đều đã có từ các bản PyTorch 1.x và giữ nguyên cách dùng trong dòng 2.x.
Thiết lập thí nghiệm huấn luyện deep learning có thể lặp lại
Muốn so sánh các cách huấn luyện deep learning thì phải loại nhiễu trước. Ta dùng tập chữ số viết tay 8x8 của scikit-learn (1.797 ảnh, 10 lớp), lấy 600 ảnh để huấn luyện mô hình, 598 để validation và 599 để kiểm tra cuối cùng. Tập kiểm tra không được dùng để chọn bất kỳ siêu tham số nào trong bài. Mô hình là MLP hai lớp ẩn 256 nơ-ron; mỗi cấu hình chạy với hạt giống 0 tới 4, mỗi lần reset random, NumPy và PyTorch.
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
# xáo trộn dữ liệu bằng generator riêng để thứ tự mini-batch lặp lại được
g = torch.Generator().manual_seed(seed)
perm = torch.randperm(len(X_tr), generator=g)train/val/test = [600, 598, 599]Với 598 mẫu validation, một mẫu đổi nhãn làm accuracy thay đổi khoảng 0,17 điểm phần trăm. Vì vậy, khi hai cấu hình chỉ hơn kém nhau dưới một độ lệch chuẩn, ta coi là hòa, không kết luận. Đây là kỷ luật cơ bản khi huấn luyện deep learning: báo cáo một lần chạy duy nhất là báo cáo nhiễu.
Learning rate trong huấn luyện deep learning: siêu tham số quan trọng nhất
Learning rate (lr) là độ dài mỗi bước cập nhật trọng số. Trong huấn luyện deep learning, lr quá nhỏ thì loss giảm chậm đến mức trông như không học; quá lớn thì bước nhảy vượt qua đáy thung lũng và loss dao động hoặc phát tán. Ta quét lr theo lũy thừa của 10 với Adam, 30 epoch:
lr=1e-05 val_acc=0.666 ± 0.054 val_loss=2.173 ± 0.005
lr=0.0001 val_acc=0.916 ± 0.002 val_loss=0.397 ± 0.003
lr=0.001 val_acc=0.963 ± 0.002 val_loss=0.146 ± 0.005
lr=0.01 val_acc=0.961 ± 0.009 val_loss=0.182 ± 0.057
lr=0.1 val_acc=0.618 ± 0.175 val_loss=1.230 ± 0.276Trong huấn luyện deep learning, hình dạng "chữ U ngược" này gặp ở hầu hết bài toán: có một dải lr rộng (ở đây từ 1e-3 tới 1e-2) cho kết quả gần nhau, hai bên là hai kiểu hỏng khác nhau. Ở 1e-5, val_loss 2,173 gần bằng ln(10) = 2,303, tức mô hình gần như chưa rời khỏi đoán đều 10 lớp. Ở 0,1, độ lệch chuẩn nhảy lên 0,175: kết quả phụ thuộc vào hạt giống, dấu hiệu rõ của huấn luyện deep learning mất ổn định. Quy trình thực tế khi huấn luyện deep learning: chạy vài chục bước ở nhiều lr, chọn lr lớn nhất mà loss vẫn giảm mượt, rồi lùi lại một chút. Không có lr "đúng" cho mọi mô hình, vì giá trị tối ưu phụ thuộc optimizer, kích thước batch và kiến trúc.
Optimizer cho huấn luyện deep learning: SGD, momentum, Adam và AdamW
SGD cập nhật w -= lr * grad trên mỗi mini-batch. Momentum cộng dồn một trung bình trượt của gradient, giúp đi xuyên qua vùng phẳng và giảm dao động. Adam (Kingma và Ba, ICLR 2015, bài báo gốc trên arXiv) ước lượng cả moment bậc nhất và bậc hai của gradient để mỗi tham số có bước đi riêng, nên ít nhạy với tỉ lệ gradient hơn SGD. So sánh trong cùng điều kiện (30 epoch):
SGD lr=0.01 val_acc=0.780 ± 0.025 val_loss=1.795 ± 0.035
SGD lr=0.1 val_acc=0.946 ± 0.004 val_loss=0.192 ± 0.006
SGD+momentum 0.9 lr=0.05 val_acc=0.966 ± 0.002 val_loss=0.157 ± 0.005
Adam lr=1e-3 val_acc=0.963 ± 0.002 val_loss=0.146 ± 0.005
AdamW lr=1e-3 wd=0.01 val_acc=0.963 ± 0.003 val_loss=0.146 ± 0.006Hai bài học rút ra cho việc huấn luyện deep learning. Thứ nhất, SGD thuần rất nhạy lr: từ 0,01 lên 0,1 accuracy tăng từ 0,780 lên 0,946 với cùng số epoch, tức "SGD kém" thường chỉ là "lr chưa đúng". Thứ hai, ở bài toán nhỏ này, SGD có momentum, Adam và AdamW cho kết quả nằm trong cùng một khoảng nhiễu (0,963 tới 0,966). Đừng suy ra "optimizer nào cũng như nhau" cho mô hình lớn; hãy suy ra rằng chênh lệch giữa các optimizer tốt thường nhỏ hơn chênh lệch do chọn lr sai.
AdamW: weight decay tách khỏi gradient
Trong torch.optim.Adam, tham số weight_decay cộng wd * w vào gradient (tức L2 regularization) rồi mới đưa qua phép chuẩn hoá thích nghi của Adam. Loshchilov và Hutter chỉ ra trong Decoupled Weight Decay Regularization rằng với optimizer thích nghi, L2 và weight decay không tương đương. AdamW sửa bằng cách co trọng số trực tiếp: w <- w * (1 - lr * wd), không đi qua gradient. Ta kiểm chứng bằng một loss không có gradient, chỉ còn phần decay:
Adam (L2) wd=0.01 w sau 100 bước: 0.90174
Adam (L2) wd=0.1 w sau 100 bước: 0.90174
AdamW (decoupled) wd=0.01 w sau 100 bước: 0.99900
AdamW (decoupled) wd=0.1 w sau 100 bước: 0.99005
công thức AdamW: (1 - 1e-3*0.01)^100 = 0.99900; (1 - 1e-3*0.1)^100 = 0.99005Kết quả thú vị: với Adam, wd=0.01 và wd=0.1 cho cùng một đáp số 0,90174. Gradient chỉ gồm số hạng L2, mà Adam chia gradient cho căn bậc hai của moment bậc hai, nên độ lớn của wd bị triệt tiêu, mỗi bước luôn đi khoảng lr. Ở AdamW, trọng số co đúng theo công thức (1 - lr*wd)^bước và wd thực sự điều khiển cường độ. Đây là lý do AdamW là lựa chọn mặc định khi cần weight decay với mô hình lớn. Lưu ý torch.optim.AdamW có weight_decay=0.01 mặc định, còn Adam mặc định 0.
Learning rate schedule trong huấn luyện deep learning: warmup và cosine
Giữ lr cố định trong huấn luyện deep learning là đơn giản nhưng lãng phí: đầu quá trình, bước lớn giúp tiến nhanh; cuối quá trình, bước nhỏ giúp hội tụ vào đáy. Hai thành phần phổ biến là warmup (tăng lr từ nhỏ trong vài epoch đầu, tránh các bước cập nhật phá hỏng trọng số khi moment của Adam còn chưa ổn định) và cosine annealing (giảm lr theo nửa đường cosine về 0, ý tưởng từ SGDR của Loshchilov và Hutter). Ta cài hệ số nhân bằng LambdaLR, kèm so sánh với OneCycleLR có sẵn của PyTorch:
def warmup_cosine(step, total, warmup):
if step < warmup:
return (step + 1) / warmup
p = (step - warmup) / max(1, total - warmup)
return 0.5 * (1 + math.cos(math.pi * p))
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: warmup_cosine(s, total, warmup=3 * steps_per_epoch))
# trong vòng lặp: opt.step() rồi mới sched.step(), mỗi mini-batch một lầnThứ tự optimizer.step() trước scheduler.step() là quy ước của PyTorch; đảo ngược sẽ bỏ qua giá trị lr đầu tiên và PyTorch có cảnh báo. Giá trị lr ghi lại sau epoch 1, 11, 21, 31, 41, 51 (từ lr_samples_output.txt) cho thấy hình dạng ba lịch:
{"const": [0.003, 0.003, 0.003, 0.003, 0.003, 0.003],
"cosine": [0.00105, 0.00286, 0.00232, 0.00154, 0.00075, 0.00018],
"onecycle": [0.00014, 0.00206, 0.00296, 0.00234, 0.00127, 0.00032]}Kết quả sau 60 epoch, 5 hạt giống:
hằng số val_acc=0.964 ± 0.002 val_loss=0.158 ± 0.004
warmup + cosine val_acc=0.965 ± 0.004 val_loss=0.143 ± 0.005
OneCycleLR val_acc=0.962 ± 0.002 val_loss=0.143 ± 0.005Accuracy hòa (0,962 tới 0,965), nhưng val_loss giảm từ 0,158 xuống 0,143 với cả hai lịch giảm lr, vượt mức nhiễu (độ lệch chuẩn 0,004 tới 0,005). Giải thích: khi lr về gần 0 cuối quá trình, trọng số ngừng nhảy quanh đáy nên xác suất đầu ra ổn định hơn. Với mô hình nhỏ này lợi ích chưa lớn; với mạng sâu và batch lớn, warmup còn quyết định chuyện mô hình có học được hay không.
Khi huấn luyện deep learning bằng OneCycleLR, lưu ý lịch này khác bản tự cài ở hai điểm: mặc định nó cũng chu kỳ hoá momentum (với Adam là beta1, đổi giữa 0,85 và 0,95 ngược chiều với lr, tham số cycle_momentum) và chỉ dùng pha tăng lr dài (mặc định 30% số bước) rồi giảm cosine, nên không phải là warmup 3 epoch như ở trên. Ngoài hai lịch trên, StepLR, ReduceLROnPlateau (giảm lr khi val_loss không cải thiện) và CosineAnnealingWarmRestarts cũng có sẵn trong torch.optim.lr_scheduler; xem tài liệu torch.optim.
Regularization khi huấn luyện deep learning: dropout, weight decay và overfitting
Để thấy overfitting trong huấn luyện deep learning, ta tăng sức chứa mô hình (512 nơ-ron mỗi lớp) và chạy 100 epoch với AdamW lr=1e-3. Mô hình đạt train accuracy 1,000 ở mọi cấu hình, nên cái cần so sánh là val_loss, tức mức tự tin sai ở các mẫu chưa thấy:
không regularization train_acc=1.000 ± 0.000 val_acc=0.962 ± 0.001 val_loss cuối=0.152 ± 0.007 val_loss min=0.128 ± 0.006
weight decay 0.1 train_acc=1.000 ± 0.000 val_acc=0.964 ± 0.003 val_loss cuối=0.132 ± 0.005 val_loss min=0.126 ± 0.005
dropout 0.3 train_acc=1.000 ± 0.000 val_acc=0.972 ± 0.005 val_loss cuối=0.128 ± 0.014 val_loss min=0.110 ± 0.003
dropout 0.3 + wd 0.1 train_acc=1.000 ± 0.000 val_acc=0.971 ± 0.003 val_loss cuối=0.117 ± 0.009 val_loss min=0.103 ± 0.004Hai cột cuối nói lên điều quan trọng cho huấn luyện deep learning: ở cấu hình không regularization, val_loss thấp nhất là 0,128 nhưng cuối quá trình là 0,152, tức mô hình đã tốt hơn ở giữa quá trình rồi xấu đi do overfitting. Dropout 0,3 (tắt ngẫu nhiên 30% nơ-ron khi train, và model.eval() tắt nó khi đánh giá) kéo val_acc lên 0,972; kết hợp weight decay 0,1 cho val_loss cuối thấp nhất (0,117). Với huấn luyện deep learning ở quy mô này, mức cải thiện accuracy là khoảng một điểm phần trăm, vừa đủ lớn hơn độ lệch chuẩn, nhưng vẫn là một bộ dữ liệu nhỏ: đừng chép con số, hãy chép phương pháp.
Early stopping: dừng khi validation không còn cải thiện
Trong huấn luyện deep learning, cách rẻ nhất để lấy lại điểm "tốt nhất ở giữa quá trình" là early stopping: theo dõi val_loss, nếu sau patience epoch không cải thiện thì dừng và nạp lại trọng số tốt nhất.
if vl < best - 1e-4:
best, bad = vl, 0
best_state = {k: v.clone() for k, v in model.state_dict().items()}
else:
bad += 1
if bad >= patience:
break
model.load_state_dict(best_state)chạy đủ 100 epoch : val_acc=0.962 ± 0.001 val_loss=0.152 ± 0.007
early stopping : val_acc=0.969 ± 0.002 val_loss=0.128 ± 0.007
epoch tốt nhất / epoch dừng theo seed: [(17, 25), (19, 27), (20, 28), (25, 33), (18, 26)]Với patience=8, quá trình học dừng ở epoch 25 tới 33 thay vì 100, tiết kiệm khoảng 70% thời gian mà val_loss tốt hơn (0,128 so với 0,152). Chú ý tinh tế: ta chọn epoch theo tập validation, nên con số val_acc của early stopping có chút lạc quan; số cuối cùng phải báo cáo trên tập kiểm tra, mà ta chưa chạm tới. Ta chạm vào nó đúng một lần trong toàn bộ quá trình huấn luyện deep learning của bài, cho cấu hình cuối được chọn từ các mục trên (AdamW lr=1e-3, weight decay 0,1, dropout 0,3, warmup cộng cosine, early stopping), chạy bằng final_test.py với 5 hạt giống:
AdamW lr=1e-3 wd=0.1 dropout=0.3 hidden=512, warmup+cosine, early stopping patience=8
val_acc = 0.970 ± 0.002
test_acc = 0.971 ± 0.002 (599 mẫu, chạy một lần)
test_loss= 0.130 ± 0.006
epoch tốt nhất theo seed: [24, 47, 45, 44, 26]Accuracy trên test (0,971) sát với validation (0,970), nên việc chọn cấu hình trên validation không làm số liệu lạc quan đáng kể ở bài toán nhỏ này. Con số này chỉ là một lần đo của quy trình huấn luyện deep learning, không dùng để chỉnh thêm bất kỳ siêu tham số nào.
Gradient clipping: giữ huấn luyện deep learning không bùng nổ
Trong huấn luyện deep learning, gradient clipping giới hạn độ lớn tổng của gradient trước khi cập nhật. torch.nn.utils.clip_grad_norm_(parameters, max_norm) tính chuẩn L2 của toàn bộ gradient, nhân tất cả với max_norm / chuẩn nếu chuẩn vượt max_norm, và trả về chuẩn trước khi cắt, rất tiện để ghi log. Hàm này phải được gọi sau backward() và trước step(). Để tạo bất ổn có kiểm soát, ta dùng MLP 8 lớp với trọng số khởi tạo theo std = gain / sqrt(fan_in) và SGD lr=0,05. Với ReLU, khởi tạo He (Kaiming) dùng gain = sqrt(2) ≈ 1,41 (xem torch.nn.init.calculate_gain), nên gain 1,5 gần như là mức chuẩn, còn gain 2,0 là quá lớn:
gn = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()gain=1.5 lr=0.05 clip=None: val_acc cuối=0.954 ± 0.008 loss=NaN: 0/5 gradient norm lớn nhất=16.1
gain=1.5 lr=0.05 clip=1.0: val_acc cuối=0.919 ± 0.015 loss=NaN: 0/5 gradient norm lớn nhất=16.2
gain=2.0 lr=0.05 clip=None: val_acc cuối=0.432 ± 0.406 loss=NaN: 3/5 gradient norm lớn nhất=inf
gain=2.0 lr=0.05 clip=1.0: val_acc cuối=0.877 ± 0.011 loss=NaN: 0/5 gradient norm lớn nhất=241Kết quả có hai mặt trong huấn luyện deep learning, và cả hai đều đáng ghi nhớ. Khi quá trình học vốn ổn định (gain 1,5, sát mức He), cắt gradient ở 1,0 làm hại: accuracy tụt từ 0,954 xuống 0,919 vì gradient bình thường đã lớn hơn 1, nên mọi bước đều bị co lại. Khi khởi tạo quá lớn (gain 2,0), không clipping khiến 3/5 lần chạy ra NaN (chuẩn gradient lên tới vô cùng), còn có clipping thì cả 5 lần đều hội tụ về 0,877 ± 0,011. Kết luận thực hành: clipping là lưới an toàn chứ không phải thuốc bổ. Hãy ghi log chuẩn gradient trước, chọn max_norm cao hơn chuẩn điển hình (ví dụ phân vị 90 đến 99) và chỉ bật khi thấy đột biến.
Cách các framework lớn đặt mặc định
| Thành phần | Mặc định phổ biến | Ghi chú |
|---|---|---|
| Optimizer | AdamW, lr 1e-4 tới 1e-3 | Rất phổ biến cho Transformer và fine-tuning; SGD+momentum vẫn phổ biến cho CNN thị giác |
| Weight decay | 0,01 tới 0,1 với AdamW | Thường không áp dụng cho bias và tham số chuẩn hoá |
| Schedule | Warmup tuyến tính + cosine hoặc tuyến tính về 0 | Warmup vài trăm tới vài nghìn bước với mô hình lớn |
| Gradient clipping | max_norm=1.0 | Thường gặp khi huấn luyện deep learning với Transformer; phải kiểm tra theo log chuẩn gradient |
| Early stopping | patience 3 tới 10 epoch | Chọn theo val_loss thay vì val_acc vì mượt hơn |
Các giá trị mặc định trên là điểm xuất phát được cộng đồng dùng rộng rãi, không phải quy tắc. Chúng ta tự rút ra kết luận nào cũng phải bằng thí nghiệm trên dữ liệu của mình.
Khi nào không nên làm phức tạp việc huấn luyện deep learning
- Dữ liệu dạng bảng: gradient boosting thường thắng mạng nơ-ron mà không cần chỉnh nhiều thứ trong bài này (xem Gradient boosting với XGBoost và LightGBM: khi nào thắng deep learning).
- Dữ liệu quá ít: thay vì chỉnh lr cho một mô hình train từ đầu, dùng mô hình đã huấn luyện sẵn (bài tiếp theo).
- Chưa có baseline: nếu chưa so với hồi quy logistic hoặc mô hình đơn giản, mọi tinh chỉnh optimizer đều chưa có ý nghĩa.
Lỗi thường gặp khi huấn luyện deep learning
- Quên
model.train()vàmodel.eval(): dropout và batch norm hành xử khác nhau giữa hai chế độ; quêneval()khi đánh giá làm val_loss nhảy lung tung. - Gọi
scheduler.step()sai chu kỳ: lịch tính theo mini-batch nhưng gọi theo epoch (hoặc ngược lại) làm lr đi sai hình dạng; hãy inopt.param_groups[0]["lr"]. - Chọn siêu tham số trên tập kiểm tra: dẫn tới số liệu cuối quá lạc quan. Chọn trên validation, báo cáo trên test một lần.
- Chỉ chạy một hạt giống khi huấn luyện deep learning: khác biệt 0,3 điểm phần trăm thường là nhiễu; ít nhất 3 tới 5 hạt giống.
- Đặt
weight_decaytrong Adam như trong AdamW: hai thứ khác nhau như đã đo ở trên. - Clipping cắt quá sớm:
max_normthấp hơn chuẩn gradient điển hình làm chậm huấn luyện như kết quả gain 1,5. - Không ghi log chuẩn gradient và lr: không có số đó thì không chẩn đoán được vì sao loss phát tán.
Checklist huấn luyện deep learning ổn định
- Khi huấn luyện deep learning, cố định hạt giống, chạy ≥ 3 hạt giống, báo cáo trung bình ± độ lệch chuẩn.
- Quét lr theo lũy thừa của 10 trước, rồi mới tinh chỉnh các thứ khác.
- Bắt đầu với AdamW; thử SGD+momentum nếu là CNN thị giác.
- Dùng warmup + cosine; ghi lại lr thật ở mỗi epoch.
- Theo dõi val_loss (không chỉ val_acc); dùng early stopping và lưu trọng số tốt nhất.
- Thêm dropout và weight decay khi train accuracy chạm 100% mà val_loss bắt đầu tăng.
- Ghi log chuẩn gradient; chỉ bật clipping khi thấy đột biến.
Toàn bộ mã nằm trong code/s7-22 (train_lib.py, experiments.py, adam_vs_adamw.py, final_test.py, các file output và requirements.txt, không ghim phiên bản); chạy bằng python experiments.py. Lần chạy ghi lại mất 221 giây trên CPU laptop 16 GB trong lúc máy còn chạy các tác vụ khác, bộ nhớ đỉnh khoảng 340 MB (số đo bằng /usr/bin/time -l nằm trong time_experiments.txt). Mọi con số huấn luyện deep learning trong bài đều là output thật từ lần chạy này. Dữ liệu là chữ số viết tay nhỏ, nên đừng mang các con số tuyệt đối sang bài toán khác.
Bài tiếp theo
Phân loại ảnh với CNN và transfer learning trong PyTorch. Khi dữ liệu chỉ có vài trăm ảnh, việc huấn luyện deep learning từ đầu thất bại dù đã chỉnh mọi thứ trong bài này. Bài sau dùng ResNet18 đã huấn luyện trên ImageNet và đo chênh lệch giữa đóng băng, fine-tune và học từ đầu.



