Skip to content

Huấn luyện mạng nơ-ron: vanishing/exploding gradient, BatchNorm, Dropout

This content is not available in your language yet.

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

  • Tự dẫn được lan truyền ngược (backpropagation) cho MLP 2 lớp ở dạng ma trận, với đại lượng δ[l]\boldsymbol{\delta}^{[l]}, và kiểm tra nó bằng kiểm tra gradient (gradient checking).
  • Giải thích được vì sao gradient tiêu biến (vanishing gradient) hoặc bùng nổ (exploding gradient) khi mạng sâu, và vì sao khởi tạo Xavier và He chữa được phần lớn chuyện này.
  • Hiểu BatchNorm và Dropout làm gì khi huấn luyện và khi dự đoán, và vì sao phải gọi model.eval().
  • Biết khi nào dùng weight decay, dừng sớm (early stopping) và cắt chuẩn gradient (gradient clipping).

Bạn đã biết lan truyền xuôi: đưa x\mathbf{x} vào, qua từng lớp, ra y^\hat{y}. Để học, gradient descent cần đạo hàm của hàm mất mát L\mathcal{L} theo mọi tham số. Một mạng nhỏ cho ảnh chữ số 8×8, ba lớp ẩn 256 nơ-ron, đã có khoảng 150 nghìn tham số.

Ở bài Gradient Descent, bạn đã ước lượng độ dốc bằng cách nhích một tham số một chút rồi tính lại mất mát. Với 150 nghìn tham số, mỗi bước cập nhật cần ít nhất 150 nghìn lượt lan truyền xuôi. Quá chậm. Ta cần một cách tính cả 150 nghìn đạo hàm với chi phí chỉ cỡ vài lượt lan truyền xuôi. Đó là lan truyền ngược.

Lan truyền ngược chuyền “lời trách” từ lớp ra về lớp vào: lớp ra biết mình sai bao nhiêu, nó báo cho lớp trước, lớp trước báo tiếp. Mỗi lần chuyền, tin bị nhân với một hệ số của lớp đó.

Giống trò chơi truyền tin qua 10 người. Nếu ai cũng chỉ nói to bằng một phần tư người trước, người cuối nghe được 0.2510≈10−60.25^{10} \approx 10^{-6} âm lượng ban đầu: gần như im lặng. Nếu ai cũng nói to gấp đôi, người cuối bị hét vào tai to gấp 210=10242^{10} = 1024 lần. Chỉ khi mỗi người giữ đúng âm lượng, tin mới đi xa được.

Với mạng sâu cũng vậy. Các lớp đầu nhận gradient rất nhỏ thì gần như đứng yên (tiêu biến), nhận gradient rất lớn thì nhảy lung tung tới mức tràn số (bùng nổ). Thêm nữa, mạng lớn đủ sức học thuộc tập train, nên còn phải chống overfitting.

Bài toán của bài này là: tính gradient của mọi tham số một cách hiệu quả, giữ cho tín hiệu gradient không tắt cũng không bùng lên khi đi qua nhiều lớp, và giữ cho mạng lớn không học thuộc dữ liệu.

Quy tắc hàm hợp trên một chuỗi phép tính

Phần tiêu đề “Quy tắc hàm hợp trên một chuỗi phép tính”

Bắt đầu với mạng chỉ có một nơ-ron mỗi lớp, mọi thứ là số vô hướng:

x  →  z1=w1x  →  a1=g(z1)  →  z2=w2a1  →  L(z2)x \;\to\; z_1 = w_1 x \;\to\; a_1 = g(z_1) \;\to\; z_2 = w_2 a_1 \;\to\; \mathcal{L}(z_2)

Muốn biết L\mathcal{L} đổi bao nhiêu khi w1w_1 nhích một chút, ta lần theo chuỗi: w1w_1 đổi làm z1z_1 đổi, z1z_1 làm a1a_1 đổi, a1a_1 làm z2z_2 đổi, z2z_2 làm L\mathcal{L} đổi. Quy tắc hàm hợp (chain rule, còn gọi quy tắc dây chuyền) nói tỉ lệ đổi của cả chuỗi là tích các tỉ lệ đổi của từng mắt xích:

∂L∂w1=∂L∂z2⋅∂z2∂a1⋅∂a1∂z1⋅∂z1∂w1=∂L∂z2⋅w2⋅g′(z1)⋅x\frac{\partial\mathcal{L}}{\partial w_1} = \frac{\partial\mathcal{L}}{\partial z_2}\cdot\frac{\partial z_2}{\partial a_1}\cdot\frac{\partial a_1}{\partial z_1}\cdot\frac{\partial z_1}{\partial w_1} = \frac{\partial\mathcal{L}}{\partial z_2}\cdot w_2 \cdot g'(z_1)\cdot x

Mỗi thừa số đọc ra từ đúng một mắt xích: z2=w2a1z_2 = w_2 a_1 nên ∂z2/∂a1=w2\partial z_2/\partial a_1 = w_2; a1=g(z1)a_1 = g(z_1) nên ∂a1/∂z1=g′(z1)\partial a_1/\partial z_1 = g'(z_1); z1=w1xz_1 = w_1 x nên ∂z1/∂w1=x\partial z_1/\partial w_1 = x.

Để ý: tính ∂L/∂w2=∂L∂z2⋅a1\partial\mathcal{L}/\partial w_2 = \frac{\partial\mathcal{L}}{\partial z_2}\cdot a_1 cũng cần ∂L∂z2\frac{\partial\mathcal{L}}{\partial z_2}. Phần đầu của chuỗi được dùng chung. Vì vậy ta tính từ cuối về đầu, lưu lại kết quả ở mỗi lớp, và lớp trước dùng lại kết quả của lớp sau. Đó là toàn bộ bí quyết của lan truyền ngược.

Giờ đến một MLP thật, với một mẫu (x,y)(\mathbf{x}, y), y∈{0,1}y \in \{0, 1\}:

z[1]=W[1]x+b[1],a[1]=g(z[1])z[2]=W[2]a[1]+b[2],a[2]=σ(z[2])=y^\begin{aligned} \mathbf{z}^{[1]} &= \mathbf{W}^{[1]}\mathbf{x} + \mathbf{b}^{[1]}, & \mathbf{a}^{[1]} &= g(\mathbf{z}^{[1]}) \\ z^{[2]} &= \mathbf{W}^{[2]}\mathbf{a}^{[1]} + b^{[2]}, & a^{[2]} &= \sigma(z^{[2]}) = \hat{y} \end{aligned}

x\mathbf{x} có nn phần tử, lớp ẩn có n[1]n^{[1]} nơ-ron, nên W[1]\mathbf{W}^{[1]} có kích thước n[1]×nn^{[1]} \times n. Lớp ra có một nơ-ron sigmoid, nên W[2]\mathbf{W}^{[2]} có kích thước 1×n[1]1 \times n^{[1]}. Hàm mất mát là cross-entropy nhị phân:

L=−[yln⁡a[2]+(1−y)ln⁡(1−a[2])]\mathcal{L} = -\left[y\ln a^{[2]} + (1 - y)\ln\left(1 - a^{[2]}\right)\right]

Đại lượng trung tâm là δ[l]=∂L/∂z[l]\boldsymbol{\delta}^{[l]} = \partial\mathcal{L}/\partial\mathbf{z}^{[l]}: mất mát nhạy thế nào với giá trị trước hàm kích hoạt của lớp ll. Nó là “lời trách” được chuyền ngược. δ[l]\boldsymbol{\delta}^{[l]} cùng kích thước với z[l]\mathbf{z}^{[l]}.

Bước 1: δ\delta ở lớp ra. Đạo hàm của L\mathcal{L} theo a[2]a^{[2]}, rồi quy đồng:

∂L∂a[2]=−ya[2]+1−y1−a[2]=a[2]−ya[2](1−a[2])\frac{\partial\mathcal{L}}{\partial a^{[2]}} = -\frac{y}{a^{[2]}} + \frac{1 - y}{1 - a^{[2]}} = \frac{a^{[2]} - y}{a^{[2]}\left(1 - a^{[2]}\right)}

Đạo hàm của sigmoid là σ′(z)=σ(z)(1−σ(z))\sigma'(z) = \sigma(z)\left(1 - \sigma(z)\right), nên ∂a[2]/∂z[2]=a[2](1−a[2])\partial a^{[2]}/\partial z^{[2]} = a^{[2]}\left(1 - a^{[2]}\right). Nhân hai thừa số, mẫu số triệt tiêu:

δ[2]=a[2]−y\delta^{[2]} = a^{[2]} - y

Gọn đến bất ngờ: dự đoán trừ nhãn. Sigmoid đi với cross-entropy (và softmax đi với cross-entropy nhiều lớp) đều cho kết quả này, và đó là một lý do chúng hay đi cặp.

Bước 2: gradient của lớp ra. Phần tử thứ jj của W[2]\mathbf{W}^{[2]} chỉ đi vào z[2]z^{[2]} qua số hạng Wj[2]aj[1]W^{[2]}_{j}a^{[1]}_j, nên ∂z[2]/∂Wj[2]=aj[1]\partial z^{[2]}/\partial W^{[2]}_j = a^{[1]}_j. Nhân với δ[2]\delta^{[2]} theo quy tắc hàm hợp, rồi xếp lại thành hàng:

∂L∂W[2]=δ[2] a[1]⊤,∂L∂b[2]=δ[2]\frac{\partial\mathcal{L}}{\partial\mathbf{W}^{[2]}} = \delta^{[2]}\,\mathbf{a}^{[1]\top}, \qquad \frac{\partial\mathcal{L}}{\partial b^{[2]}} = \delta^{[2]}

Bước 3: chuyền δ\delta về lớp ẩn. Nơ-ron ẩn jj ảnh hưởng tới L\mathcal{L} qua aj[1]a^{[1]}_j, mà aj[1]a^{[1]}_j đi vào z[2]z^{[2]} với hệ số Wj[2]W^{[2]}_j. Vậy ∂L/∂aj[1]=Wj[2] δ[2]\partial\mathcal{L}/\partial a^{[1]}_j = W^{[2]}_j\,\delta^{[2]}. Khi lớp sau có nhiều nơ-ron, aj[1]a^{[1]}_j đi vào tất cả, và ta cộng các đường lại: ∑iWij[2]δi[2]\sum_i W^{[2]}_{ij}\delta^{[2]}_i, chính là phần tử jj của W[2]⊤δ[2]\mathbf{W}^{[2]\top}\boldsymbol{\delta}^{[2]}. Cuối cùng qua hàm kích hoạt, nhân với g′(zj[1])g'(z^{[1]}_j):

δ[1]=(W[2]⊤δ[2])⊙g′ ⁣(z[1])\boldsymbol{\delta}^{[1]} = \left(\mathbf{W}^{[2]\top}\boldsymbol{\delta}^{[2]}\right) \odot g'\!\left(\mathbf{z}^{[1]}\right)

Dấu ⊙\odot là nhân từng phần tử, vì mỗi nơ-ron có hàm kích hoạt riêng của nó, không trộn với nơ-ron khác.

Bước 4: gradient của lớp ẩn. Lặp lại Bước 2, với x\mathbf{x} đóng vai a[1]\mathbf{a}^{[1]}:

∂L∂W[1]=δ[1] x⊤,∂L∂b[1]=δ[1]\frac{\partial\mathcal{L}}{\partial\mathbf{W}^{[1]}} = \boldsymbol{\delta}^{[1]}\,\mathbf{x}^\top, \qquad \frac{\partial\mathcal{L}}{\partial\mathbf{b}^{[1]}} = \boldsymbol{\delta}^{[1]}

δ[1]\boldsymbol{\delta}^{[1]} là cột n[1]n^{[1]} phần tử, x⊤\mathbf{x}^\top là hàng nn phần tử, nên tích là ma trận n[1]×nn^{[1]} \times n, đúng kích thước của W[1]\mathbf{W}^{[1]}. Kiểm kích thước như vậy là cách nhanh nhất để bắt lỗi chuyển vị.

Chi phí của lan truyền ngược chỉ cỡ hai lần lan truyền xuôi, bất kể có bao nhiêu tham số. Đổi lại, phải giữ mọi a[l]\mathbf{a}^{[l]} trong bộ nhớ cho tới lúc đi ngược; đó là lý do huấn luyện tốn bộ nhớ hơn dự đoán nhiều. PyTorch, TensorFlow, JAX làm việc này tự động (autograd), nhưng chúng làm đúng các bước trên.

Lan truyền ngược tự viết rất dễ sai một chỗ chuyển vị hay một thừa số g′g', mà mạng vẫn chạy, mất mát vẫn giảm chút ít. Cách bắt lỗi: tính lại từng đạo hàm bằng sai phân trung tâm (central difference), với một bước nhỏ hh:

∂L∂θk≈L(θk+h)−L(θk−h)2h\frac{\partial\mathcal{L}}{\partial\theta_k} \approx \frac{\mathcal{L}(\theta_k + h) - \mathcal{L}(\theta_k - h)}{2h}

Ở tử số, chỉ θk\theta_k được nhích, mọi tham số khác giữ nguyên. Sai phân trung tâm có sai số cỡ h2h^2, chính xác hơn nhiều so với sai phân một phía (L(θk+h)−L(θk))/h\left(\mathcal{L}(\theta_k + h) - \mathcal{L}(\theta_k)\right)/h (sai số cỡ hh). Với số thực 64 bit, h≈10−5h \approx 10^{-5} là hợp lý: nhỏ hơn nữa thì sai số làm tròn ở phép trừ lại lớn lên.

So gradient lan truyền ngược g\mathbf{g} với gradient sai phân gnum\mathbf{g}_{\text{num}} bằng sai số tương đối, để không phụ thuộc gradient lớn hay nhỏ:

sai soˆˊ=∥g−gnum∥∥g∥+∥gnum∥\text{sai số} = \frac{\lVert\mathbf{g} - \mathbf{g}_{\text{num}}\rVert}{\lVert\mathbf{g}\rVert + \lVert\mathbf{g}_{\text{num}}\rVert}

Cỡ 10−710^{-7} hoặc nhỏ hơn là đúng; cỡ 10−210^{-2} trở lên gần như chắc chắn có lỗi. Mỗi tham số cần hai lượt lan truyền xuôi, nên chỉ kiểm trên mạng nhỏ, vài mẫu, rồi tắt đi khi huấn luyện thật.

Viết công thức δ[l]\boldsymbol{\delta}^{[l]} lặp lại từ lớp ra về lớp 1. Mỗi lần lùi một lớp, δ\boldsymbol{\delta} bị nhân với W[l+1]⊤\mathbf{W}^{[l+1]\top} rồi với g′(z[l])g'(\mathbf{z}^{[l]}). Trở lại mạng một nơ-ron mỗi lớp cho dễ thấy:

δ[1]=δ[NL]∏l=2NLwl g′ ⁣(z[l−1])\delta^{[1]} = \delta^{[N_L]}\prod_{l=2}^{N_L} w_l\, g'\!\left(z^{[l-1]}\right)

Một tích gồm NL−1N_L - 1 thừa số. Nếu mỗi thừa số cỡ 0.250.25, mười lớp cho 0.259≈3.8×10−60.25^{9} \approx 3.8 \times 10^{-6}; nếu cỡ 2 thì 29=5122^{9} = 512. Tích của nhiều số hoặc co về 0, hoặc phình rất nhanh; chỉ khi các thừa số quanh 1 thì nó mới giữ được cỡ. Gradient của W[l]\mathbf{W}^{[l]} là δ[l]a[l−1]⊤\boldsymbol{\delta}^{[l]}\mathbf{a}^{[l-1]\top}, nên các lớp đầu chịu ảnh hưởng nặng nhất.

  • Tiêu biến: các lớp đầu gần như không học. Mất mát giảm chậm hoặc dừng hẳn, dù các lớp cuối vẫn thay đổi.
  • Bùng nổ: một bước cập nhật quá lớn đẩy tham số ra xa, mất mát nhảy vọt, rồi thành inf hoặc nan.

Vì sao sigmoid dễ gây tiêu biến. Đặt a=σ(z)∈(0,1)a = \sigma(z) \in (0, 1), thì σ′(z)=a(1−a)\sigma'(z) = a(1 - a). Đây là parabol theo aa, lớn nhất ở a=1/2a = 1/2 (tức z=0z = 0):

σ′(z)=a(1−a)≤12⋅12=14\sigma'(z) = a(1 - a) \le \frac{1}{2}\cdot\frac{1}{2} = \frac{1}{4}

Vậy mỗi lớp sigmoid co δ\delta ít nhất 4 lần, trừ khi trọng số lớn bù lại. Khi ∣z∣|z| lớn, sigmoid bão hòa (saturate): aa gần 0 hoặc 1, σ′\sigma' gần 0, còn tệ hơn. Tanh có tanh⁡′(0)=1\tanh'(0) = 1 nên đỡ hơn, nhưng cũng bão hòa. ReLU có đạo hàm đúng bằng 1 ở phần dương, nên tự nó không co tín hiệu; lúc này trọng số quyết định tất cả, tức là cách khởi tạo.

Không khởi tạo toàn số 0. Nếu mọi trọng số của một lớp bằng nhau, mọi nơ-ron của lớp đó tính ra cùng một giá trị, nhận cùng một δ\delta, nên được cập nhật y hệt nhau. Chúng mãi là bản sao của nhau, và lớp 256 nơ-ron chỉ làm việc của một nơ-ron. Khởi tạo ngẫu nhiên để phá vỡ đối xứng (symmetry breaking). Câu hỏi còn lại là ngẫu nhiên với độ lệch chuẩn bao nhiêu.

Giữ phương sai qua mỗi lớp. Xét zi=∑j=1ninWijajz_i = \sum_{j=1}^{n_{\text{in}}} W_{ij}a_j, với ninn_{\text{in}} là số nơ-ron vào. Giả sử các WijW_{ij} độc lập, trung bình 0, phương sai s2s^2, và độc lập với các aja_j. Mỗi số hạng có phương sai s2 E[aj2]s^2\,\mathbb{E}[a_j^2], và phương sai của tổng các số hạng độc lập là tổng các phương sai:

Var(zi)=nin s2 E ⁣[aj2]\mathrm{Var}(z_i) = n_{\text{in}}\, s^2\, \mathbb{E}\!\left[a_j^2\right]

Muốn tín hiệu không to lên cũng không nhỏ đi qua mỗi lớp, ta chọn s2s^2 để Var(z)\mathrm{Var}(z) ở lớp này bằng ở lớp trước.

  • Hàm kích hoạt gần tuyến tính quanh 0 (tanh): a≈za \approx z, nên E[a2]≈Var(ztrước)\mathbb{E}[a^2] \approx \mathrm{Var}(z_{\text{trước}}), cần nins2=1n_{\text{in}}s^2 = 1. Lượt lan truyền ngược nhân với W⊤\mathbf{W}^\top nên đòi nouts2=1n_{\text{out}}s^2 = 1. Khởi tạo Xavier (Glorot và Bengio, 2010) lấy trung bình hai đòi hỏi:
s2=2nin+nouts^2 = \frac{2}{n_{\text{in}} + n_{\text{out}}}
  • ReLU: với zz đối xứng quanh 0, ReLU đặt nửa số giá trị về 0, nên E[a2]=12Var(ztrước)\mathbb{E}[a^2] = \frac{1}{2}\mathrm{Var}(z_{\text{trước}}). Để bù nửa bị mất, khởi tạo He (He và cộng sự, 2015, còn gọi Kaiming) chọn:
s2=2nins^2 = \frac{2}{n_{\text{in}}}

Lấy lớp vuông 64 → 64 làm ví dụ. Mỗi lần lùi một lớp ReLU, phương sai của δ\delta bị nhân với n s2⋅12n\,s^2 \cdot \frac{1}{2}. Với s=1s = 1 là 64×1×12=3264 \times 1 \times \frac{1}{2} = 32, tức độ lệch chuẩn nhân 32≈5.7\sqrt{32} \approx 5.7 mỗi lớp: bùng nổ. Với He, 64×264×12=164 \times \frac{2}{64} \times \frac{1}{2} = 1: giữ nguyên. Ô code thứ hai sẽ đo đúng hai con số này.

Xavier dẫn ra cho hàm gần tuyến tính quanh 0, mà sigmoid quanh 0 có độ dốc chỉ 14\frac{1}{4}, nên Xavier với sigmoid vẫn tiêu biến. Đây là một lý do mạng sâu ngày nay dùng ReLU (hoặc GELU) cùng He. nn.Linear của PyTorch mặc định lấy trọng số đều trong [−1/nin, 1/nin]\left[-1/\sqrt{n_{\text{in}}},\, 1/\sqrt{n_{\text{in}}}\right], tức phương sai 1/(3nin)1/(3n_{\text{in}}), nhỏ hơn He; muốn đúng He thì gọi nn.init.kaiming_normal_.

Khởi tạo tốt chỉ giữ cỡ tín hiệu lúc đầu. Trong lúc huấn luyện, trọng số thay đổi, và phân phối của z[l]\mathbf{z}^{[l]} trôi đi. Chuẩn hóa theo batch (batch normalization, BatchNorm, BN; Ioffe và Szegedy, 2015) ép lại cỡ tín hiệu ở mỗi bước.

Xét một nơ-ron trong một mini-batch BB mẫu, với các giá trị z(1),…,z(B)z^{(1)}, \dots, z^{(B)}. BatchNorm tính trung bình và phương sai của batch, rồi chuẩn hóa:

μB=1B∑i=1Bz(i),σB2=1B∑i=1B(z(i)−μB)2z^(i)=z(i)−μBσB2+ϵ\begin{aligned} \mu_B &= \frac{1}{B}\sum_{i=1}^{B} z^{(i)}, \qquad \sigma_B^2 = \frac{1}{B}\sum_{i=1}^{B}\left(z^{(i)} - \mu_B\right)^2 \\ \hat{z}^{(i)} &= \frac{z^{(i)} - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} \end{aligned}

ϵ\epsilon là một số rất nhỏ (PyTorch mặc định 10−510^{-5}) để không chia cho 0. Sau bước này, các z^\hat{z} có trung bình 0, phương sai gần 1. Cuối cùng co giãn và dịch:

BN ⁣(z(i))=γ z^(i)+β\mathrm{BN}\!\left(z^{(i)}\right) = \gamma\,\hat{z}^{(i)} + \beta

Vì sao cần γ\gamma, β\beta. Ép mọi nơ-ron về trung bình 0, phương sai 1 có thể quá gò bó: một sigmoid sau đó sẽ chỉ làm việc ở vùng gần tuyến tính. γ\gamma và β\beta là tham số học được, mỗi nơ-ron một cặp, cho mạng tự chọn cỡ và vị trí phù hợp. Nếu cần, mạng có thể học γ=σB\gamma = \sigma_B, β=μB\beta = \mu_B để trả lại đúng zz ban đầu. Vì BN trừ đi trung bình, hệ số tự do b[l]\mathbf{b}^{[l]} của lớp tuyến tính đứng trước bị triệt tiêu, nên thường bỏ nó; β\beta làm thay việc đó.

BN thường đặt sau lớp tuyến tính và trước hàm kích hoạt, như bài báo gốc. Mỗi nơ-ron có μB\mu_B, σB\sigma_B, γ\gamma, β\beta riêng.

Khi huấn luyện và khi dự đoán. Lúc dự đoán, kết quả cho một ảnh không được phụ thuộc vào các ảnh khác tình cờ chung batch, và batch có thể chỉ có một ảnh. Vì vậy, trong lúc huấn luyện, BN giữ thêm trung bình động (running average) của μB\mu_B và σB2\sigma_B^2 qua các batch:

μrun←(1−α) μrun+α μB\mu_{\text{run}} \leftarrow (1 - \alpha)\,\mu_{\text{run}} + \alpha\,\mu_B

và tương tự cho phương sai. PyTorch gọi α\alpha là momentum, mặc định 0.10.1 (khác momentum của bộ tối ưu). Lúc dự đoán, BN dùng μrun\mu_{\text{run}}, σrun2\sigma^2_{\text{run}} thay cho thống kê của batch. Chuyển giữa hai chế độ là việc của model.train() và model.eval().

BN giúp gì. Mạng có BN chịu được tốc độ học lớn hơn, ít nhạy với khởi tạo, và hội tụ nhanh hơn. Thống kê của batch có nhiễu, nên BN còn có tác dụng điều chuẩn nhẹ. Bài báo gốc giải thích bằng việc giảm “internal covariate shift” (phân phối đầu vào của mỗi lớp trôi đi), nhưng Santurkar và cộng sự (2018) cho thấy lý do chính có lẽ là BN làm mặt mất mát trơn hơn. Điểm yếu: batch nhỏ thì μB\mu_B, σB\sigma_B nhiễu. Transformer vì vậy dùng chuẩn hóa lớp (layer normalization), chuẩn hóa trên các nơ-ron của một mẫu, xem bài Transformer.

Mạng lớn có thể “học thuộc” tập train, như đường bậc 15 trong bài Overfitting và underfitting. Một kiểu học thuộc đặc trưng của mạng nơ-ron: vài nơ-ron dựa vào nhau rất chặt, nơ-ron này chỉ có ích khi nơ-ron kia ở đúng chỗ (co-adaptation). Dropout (Srivastava và cộng sự, 2014) phá kiểu dựa dẫm này bằng một cách đơn giản đến lạ: ở mỗi bước huấn luyện, tắt ngẫu nhiên mỗi nơ-ron với xác suất pp.

Với một lớp có đầu ra a\mathbf{a}, lấy một mặt nạ r\mathbf{r} mà mỗi phần tử độc lập, bằng 1 với xác suất 1−p1 - p và bằng 0 với xác suất pp. Bản dùng phổ biến, inverted dropout, chia thêm cho 1−p1 - p:

a~=11−p r⊙a\tilde{\mathbf{a}} = \frac{1}{1 - p}\,\mathbf{r} \odot \mathbf{a}

Vì sao chia cho 1−p1 - p. Mỗi phần tử có kỳ vọng E[a~j]=(1−p)⋅aj1−p+p⋅0=aj\mathbb{E}[\tilde{a}_j] = (1 - p)\cdot\frac{a_j}{1 - p} + p \cdot 0 = a_j. Lớp sau nhận tín hiệu cùng cỡ trung bình như khi không có dropout. Nhờ vậy, lúc dự đoán, Dropout không làm gì cả: giữ nguyên a\mathbf{a}, không tắt nơ-ron nào, không nhân gì. Bài báo gốc làm ngược lại (giữ nguyên lúc huấn luyện, nhân trọng số với 1−p1 - p lúc dự đoán); hai cách cho cùng kỳ vọng.

Khi lan truyền ngược, nơ-ron bị tắt có δ\delta bằng 0, nên trọng số nối vào nó không đổi ở bước đó. Mỗi bước huấn luyện vì vậy là huấn luyện một mạng con ngẫu nhiên, và mạng lúc dự đoán giống như trung bình của rất nhiều mạng con. Giá trị hay dùng: p=0.5p = 0.5 cho lớp ẩn của MLP lớn (như bài báo gốc), pp khoảng 0.10.1 trong Transformer.

Weight decay, dừng sớm, cắt chuẩn gradient

Phần tiêu đề “Weight decay, dừng sớm, cắt chuẩn gradient”

Ba kỹ thuật ngắn nhưng gần như mạng nào cũng dùng.

Weight decay. Thêm phạt L2 λ∥w∥22\lambda\lVert\mathbf{w}\rVert_2^2 như Ridge ở bài Ridge, Lasso, Elastic Net. Gradient của khoản phạt là 2λw2\lambda\mathbf{w}, nên bước gradient descent thành:

w←w−η(∇L+2λw)=(1−2ηλ) w−η∇L\mathbf{w} \leftarrow \mathbf{w} - \eta\left(\nabla\mathcal{L} + 2\lambda\mathbf{w}\right) = (1 - 2\eta\lambda)\,\mathbf{w} - \eta\nabla\mathcal{L}

Vế phải cho thấy tên gọi: trước mỗi bước, trọng số bị co lại theo một tỉ lệ cố định, “phân rã” dần về 0 nếu dữ liệu không kéo nó ra. Tham số weight_decay của các bộ tối ưu trong PyTorch cộng thẳng wd⋅w\text{wd}\cdot\mathbf{w} vào gradient, tức wd=2λ\text{wd} = 2\lambda. Với Adam, cộng vào gradient không còn tương đương với co trọng số, vì Adam chia lại gradient; AdamW (Loshchilov và Hutter, 2019) tách phần co trọng số ra riêng. Thường không phạt b\mathbf{b}, γ\gamma, β\beta.

Dừng sớm (early stopping). Sau mỗi epoch, đo mất mát trên tập kiểm định (validation set). Mất mát kiểm định thường giảm rồi tăng lại khi mạng bắt đầu học thuộc. Lưu lại tham số ở epoch tốt nhất; nếu sau một số epoch (gọi là độ kiên nhẫn, patience) vẫn không tốt hơn thì dừng, và dùng bản đã lưu.

Cắt chuẩn gradient (gradient clipping). Chặn bùng nổ trực tiếp: nếu chuẩn của cả vectơ gradient g\mathbf{g} vượt ngưỡng cc, co nó lại:

g←c g∥g∥khi ∥g∥>c\mathbf{g} \leftarrow c\,\frac{\mathbf{g}}{\lVert\mathbf{g}\rVert} \quad \text{khi } \lVert\mathbf{g}\rVert > c

Hướng giữ nguyên, chỉ độ dài bị giới hạn, nên một batch xấu không thể đẩy tham số đi quá xa. Trong PyTorch là torch.nn.utils.clip_grad_norm_(model.parameters(), c), gọi sau backward() và trước step(). Kỹ thuật này đặc biệt quan trọng với mạng hồi tiếp (Pascanu và cộng sự, 2013), xem bài Mạng nơ-ron hồi tiếp (RNN).

  • Khởi tạo tốt và BN chưa đủ cho mạng rất sâu (hàng trăm lớp). Kết nối tắt (residual connection) của ResNet cho gradient một đường đi thẳng, xem bài Các kiến trúc CNN hiện đại.
  • Bài này không bàn chọn bộ tối ưu và lịch tốc độ học. Momentum, Adam, warmup ở bài Các thuật toán tối ưu nâng cao.
  • Phân tích phương sai dựa trên giả định độc lập. Nó cho đúng cỡ lúc khởi tạo, nhưng không đảm bảo gì sau khi huấn luyện đã chạy.

Minh họa dưới đây dựng một MLP sâu với dữ liệu ngẫu nhiên và vẽ, mỗi lớp một cột, độ lớn gradient của lớp đó, từ lớp đầu (trái) tới lớp cuối (phải). Trục đứng theo thang log: hai cột cách nhau một khoảng bằng nhau trên trục nghĩa là gấp nhau cùng một số lần, nên bạn đọc được “nhỏ đi bao nhiêu lần mỗi lớp”. Bạn chọn hàm kích hoạt (sigmoid, tanh, ReLU), cách khởi tạo (độ lệch chuẩn 1, Xavier, He) và số lớp (2 tới 20). Hãy thử:

  1. Sigmoid + Xavier, 10 lớp: các cột thấp dần rõ rệt về phía trái. Mỗi lần lùi một lớp, gradient nhỏ đi vài lần, đúng như giới hạn σ′≤14\sigma' \le \frac{1}{4}. Kéo số lớp lên 20: các cột đầu gần như biến mất. Đó là gradient tiêu biến.
  2. ReLU + độ lệch chuẩn 1: ngược lại, các cột vọt lên rất cao, hơn cấu hình ổn định nhiều bậc độ lớn, và càng nhiều lớp càng cao. Đó là gradient bùng nổ, do mỗi lớp nhân phương sai lên khoảng n/2n/2 lần.
  3. ReLU + He: các cột gần như bằng nhau, dù tăng lên 20 lớp. Tín hiệu đi qua mỗi lớp mà giữ được cỡ.
  4. Đổi sang tanh, giữ Xavier: các cột đều hơn hẳn so với sigmoid ở bước 1, vì tanh⁡′(0)=1\tanh'(0) = 1 chứ không phải 14\frac{1}{4}. Đây đúng là trường hợp Xavier được thiết kế cho.

Hai ô đầu chạy ngay trên trang, chỉ dùng NumPy; ô nào cũng tự tạo dữ liệu. Ô thứ ba dùng PyTorch, nên chạy trên máy bạn.

Ô đầu tiên cài đặt đúng bốn bước lan truyền ngược của mục Lý thuyết cho MLP 2 lớp (3 đầu vào, 4 nơ-ron ẩn sigmoid, 1 nơ-ron ra sigmoid), trên 5 mẫu ngẫu nhiên, rồi so với gradient sai phân trung tâm, h=10−5h = 10^{-5}.

Lan truyền ngược và kiểm tra gradient

import numpy as np
rng = np.random.default_rng(0)
m, n, n1 = 5, 3, 4 # số mẫu, số đặc trưng, số nơ-ron lớp ẩn
X = rng.normal(size=(m, n)) # mỗi hàng là một mẫu x
y = np.array([0, 1, 1, 0, 1])
W1, b1 = rng.normal(size=(n1, n)), np.zeros(n1)
W2, b2 = rng.normal(size=(1, n1)), np.zeros(1)
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def loss(W1, b1, W2, b2): # cross-entropy nhị phân, trung bình m mẫu
a2 = sigmoid(sigmoid(X @ W1.T + b1) @ W2.T + b2).ravel()
return -np.mean(y * np.log(a2) + (1 - y) * np.log(1 - a2))
# Lan truyền ngược, từng mẫu một, cộng dồn gradient
grads = [np.zeros_like(p) for p in (W1, b1, W2, b2)]
for x, t in zip(X, y):
a1 = sigmoid(W1 @ x + b1) # lan truyền xuôi
a2 = sigmoid(W2 @ a1 + b2)
d2 = a2 - t # delta lớp ra
d1 = (W2.T @ d2) * a1 * (1 - a1) # delta lớp ẩn
for g, v in zip(grads, (np.outer(d1, x), d1, np.outer(d2, a1), d2)):
g += v / m
# Gradient bằng sai phân trung tâm, từng tham số một
params, h = [W1, b1, W2, b2], 1e-5 # h: bước sai phân
for name, p, g in zip(["W1", "b1", "W2", "b2"], params, grads):
num = np.zeros_like(p)
for i in np.ndindex(p.shape):
old = p[i]
p[i] = old + h
up = loss(*params)
p[i] = old - h
num[i] = (up - loss(*params)) / (2 * h)
p[i] = old
rel = np.linalg.norm(g - num) / (np.linalg.norm(g) + np.linalg.norm(num))
print(f"{name}: max |grad| = {np.abs(g).max():.4f}, rel err = {rel:.1e}")

The first run downloads Python, about 10 MB.

Sai số tương đối của cả bốn nhóm tham số đều cực nhỏ, từ 3.1×10−123.1 \times 10^{-12} (b[2]\mathbf{b}^{[2]}) tới 1.7×10−101.7 \times 10^{-10} (W[1]\mathbf{W}^{[1]}), dưới xa ngưỡng 10−710^{-7}: lan truyền ngược đúng. Giờ hãy cố ý làm sai: xóa * a1 * (1 - a1) ở dòng tính d1 (quên thừa số g′g') rồi chạy lại. W[2]\mathbf{W}^{[2]}, b[2]\mathbf{b}^{[2]} vẫn đúng, còn sai số của W[1]\mathbf{W}^{[1]} và b[1]\mathbf{b}^{[1]} nhảy lên 7.6×10−17.6 \times 10^{-1} và 7.2×10−17.2 \times 10^{-1}. Kiểm tra gradient không chỉ báo có lỗi, nó còn chỉ ra lỗi nằm ở lớp nào.

Ô thứ hai dựng một MLP 10 lớp, mỗi lớp 64 nơ-ron, cho 32 mẫu ngẫu nhiên đi qua, rồi lan truyền ngược và in ∥δ[l]∥\lVert\boldsymbol{\delta}^{[l]}\rVert ở từng lớp với ba cấu hình. Để ba cấu hình so được với nhau, δ\boldsymbol{\delta} ở lớp cuối được đặt giống hệt nhau (một vectơ ngẫu nhiên cố định), nên hàng 10 của bảng là cùng một số.

Độ lớn gradient qua 10 lớp

import numpy as np
N_L, width, m = 10, 64, 32 # số lớp, số nơ-ron mỗi lớp, số mẫu
# Trả về ||delta^[l]|| = ||dL/dz^[l]|| của từng lớp l = 1..N_L
def run(act, init_std):
rng = np.random.default_rng(0)
g = {"sigmoid": lambda z: 1 / (1 + np.exp(-z)),
"relu": lambda z: np.maximum(z, 0)}[act]
X = rng.normal(size=(m, width)) # mỗi hàng là một mẫu
Ws, zs, acts = [], [], [X]
for l in range(N_L): # lan truyền xuôi, lớp cuối không có g
W = rng.normal(0, init_std(width, width), size=(width, width))
Ws.append(W)
zs.append(acts[-1] @ W.T)
acts.append(g(zs[-1]) if l < N_L - 1 else zs[-1])
# Mất mát giả L = trung bình của r . (đầu ra), r ngẫu nhiên cố định,
# nên delta lớp cuối = r / m, giống nhau ở cả ba cấu hình
d = rng.normal(size=(m, width)) / m
norms = [np.linalg.norm(d)]
for l in range(N_L - 1, 0, -1): # lan truyền ngược về lớp 1
a = acts[l] # kích hoạt của lớp l (đếm từ 1)
dg = a * (1 - a) if act == "sigmoid" else (zs[l - 1] > 0)
d = (d @ Ws[l]) * dg # delta^[l] = (W^[l+1]^T delta^[l+1]) * g'
norms.append(np.linalg.norm(d))
return norms[::-1]
xavier = lambda n_in, n_out: np.sqrt(2 / (n_in + n_out))
he = lambda n_in, n_out: np.sqrt(2 / n_in)
one = lambda n_in, n_out: 1.0
cols = {"sig+Xavier": run("sigmoid", xavier),
"relu+std1": run("relu", one),
"relu+He": run("relu", he)}
print("layer" + "".join(f"{k:>12}" for k in cols))
for l in range(N_L):
print(f"{l + 1:>5}" + "".join(f"{v[l]:>12.1e}" for v in cols.values()))

The first run downloads Python, about 10 MB.

Đọc từ dưới lên, tức theo chiều lan truyền ngược:

  • Sigmoid + Xavier: từ 1.4 ở lớp 10 xuống 2.9×10−62.9 \times 10^{-6} ở lớp 1, nhỏ đi khoảng 4 lần mỗi lớp. Lớp đầu nhận gradient nhỏ hơn lớp cuối gần nửa triệu lần.
  • ReLU + std 1: từ 1.4 lên 8.0×1068.0 \times 10^{6}, lớn lên khoảng 5.7 lần mỗi lớp, đúng con số 32\sqrt{32} đã tính ở mục Khởi tạo.
  • ReLU + He: giữ quanh 1.3 tới 1.5 ở mọi lớp.

Gradient của trọng số, δ[l]a[l−1]⊤\boldsymbol{\delta}^{[l]}\mathbf{a}^{[l-1]\top}, còn nhân thêm với kích hoạt của lớp trước. Với std 1, kích hoạt cũng bùng nổ theo chiều xuôi, nên mọi lớp đều có gradient trọng số khổng lồ, không chỉ các lớp đầu.

Ô thứ ba huấn luyện hai MLP 3 lớp ẩn trên bộ chữ số viết tay load_digits của scikit-learn (1797 ảnh 8×8, nửa để train, nửa để test): một mạng thường, một mạng có BatchNorm và Dropout p=0.5p = 0.5 sau mỗi lớp ẩn. Cuối cùng, ô cố ý quên model.eval() để xem chuyện gì xảy ra. Để cài PyTorch bản chỉ chạy CPU, xem bài Xây dựng LLM đầu tiên.

Run on your machine

MLP có và không có BatchNorm, Dropout

Install
pip install torch scikit-learn
Hardware
CPU là đủ. Khoảng 5 tới 15 giây tùy máy
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(0)
X, y = load_digits(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.5, random_state=0, stratify=y
)
mean, std = X_train.mean(), X_train.std() + 1e-8
X_tr = torch.tensor((X_train - mean) / std, dtype=torch.float32)
y_tr = torch.tensor(y_train, dtype=torch.long)
X_te = torch.tensor((X_test - mean) / std, dtype=torch.float32)
y_te = torch.tensor(y_test, dtype=torch.long)
def make_mlp(use_bn_dropout=False):
layers = []
dims = [64, 128, 128, 128, 10]
for i in range(len(dims) - 1):
layers.append(nn.Linear(dims[i], dims[i + 1]))
if i < len(dims) - 2:
if use_bn_dropout:
layers.append(nn.BatchNorm1d(dims[i + 1]))
layers.append(nn.ReLU())
if use_bn_dropout:
layers.append(nn.Dropout(0.5))
return nn.Sequential(*layers)
for name, use_reg in [("Base MLP", False), ("BN + Dropout", True)]:
torch.manual_seed(0)
model = make_mlp(use_reg)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(40):
model.train()
loss = loss_fn(model(X_tr), y_tr)
opt.zero_grad()
loss.backward()
opt.step()
model.eval()
with torch.no_grad():
acc = (model(X_te).argmax(dim=1) == y_te).float().mean().item()
print(f"{name} (eval): test acc = {acc:.3f}")
if use_reg:
model.train() # cố ý quên eval
accs = []
for _ in range(3):
with torch.no_grad():
acc_m = (model(X_te).argmax(dim=1) == y_te).float().mean().item()
accs.append(f"{acc_m:.3f}")
print(f"{name} (quen eval): test acc = {accs}")

Sample output

From one real run. A model gives a different answer each time.

Base MLP (eval): test acc = 0.913
BN + Dropout (eval): test acc = 0.917
BN + Dropout (quen eval): test acc = ['0.830', '0.815', '0.816']

Đọc kết quả:

  • Khi có model.eval(): Mạng có BatchNorm và Dropout đạt độ chính xác kiểm tra 0.917, nhỉnh hơn Base MLP (0.913) trên tập dữ liệu chữ số 8×8. Lúc này Dropout tắt hoàn toàn và BatchNorm dùng kỳ vọng, phương sai động tích lũy trong quá trình huấn luyện thay vì thống kê của batch kiểm tra.
  • Khi quên model.eval(): Độ chính xác sụt giảm mạnh xuống khoảng 0.815–0.830 và thay đổi ngẫu nhiên qua từng lần chạy. Lý do: Dropout vẫn tiếp tục tắt ngẫu nhiên 50% nơ-ron khiến tín hiệu đầu ra bị khuyết tật, đồng thời BatchNorm chuẩn hóa theo trung bình của tập kiểm tra thay vì phân phối đã học.
Question 1 of 5 Một mạng 7 lớp dùng sigmoid ở mọi lớp ẩn. Giả sử mọi trọng số trên đường đi đều bằng 1. So với δ\delta ở lớp 7, δ\delta ở lớp 1 lớn nhất là bao nhiêu lần?
Question 2 of 5 Lớp ẩn có 512 nơ-ron vào, dùng ReLU. Theo khởi tạo He, độ lệch chuẩn của trọng số là bao nhiêu?
Question 3 of 5 Lớp Dropout với p=0.2p = 0.2 (xác suất bỏ). Khi huấn luyện, một nơ-ron có giá trị a=3a = 3 và được giữ lại. Theo inverted dropout, giá trị đi tiếp là bao nhiêu? Còn khi dự đoán?
Question 4 of 5 Kiểm tra gradient cho MLP 2 lớp: sai số tương đối của W[2]\mathbf{W}^{[2]} và b[2]\mathbf{b}^{[2]} cỡ 10−1110^{-11}, của W[1]\mathbf{W}^{[1]} và b[1]\mathbf{b}^{[1]} cỡ 0.70.7. Lỗi nhiều khả năng nằm ở đâu?
Question 5 of 5 Bạn huấn luyện mạng có BatchNorm, rồi dự đoán từng ảnh một (batch cỡ 1) mà quên gọi model.eval(). Điều gì xảy ra ở lớp BatchNorm?

Ý chính:

  • Lan truyền ngược là quy tắc hàm hợp tính từ lớp ra về lớp vào, dùng lại kết quả trung gian: δ[l]=(W[l+1]⊤δ[l+1])⊙g′(z[l])\boldsymbol{\delta}^{[l]} = \left(\mathbf{W}^{[l+1]\top}\boldsymbol{\delta}^{[l+1]}\right) \odot g'(\mathbf{z}^{[l]}), ∂L/∂W[l]=δ[l]a[l−1]⊤\partial\mathcal{L}/\partial\mathbf{W}^{[l]} = \boldsymbol{\delta}^{[l]}\mathbf{a}^{[l-1]\top}. Kiểm tra bằng sai phân trung tâm; sai số tương đối cỡ 10−710^{-7} trở xuống là đúng.
  • Gradient ở lớp đầu là tích nhiều thừa số W⊤\mathbf{W}^\top và g′g', nên dễ tiêu biến (sigmoid: σ′≤14\sigma' \le \frac{1}{4}) hoặc bùng nổ (trọng số quá lớn).
  • Khởi tạo giữ phương sai qua mỗi lớp: Xavier s2=2/(nin+nout)s^2 = 2/(n_{\text{in}} + n_{\text{out}}) cho tanh, He s2=2/nins^2 = 2/n_{\text{in}} cho ReLU.
  • BatchNorm chuẩn hóa mỗi nơ-ron theo batch rồi co giãn bằng γ\gamma, β\beta, và dùng trung bình động khi dự đoán; inverted dropout tắt nơ-ron với xác suất pp và chia cho 1−p1 - p khi huấn luyện, không làm gì khi dự đoán. Weight decay co trọng số, dừng sớm giữ mô hình tốt nhất trên tập kiểm định, cắt chuẩn gradient chặn bùng nổ.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About