Bỏ qua để đến nội dung

Các thuật toán tối ưu nâng cao: SGD, Momentum, RMSProp, Adam

Sau bài này, bạn sẽ:

  • Nhận ra ba điểm yếu của gradient descent thuần: bò chậm trong thung lũng hẹp, lắc theo nhiễu của mini-batch, và chỉ có một tốc độ học cho mọi tham số.
  • Viết được quy tắc cập nhật của Momentum, AdaGrad, RMSProp và Adam, và nói được mỗi ký hiệu trong đó làm gì.
  • Giải thích được vì sao Adam cần hiệu chỉnh độ lệch (bias correction), bằng một phép tính ở bước đầu tiên.
  • Chọn được bộ tối ưu (optimizer), tốc độ học và lịch tốc độ học (learning rate schedule) hợp lý khi bắt đầu một bài toán.

Bạn thả một viên bi vào một con mương dài, hai thành rất dốc, đáy nghiêng nhẹ về một đầu. Viên bi không lăn thẳng xuống đầu thấp. Nó lăn qua lại giữa hai thành mương vài lần, rồi mới trôi dọc theo đáy. Nhưng khác GD, viên bi có quán tính: càng lăn lâu theo một hướng, nó càng nhanh.

GD thì không có quán tính. Mỗi bước, nó chỉ nhìn độ dốc ngay dưới chân. Bài này dùng một con mương đồ chơi để thấy rõ điều đó:

L(w1,w2)=12(w12+100 w22)\mathcal{L}(w_1, w_2) = \frac{1}{2}\left(w_1^2 + 100\,w_2^2\right)

Đáy ở (0,0)(0, 0). Theo w1w_1 độ cong là 1, theo w2w_2 là 100, nên κ=100\kappa = 100: thung lũng dài gấp 10 lần bề ngang (κ=10\sqrt{\kappa} = 10). Xuất phát ở (−8,1.5)(-8, 1.5).

Thứ nhất, thung lũng hẹp. Thành dốc theo w2w_2 ép η<2/100=0.02\eta < 2/100 = 0.02. Lấy η=0.018\eta = 0.018, sát ngưỡng. Theo w2w_2, khoảng cách tới đáy bị nhân với 1−0.018×100=−0.81 - 0.018 \times 100 = -0.8 mỗi bước: w2w_2 nhảy qua lại hai bên đáy (zigzag). Theo w1w_1, nó bị nhân với 1−0.018×1=0.9821 - 0.018 \times 1 = 0.982: mỗi bước chỉ gần thêm 1.8%. GD cần 369 bước để vào trong bán kính 0.01 quanh đáy, gần như toàn bộ là bò dọc thung lũng.

Thứ hai, nhiễu của mini-batch. Với mini-batch, gradient mỗi bước là ước lượng trên vài chục mẫu, nên hướng đi lệch qua lệch lại. Bài Gradient Descent đã thấy SGD với η\eta cố định cứ nhấp nhô quanh đáy thay vì dừng hẳn.

Thứ ba, một tốc độ học cho mọi tham số. Ở điểm xuất phát, gradient là [−8,150]⊤[-8, 150]^\top: theo w2w_2 lớn gấp gần 19 lần theo w1w_1. Cùng một η\eta, tham số có gradient lớn bước dài, tham số có gradient nhỏ bước ngắn, dù chính tham số đó mới cần đi xa (từ −8-8 về 0). Mạng nơ-ron có hàng triệu tham số, thang đo gradient của chúng chênh nhau rất xa.

  1. Nhớ hướng đã đi. Cộng dồn các gradient cũ thành một “vận tốc”. Hướng nào gradient cứ cùng dấu thì vận tốc lớn dần (dọc thung lũng). Hướng nào gradient đổi dấu liên tục thì các phần cộng vào triệt tiêu nhau (ngang thung lũng). Đó là Momentum.
  2. Mỗi tham số một tốc độ học. Chia bước của mỗi tham số cho độ lớn điển hình của gradient của chính nó. Tham số có gradient lớn được hãm lại, tham số có gradient nhỏ được đẩy lên. Đó là AdaGrad và RMSProp.

Adam ghép cả hai ý. Bài toán của bài này là: sửa quy tắc cập nhật của GD để nó đi nhanh dọc thung lũng hẹp, bớt lắc theo nhiễu, và không phải chọn lại η\eta mỗi khi thang đo của gradient thay đổi.

Mọi thuật toán trong bài đều là GD có sửa đổi, nên dùng chung ký hiệu. Bài này đánh số bước từ 1, vì Adam cần t≥1t \geq 1:

gt=∇θL(θt−1),t=1,2,3,…\mathbf{g}_t = \nabla_{\boldsymbol{\theta}}\mathcal{L}\left(\boldsymbol{\theta}_{t-1}\right), \qquad t = 1, 2, 3, \dots

gt\mathbf{g}_t là gradient ở bước tt, tính tại tham số hiện có θt−1\boldsymbol{\theta}_{t-1}; với mini-batch thì tính trên nhóm mẫu của bước đó. GD thường viết lại thành θt=θt−1−η gt\boldsymbol{\theta}_t = \boldsymbol{\theta}_{t-1} - \eta\,\mathbf{g}_t. Các bộ tối ưu dưới đây giữ thêm vài vectơ cùng kích thước với θ\boldsymbol{\theta}, gọi là trạng thái bộ tối ưu (optimizer state), tất cả bắt đầu bằng 0\mathbf{0}.

Từ mục RMSProp trở đi, các phép bình phương, căn và chia giữa hai vectơ đều làm trên từng phần tử: gt2\mathbf{g}_t^2 là vectơ các gj2g_j^2, chứ không phải tích vô hướng.

Momentum (quán tính) do Polyak đề xuất năm 1964, còn gọi là phương pháp “viên bi nặng” (heavy ball). Nó giữ một vectơ vận tốc (velocity) v\mathbf{v}:

vt=β vt−1+gtθt=θt−1−η vt\begin{aligned} \mathbf{v}_t &= \beta\,\mathbf{v}_{t-1} + \mathbf{g}_t \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} - \eta\,\mathbf{v}_t \end{aligned}

β∈[0,1)\beta \in [0, 1) là hệ số momentum, thường lấy β=0.9\beta = 0.9. Dòng đầu: giữ lại phần β\beta của vận tốc cũ, cộng thêm gradient mới. Dòng hai: đi theo vận tốc thay vì theo gradient. Với β=0\beta = 0, vt=gt\mathbf{v}_t = \mathbf{g}_t và ta có lại GD.

Vận tốc nhớ những gì? Thay vt−1=β vt−2+gt−1\mathbf{v}_{t-1} = \beta\,\mathbf{v}_{t-2} + \mathbf{g}_{t-1} vào dòng đầu, rồi thay tiếp vt−2\mathbf{v}_{t-2}, và cứ thế tới v0=0\mathbf{v}_0 = \mathbf{0}:

vt=gt+β gt−1+β2 gt−2+⋯+βt−1 g1\mathbf{v}_t = \mathbf{g}_t + \beta\,\mathbf{g}_{t-1} + \beta^2\,\mathbf{g}_{t-2} + \dots + \beta^{t-1}\,\mathbf{g}_1

Vận tốc là tổng mọi gradient đã gặp, gradient càng cũ thì trọng số càng nhỏ, giảm theo cấp số nhân. Hai trường hợp cho thấy vì sao cách này hợp với thung lũng hẹp:

  • Gradient cùng dấu mãi (dọc thung lũng), gt=g\mathbf{g}_t = \mathbf{g}: tổng là g(1+β+β2+… )\mathbf{g}\left(1 + \beta + \beta^2 + \dots\right), tiến tới g/(1−β)\mathbf{g}/(1 - \beta). Với β=0.9\beta = 0.9, bước dài gấp 10 lần GD.
  • Gradient đổi dấu mỗi bước (ngang thung lũng), g,−g,g,…\mathbf{g}, -\mathbf{g}, \mathbf{g}, \dots: tổng là g(1−β+β2−… )\mathbf{g}\left(1 - \beta + \beta^2 - \dots\right), tiến tới g/(1+β)≈0.53 g\mathbf{g}/(1 + \beta) \approx 0.53\,\mathbf{g}. Các phần cộng vào triệt tiêu một nửa.

Trên con mương đồ chơi, cùng η=0.018\eta = 0.018, Momentum vào trong bán kính 0.01 của đáy sau 111 bước, thay vì 369. Cái giá là quán tính: viên bi lao dọc thung lũng, vượt qua đáy tới w1≈2.19w_1 \approx 2.19 rồi mới quay lại. Theo chiều hẹp nó cũng vẫn lắc qua lại, và với β\beta lớn thì dao động này tắt chậm hơn ở GD. Cả hai điều đều thấy ở hình của mục Minh họa.

Nhiều tài liệu viết vt=β vt−1+(1−β) gt\mathbf{v}_t = \beta\,\mathbf{v}_{t-1} + (1 - \beta)\,\mathbf{g}_t. Đây là trung bình động mũ (exponential moving average, EMA) của gradient: các trọng số (1−β)βk(1 - \beta)\beta^k cộng lại gần bằng 1, và “cửa sổ nhớ” dài cỡ 1/(1−β)1/(1 - \beta) bước, tức khoảng 10 bước với β=0.9\beta = 0.9. Vectơ đó bằng đúng (1−β)(1 - \beta) lần vectơ vt\mathbf{v}_t ở trên, nên hai cách viết cho cùng đường đi nếu η\eta của cách thứ hai lớn gấp 1/(1−β)1/(1 - \beta). Khi đọc code hay paper, hãy xem họ dùng cách nào trước khi chép η\eta.

Nesterov momentum sửa một chi tiết: đo gradient ở chỗ quán tính sắp đưa tới, thay vì ở chỗ đang đứng:

vt=β vt−1+∇θL(θt−1−ηβ vt−1)\mathbf{v}_t = \beta\,\mathbf{v}_{t-1} + \nabla_{\boldsymbol{\theta}}\mathcal{L}\left(\boldsymbol{\theta}_{t-1} - \eta\beta\,\mathbf{v}_{t-1}\right)

Nếu quán tính sắp đưa viên bi qua đáy, gradient đo ở phía bên kia đã chỉ ngược lại và hãm bớt, nên đỡ vượt quá. Sutskever và cộng sự (2013) viết Nesterov dưới dạng này và dùng nó để huấn luyện mạng sâu. MLPClassifier của scikit-learn bật nó sẵn (nesterovs_momentum=True).

AdaGrad và RMSProp: mỗi tham số một tốc độ học

Phần tiêu đề “AdaGrad và RMSProp: mỗi tham số một tốc độ học”

AdaGrad (Duchi, Hazan, Singer, 2011) cộng dồn bình phương gradient của từng tham số, rồi chia bước cho căn của tổng đó:

st=st−1+gt2θt=θt−1−ηst+ε gt\begin{aligned} \mathbf{s}_t &= \mathbf{s}_{t-1} + \mathbf{g}_t^2 \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} - \frac{\eta}{\sqrt{\mathbf{s}_t} + \varepsilon}\,\mathbf{g}_t \end{aligned}

ε\varepsilon là một số rất nhỏ, ví dụ 10−810^{-8}, chỉ để không chia cho 0. Tham số jj có tốc độ học riêng η/(sj+ε)\eta/\left(\sqrt{s_j} + \varepsilon\right): gradient của nó từng lớn thì tốc độ học nhỏ, và ngược lại. AdaGrad hợp với dữ liệu thưa, nơi có đặc trưng hiếm khi xuất hiện. Nhưng st\mathbf{s}_t chỉ tăng, không bao giờ giảm, nên huấn luyện lâu thì mọi tốc độ học co dần về 0 và mô hình ngừng học, dù chưa tới đáy.

RMSProp (Hinton, bài giảng 6e của khóa Neural Networks for Machine Learning, 2012) thay tổng bằng trung bình động mũ, để gradient cũ bị quên dần:

st=ρ st−1+(1−ρ) gt2θt=θt−1−ηst+ε gt\begin{aligned} \mathbf{s}_t &= \rho\,\mathbf{s}_{t-1} + (1 - \rho)\,\mathbf{g}_t^2 \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} - \frac{\eta}{\sqrt{\mathbf{s}_t} + \varepsilon}\,\mathbf{g}_t \end{aligned}

ρ\rho thường là 0.9 hoặc 0.99 (PyTorch mặc định 0.99). st\sqrt{\mathbf{s}_t} là căn của trung bình bình phương (root mean square, RMS) các gradient gần đây, nên mới có tên RMSProp. Nếu gradient của một tham số giữ độ lớn cỡ ∣g∣\lvert g\rvert, thì s≈∣g∣\sqrt{s} \approx \lvert g\rvert và bước của nó là khoảng η g/∣g∣\eta\,g/\lvert g\rvert: dài cỡ η\eta, bất kể gradient lớn hay nhỏ. Ở điểm xuất phát của con mương, gradient theo w2w_2 lớn gấp gần 19 lần theo w1w_1, nhưng hai bước của RMSProp dài ngang nhau.

Hệ quả quan trọng: nhân hàm mất mát với một hằng số kk thì gradient nhân kk, st\sqrt{\mathbf{s}_t} cũng nhân kk, và bước không đổi (bỏ qua ε\varepsilon). GD thì bước nhân kk, và ngưỡng 2/L2/L chia kk.

Có một điểm lạ ở bước đầu. s0=0\mathbf{s}_0 = \mathbf{0}, nên s1=(1−ρ) g12\mathbf{s}_1 = (1 - \rho)\,\mathbf{g}_1^2 và s1=1−ρ ∣g1∣\sqrt{\mathbf{s}_1} = \sqrt{1 - \rho}\,\lvert\mathbf{g}_1\rvert. Với ρ=0.99\rho = 0.99, căn này chỉ bằng 0.1 ∣g1∣0.1\,\lvert\mathbf{g}_1\rvert, nên bước đầu dài gấp 10 lần η\eta. Trên con mương với η=0.1\eta = 0.1, RMSProp nhảy một phát từ (−8,1.5)(-8, 1.5) tới (−7,0.5)(-7, 0.5). Adam sửa đúng chỗ này.

Adam (adaptive moment estimation, Kingma và Ba, 2015) giữ hai trung bình động mũ: của gradient (như Momentum) và của bình phương gradient (như RMSProp):

vt=β1 vt−1+(1−β1) gtst=β2 st−1+(1−β2) gt2\begin{aligned} \mathbf{v}_t &= \beta_1\,\mathbf{v}_{t-1} + (1 - \beta_1)\,\mathbf{g}_t \\ \mathbf{s}_t &= \beta_2\,\mathbf{s}_{t-1} + (1 - \beta_2)\,\mathbf{g}_t^2 \end{aligned}

vt\mathbf{v}_t ước lượng trung bình của gradient, gọi là moment bậc một (first moment). st\mathbf{s}_t ước lượng trung bình của bình phương gradient, gọi là moment bậc hai (second moment). Hai vectơ này là trạng thái bộ tối ưu của Adam, nên Adam tốn thêm bộ nhớ gấp đôi số tham số. Rồi hiệu chỉnh độ lệch và cập nhật:

v^t=vt1−β1t,s^t=st1−β2tθt=θt−1−η v^ts^t+ε\begin{aligned} \hat{\mathbf{v}}_t &= \frac{\mathbf{v}_t}{1 - \beta_1^t}, \qquad \hat{\mathbf{s}}_t = \frac{\mathbf{s}_t}{1 - \beta_2^t} \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} - \eta\,\frac{\hat{\mathbf{v}}_t}{\sqrt{\hat{\mathbf{s}}_t} + \varepsilon} \end{aligned}

β1t\beta_1^t là β1\beta_1 mũ tt (số bước), không phải chỉ số. Giá trị mặc định trong bài báo gốc, PyTorch và scikit-learn: η=0.001\eta = 0.001, β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999, ε=10−8\varepsilon = 10^{-8}.

Vì sao cần hiệu chỉnh độ lệch. Cả hai trung bình bắt đầu từ 0\mathbf{0}, nên lúc đầu chúng bị kéo về 0. Xét bước 1 trên con mương, g1=[−8,150]⊤\mathbf{g}_1 = [-8, 150]^\top:

v1=0.1 g1=[−0.8, 15]⊤s1=0.001 g12=[0.064, 22.5]⊤,s1≈[0.253, 4.743]⊤\begin{aligned} \mathbf{v}_1 &= 0.1\,\mathbf{g}_1 = [-0.8,\ 15]^\top \\ \mathbf{s}_1 &= 0.001\,\mathbf{g}_1^2 = [0.064,\ 22.5]^\top, \qquad \sqrt{\mathbf{s}_1} \approx [0.253,\ 4.743]^\top \end{aligned}

v1\mathbf{v}_1 chỉ bằng 10% gradient thật, còn s1\sqrt{\mathbf{s}_1} chỉ bằng 0.001≈3.2%\sqrt{0.001} \approx 3.2\% độ lớn thật. Nếu không hiệu chỉnh, tỉ số v1/s1≈[−3.16, 3.16]⊤\mathbf{v}_1/\sqrt{\mathbf{s}_1} \approx [-3.16,\ 3.16]^\top: mẫu số bị kéo về 0 mạnh hơn tử số, nên bước đầu dài gấp 0.1/0.001≈3.160.1/\sqrt{0.001} \approx 3.16 lần η\eta. Sau hiệu chỉnh, v^1=v1/0.1=g1\hat{\mathbf{v}}_1 = \mathbf{v}_1/0.1 = \mathbf{g}_1 và s^1=s1/0.001=g12\hat{\mathbf{s}}_1 = \mathbf{s}_1/0.001 = \mathbf{g}_1^2, nên tỉ số là [−1,1]⊤[-1, 1]^\top: mỗi tham số đi đúng η\eta. Với η=0.1\eta = 0.1, Adam đi từ (−8,1.5)(-8, 1.5) tới (−7.9,1.4)(-7.9, 1.4).

Vì sao chia cho 1−βt1 - \beta^t. Giả sử gradient không đổi, gt=g\mathbf{g}_t = \mathbf{g}. Khai triển như ở mục Momentum:

vt=(1−β1)(1+β1+⋯+β1t−1)g=(1−β1) 1−β1t1−β1 g=(1−β1t)g\begin{aligned} \mathbf{v}_t &= (1 - \beta_1)\left(1 + \beta_1 + \dots + \beta_1^{t-1}\right)\mathbf{g} \\ &= (1 - \beta_1)\,\frac{1 - \beta_1^t}{1 - \beta_1}\,\mathbf{g} = \left(1 - \beta_1^t\right)\mathbf{g} \end{aligned}

Dòng thứ hai dùng công thức tổng cấp số nhân 1+β+⋯+βt−1=(1−βt)/(1−β)1 + \beta + \dots + \beta^{t-1} = (1 - \beta^t)/(1 - \beta). Vậy vt\mathbf{v}_t chỉ bằng 1−β1t1 - \beta_1^t lần giá trị đúng, và chia cho đúng số đó là trả nó về g\mathbf{g}. Với β1=0.9\beta_1 = 0.9, độ lệch gần hết sau vài chục bước. Với β2=0.999\beta_2 = 0.999 thì rất chậm: 1−0.999100≈0.0951 - 0.999^{100} \approx 0.095, nên sau 100 bước, st\mathbf{s}_t chưa hiệu chỉnh vẫn chỉ bằng khoảng 10% giá trị đúng. Nếu gradient giữ nguyên, tỉ số chưa hiệu chỉnh là (1−β1t)/1−β2t\left(1 - \beta_1^t\right)/\sqrt{1 - \beta_2^t} lần η\eta: 3.16 ở bước 1, lên tới khoảng 6.5 quanh bước 10, còn 3.24 ở bước 100 và khoảng 2 ở bước 290. Không có hiệu chỉnh, hàng trăm bước đầu đều quá dài.

Trên con mương, Adam với η=0.1\eta = 0.1 cần 193 bước: nhanh hơn GD (369), chậm hơn Momentum (111) và RMSProp (103). Adam không phải nhanh nhất ở mọi bài toán. Điểm mạnh của nó là bền: cùng một η\eta chạy được khi thang đo của gradient thay đổi, như ô code thứ nhất sẽ cho thấy.

Với mini-batch, gradient có nhiễu. Giữ η\eta cố định thì tham số cứ nhấp nhô quanh đáy với biên độ cỡ bước đi. Cách chữa là cho η\eta đổi theo số bước, gọi là lịch tốc độ học. Ba kiểu hay gặp:

  • Giảm theo bậc (step decay): nhân η\eta với 0.1 sau một số epoch cố định.
  • Giảm theo cosin (cosine decay): đi từ ηmax⁡\eta_{\max} xuống ηmin⁡\eta_{\min} theo nửa chu kỳ cosin, chậm ở đầu và cuối, nhanh ở giữa. Công thức ở ngay dưới.
  • Khởi động (warmup): vài trăm hoặc vài nghìn bước đầu, tăng η\eta tuyến tính từ gần 0 lên ηmax⁡\eta_{\max}, rồi mới giảm. Lúc đầu, tham số còn ngẫu nhiên và trạng thái bộ tối ưu chưa ổn định, bước lớn dễ làm hỏng việc học. Huấn luyện Transformer và LLM gần như luôn dùng warmup cộng giảm theo cosin.

Với TT là tổng số bước, giảm theo cosin là:

ηt=ηmin⁡+12(ηmax⁡−ηmin⁡)(1+cos⁡πtT)\eta_t = \eta_{\min} + \frac{1}{2}\left(\eta_{\max} - \eta_{\min}\right)\left(1 + \cos\frac{\pi t}{T}\right)

Ở t=0t = 0, cos⁡0=1\cos 0 = 1 nên ηt=ηmax⁡\eta_t = \eta_{\max}. Ở t=Tt = T, cos⁡π=−1\cos\pi = -1 nên ηt=ηmin⁡\eta_t = \eta_{\min}.

Tình huốngGợi ý bắt đầu
Mạng nơ-ron, bài toán mớiAdam, η=10−3\eta = 10^{-3}. Ít phải chỉnh nhất
Transformer, LLMAdamW, warmup rồi giảm theo cosin
Mạng tích chập cho ảnh, có thời gian chỉnhSGD + momentum 0.9 (thường kèm Nesterov), lịch giảm η\eta
Mô hình tuyến tính, dữ liệu nhỏKhông cần các bộ này: phương trình chuẩn, hoặc solver mặc định của scikit-learn

AdamW (Loshchilov và Hutter, 2019) là Adam có weight decay tách riêng. Nếu cộng phạt L2 λ∥θ∥2\lambda\lVert\boldsymbol{\theta}\rVert^2 của bài Ridge, Lasso, Elastic Net vào hàm mất mát, phần gradient của phạt cũng bị chia cho s^t\sqrt{\hat{\mathbf{s}}_t}, nên tham số có gradient lớn bị kéo về 0 ít hơn. AdamW trừ thẳng ηλθt−1\eta\lambda\boldsymbol{\theta}_{t-1} ở bước cập nhật, ngoài phân số của Adam, để mọi tham số bị kéo đều nhau.

  • Không có bộ tối ưu tốt nhất cho mọi bài. Trên con mương đồ chơi, Momentum và RMSProp đều nhanh hơn Adam. Wilson và cộng sự (2017) còn thấy các phương pháp thích nghi như Adam đôi khi cho mô hình tổng quát hóa kém hơn SGD có momentum được chỉnh kỹ.
  • Vẫn chỉ nhìn độ dốc. Cả bốn thuật toán chỉ dùng gradient. Chúng giảm bớt, chứ không xóa được, ảnh hưởng của số điều kiện; chuẩn hóa đặc trưng vẫn nên làm.
  • Không có đảm bảo trên hàm không lồi. Mạng nơ-ron có nhiều cực tiểu cục bộ và điểm yên ngựa. Bài này không chứng minh hội tụ; phần huấn luyện mạng nơ-ron học ở bài Huấn luyện mạng nơ-ron.

Hình dưới vẽ đường mức của con mương L=12(w12+100 w22)\mathcal{L} = \frac{1}{2}\left(w_1^2 + 100\,w_2^2\right) và 60 bước đầu của ba thuật toán, cùng xuất phát ở (−8,1.5)(-8, 1.5). Hãy nhìn vào ba kiểu đi: GD nhảy qua lại giữa hai thành rồi bò chậm dọc đáy, Momentum lao dọc thung lũng, vượt qua đáy rồi quay lại, còn Adam đi chéo xuống lòng thung lũng ngay từ đầu, gần như không zigzag.

Ô code thứ hai vẽ lại hình này với đủ bốn thuật toán, chạy 100 bước, kèm đường loss. Bạn có thể đổi η\eta và β\beta ở đó.

Ba ô dưới đây chạy độc lập. Ô thứ hai cần Matplotlib, ô thứ ba cần thêm scikit-learn, nên lần đầu chạy lâu hơn. Ô thứ ba huấn luyện ba mạng nơ-ron nhỏ, mất thêm vài giây.

Ô thứ nhất cài đặt bốn thuật toán bằng NumPy, chạy trên con mương từ (−8,1.5)(-8, 1.5), dừng khi khoảng cách tới đáy nhỏ hơn 0.01 hoặc sau 1000 bước. Lần chạy thứ hai nhân hàm mất mát với 10 (scale = 10), giữ nguyên mọi η\eta.

Bốn bộ tối ưu trên thung lũng hẹp

import numpy as np
c = np.array([1.0, 100.0]) # độ cong theo w1 và w2: thung lũng hẹp
def run(name, eta, scale=1.0, beta1=0.9, beta2=0.999, rho=0.99, eps=1e-8):
w = np.array([-8.0, 1.5]) # điểm xuất phát
v, s = np.zeros(2), np.zeros(2) # moment 1 và moment 2
for t in range(1, 1001):
g = scale * c * w # gradient của scale * 0.5 * (w1^2 + 100 w2^2)
if name == "GD":
w = w - eta * g
elif name == "Momentum":
v = beta1 * v + g # vận tốc: cộng dồn gradient cũ
w = w - eta * v
elif name == "RMSProp":
s = rho * s + (1 - rho) * g**2 # trung bình bình phương
w = w - eta * g / (np.sqrt(s) + eps)
else: # Adam
v = beta1 * v + (1 - beta1) * g
s = beta2 * s + (1 - beta2) * g**2
v_hat = v / (1 - beta1**t) # hiệu chỉnh độ lệch
s_hat = s / (1 - beta2**t)
w = w - eta * v_hat / (np.sqrt(s_hat) + eps)
dist = np.linalg.norm(w) # khoảng cách tới đáy (0, 0)
if dist < 0.01 or dist > 1e6:
break
status = "diverged" if dist > 1e6 else f"{t:4d} steps"
return status, scale * 0.5 * np.sum(c * w**2) # L lúc dừng
settings = [("GD", 0.018), ("Momentum", 0.018),
("RMSProp", 0.1), ("Adam", 0.1)]
for scale in [1.0, 10.0]: # scale = 10: thung lũng dốc gấp 10 lần
print(f"scale = {scale:.0f}")
for name, eta in settings:
status, loss = run(name, eta, scale)
print(f" {name:8s} eta = {eta}: {status}, L = {loss:.1e}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Với scale = 1, số bước khớp phần Lý thuyết: GD 369, Momentum 111, RMSProp 103, Adam 193. Cột L là giá trị mất mát lúc dừng, đều dưới 5×10−35 \times 10^{-3}. Với scale = 10, độ cong theo w2w_2 thành 1000 và ngưỡng của GD thành 2/1000=0.0022/1000 = 0.002, nên η=0.018\eta = 0.018 làm cả GD lẫn Momentum phân kỳ. RMSProp và Adam đi đúng số bước như cũ: gradient lớn gấp 10, mẫu số cũng lớn gấp 10, bước không đổi. L lúc dừng lớn gấp 10 chỉ vì chính hàm mất mát đã nhân 10. Hãy thử giảm beta1 của Momentum xuống 0.5 hoặc thay eta của Adam bằng 0.3.

Ô thứ hai vẽ đường đi 100 bước đầu trên đường mức (bên trái) và mất mát theo số bước, trục tung theo thang log (bên phải).

Đường đi trên đường mức

import matplotlib.pyplot as plt
import numpy as np
c = np.array([1.0, 100.0]) # L = 0.5 * (w1^2 + 100 w2^2), như ô trên
def path(name, eta, steps=100, beta1=0.9, beta2=0.999, rho=0.99):
w, v, s = np.array([-8.0, 1.5]), np.zeros(2), np.zeros(2)
points = [w]
for t in range(1, steps + 1):
g = c * w
if name == "GD":
step = g
elif name == "Momentum":
v = beta1 * v + g
step = v
elif name == "RMSProp":
s = rho * s + (1 - rho) * g**2
step = g / (np.sqrt(s) + 1e-8)
else: # Adam, có hiệu chỉnh độ lệch
v = beta1 * v + (1 - beta1) * g
s = beta2 * s + (1 - beta2) * g**2
step = (v / (1 - beta1**t)) / (np.sqrt(s / (1 - beta2**t)) + 1e-8)
w = w - eta * step
points.append(w)
return np.array(points)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
W1, W2 = np.meshgrid(np.linspace(-9, 3, 300), np.linspace(-2, 2, 300))
levels = np.geomspace(0.05, 150, 12) # các mức của L, cách đều theo log
ax1.contour(W1, W2, 0.5 * (W1**2 + 100 * W2**2), levels, colors="0.8")
for name, eta in [("GD", 0.018), ("Momentum", 0.018),
("RMSProp", 0.1), ("Adam", 0.1)]:
p = path(name, eta)
loss = 0.5 * (p[:, 0]**2 + 100 * p[:, 1]**2) # L tại từng bước
ax1.plot(p[:, 0], p[:, 1], ".-", ms=3, lw=1, label=name)
ax2.semilogy(loss, label=name)
print(f"{name:8s}: L after 100 steps = {loss[-1]:.1e}")
ax1.plot(0, 0, "kx", ms=10) # đáy
ax1.set(xlabel="w1", ylabel="w2", title="Paths, 100 steps")
ax2.set(xlabel="step", ylabel="loss", title="Loss (log scale)")
ax1.legend(loc="lower left")
plt.show()

Lần chạy đầu cần tải Python, khoảng 10 MB.

Sau 100 bước, mất mát của RMSProp là 7.1×10−57.1 \times 10^{-5}, Momentum 1.1×10−31.1 \times 10^{-3}, Adam 0.450.45, GD 0.850.85. Đường loss của Momentum có răng cưa: viên bi nhảy qua lại theo chiều hẹp, và mỗi lần nó lên gần thành mương thì loss tăng trở lại. Đường đỏ của Adam uốn mềm quanh trục w1w_1, khác hẳn kiểu zigzag của GD. Để ý cả bước đầu của RMSProp: một bước dài 1.0 thay vì 0.1, đúng như phép tính ở mục RMSProp.

Ô thứ ba chuyển sang dữ liệu thật: 1 797 ảnh chữ số viết tay 8×88 \times 8 của load_digits, 75% để huấn luyện. Mô hình là MLPClassifier, một mạng nơ-ron nhỏ với 32 nơ-ron ẩn, học ở bài Mạng nơ-ron nhân tạo. Ở đây chỉ cần coi nó là một mô hình có khoảng 2 400 tham số và hàm mất mát không lồi. Ba lần huấn luyện có cùng khởi tạo, cùng η=0.01\eta = 0.01, mini-batch 64 ảnh, 20 epoch; chỉ đổi bộ tối ưu. Loss là cross-entropy trung bình trên tập huấn luyện trong mỗi epoch.

SGD, Momentum và Adam trên load_digits

import warnings
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits
from sklearn.exceptions import ConvergenceWarning
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
# 20 epoch là cố ý ít, nên tắt cảnh báo "chưa hội tụ"
warnings.filterwarnings("ignore", category=ConvergenceWarning)
X, y = load_digits(return_X_y=True) # ảnh chữ số 8x8, điểm ảnh 0..16
X_train, X_test, y_train, y_test = train_test_split(
X / 16, y, test_size=0.25, random_state=0) # đưa điểm ảnh về [0, 1]
optimizers = {
"SGD": dict(solver="sgd", momentum=0.0),
"Momentum": dict(solver="sgd", momentum=0.9, nesterovs_momentum=False),
"Adam": dict(solver="adam"), # beta1 = 0.9, beta2 = 0.999 mặc định
}
for name, options in optimizers.items():
# cùng mạng, cùng điểm khởi tạo, cùng eta = 0.01, mini-batch 64 ảnh
model = MLPClassifier(hidden_layer_sizes=(32,), batch_size=64,
learning_rate_init=0.01, max_iter=20,
random_state=0, **options)
model.fit(X_train, y_train)
plt.plot(range(1, 21), model.loss_curve_, marker=".", label=name)
loss, acc = model.loss_curve_[-1], model.score(X_test, y_test)
print(f"{name:8s}: loss = {loss:.4f}, test acc = {acc:.3f}")
plt.xlabel("epoch")
plt.ylabel("training loss")
plt.yscale("log")
plt.legend()
plt.show()

Lần chạy đầu cần tải Python, khoảng 10 MB.

Sau 20 epoch: SGD còn loss 1.4786 và độ chính xác trên tập kiểm tra 0.680; Momentum 0.1815 và 0.947; Adam 0.0321 và 0.971. Chỗ gợn trên đường của Adam là nhiễu của mini-batch. Phần lớn lợi thế của Momentum ở đây đến từ bước dài gấp khoảng 10 lần: thử đổi learning_rate_init của SGD thành 0.1, loss sau 20 epoch là 0.1783, ngang Momentum với η=0.01\eta = 0.01. Còn Adam với η=0.1\eta = 0.1 lại kém hơn (0.9360.936 trên tập kiểm tra), vì bước dài cỡ η\eta cho mọi tham số thì 0.1 là quá lớn.

Câu 1/4 Bạn dùng Momentum với β=0.9\beta = 0.9. Trên một sườn dốc đều, gradient giữ nguyên bằng g\mathbf{g} qua rất nhiều bước. Về sau, mỗi bước θ\boldsymbol{\theta} dịch đi khoảng bao nhiêu?
Câu 2/4 Bạn dùng Adam (β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999, ε\varepsilon rất nhỏ, có hiệu chỉnh độ lệch). Ở bước đầu tiên, gradient theo θ1\theta_1 là 0.0020.002, theo θ2\theta_2 là 500500. Mỗi tham số dịch đi bao xa?
Câu 3/4 Bạn nhân hàm mất mát với 100 (ví dụ đổi đơn vị của yy) và giữ nguyên η\eta đang chạy tốt. Nhiều khả năng điều gì xảy ra với GD và với Adam?
Câu 4/4 Bạn huấn luyện rất lâu bằng AdaGrad. Loss còn cao, gradient chưa nhỏ, nhưng tham số gần như không nhúc nhích nữa. Lý do hợp lý nhất là gì?

Ý chính:

  • GD thuần bò chậm khi κ\kappa lớn, vì thành dốc ép η<2/L\eta < 2/L còn hướng phẳng cần bước dài. Trên con mương κ=100\kappa = 100, GD cần 369 bước.
  • Momentum: vt=βvt−1+gt\mathbf{v}_t = \beta\mathbf{v}_{t-1} + \mathbf{g}_t, θt=θt−1−ηvt\boldsymbol{\theta}_t = \boldsymbol{\theta}_{t-1} - \eta\mathbf{v}_t. Theo hướng nhất quán, bước dài gấp 1/(1−β)1/(1 - \beta); theo hướng đổi dấu, gradient triệt tiêu bớt. Cái giá là vượt qua đáy.
  • RMSProp chia bước của mỗi tham số cho căn trung bình động mũ của gt2\mathbf{g}_t^2, nên bước dài cỡ η\eta và không đổi khi nhân hàm mất mát với một hằng số. AdaGrad cộng dồn thay vì lấy trung bình, nên tốc độ học co mãi về 0.
  • Adam ghép hai trung bình động (β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999) và chia chúng cho 1−βt1 - \beta^t để bù việc bắt đầu từ 0; không có bước này, bước đầu dài gấp khoảng 3.16 lần. Mặc định nên thử: Adam (AdamW) với η=10−3\eta = 10^{-3}, cộng một lịch tốc độ học.

Đọc thêm:

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu