Framework như PyTorch cho phép huấn luyện mạng nơ-ron chỉ bằng vài dòng, nhưng khi loss không giảm hay gradient biến mất, người chưa hiểu bên trong sẽ không biết bắt đầu từ đâu. Bài này xây một mạng nơ-ron nhiều lớp hoàn toàn bằng NumPy: lan truyền xuôi (forward), lan truyền ngược (backpropagation), ba hàm kích hoạt (sigmoid, tanh, ReLU) và bốn cách khởi tạo trọng số. Mỗi điều khẳng định đều có output thật: kiểm tra gradient bằng sai phân, bài toán XOR, dữ liệu hai vầng trăng, và đối chiếu từng con số với autograd của PyTorch.
Bài nối tiếp PyTorch cơ bản: tensor, autograd và vòng lặp huấn luyện, nơi một dòng backward() làm hết mọi việc; ở đây ta mở hộp đen đó. Nền toán của phần cập nhật là Hồi quy tuyến tính và gradient descent: tự cài đặt bằng NumPy, còn phép nhân ma trận và broadcasting dùng xuyên suốt đến từ NumPy cho machine learning: vector hoá và broadcasting. Môi trường: Apple M4, 16 GB, Python 3.10, NumPy 1.26.0, scikit-learn 1.3.1, PyTorch 2.0.1 (chỉ để sinh dữ liệu). Dữ liệu giả lập hoàn toàn.
Mạng nơ-ron là gì: lớp tuyến tính xếp chồng với phi tuyến
Một mạng nơ-ron truyền thẳng (MLP) là chuỗi các lớp, mỗi lớp làm hai việc: biến đổi tuyến tính z = a @ W + b, rồi áp một hàm phi tuyến a' = f(z). Nếu bỏ hàm phi tuyến, nhiều lớp tuyến tính chồng lên nhau vẫn chỉ là một phép tuyến tính, nên phi tuyến là thứ cho mạng nơ-ron sức mạnh. Theo ghi chú CS231n về mạng nơ-ron của Stanford, mạng nơ-ron có ít nhất một lớp ẩn có thể xấp xỉ mọi hàm liên tục với độ chính xác tuỳ ý, nhưng đây là phát biểu yếu trong thực hành vì không nói cần bao nhiêu nơ-ron hay huấn luyện dễ hay khó; mạng nơ-ron sâu hơn thường hiệu quả hơn dù khả năng biểu diễn tương đương.
Ta dùng lớp cuối trả về logit (chưa qua sigmoid) và hàm mất mát binary cross-entropy. Với tổ hợp này, đạo hàm của loss theo logit của mẫu i có dạng gọn: (sigmoid(z) - y) / n, đúng thứ ta sẽ dùng để khởi động lan truyền ngược.
Forward và backpropagation trong mạng nơ-ron: chain rule từng lớp
Ghi chú CS231n về backpropagation mô tả ý tưởng cốt lõi: mỗi phép tính (cổng) biết đạo hàm cục bộ của riêng nó; ở lượt ngược, ta nhân gradient đến từ phía sau với đạo hàm cục bộ rồi chuyển lùi. Với phép nhân ma trận D = W·X, gradient là dW = dD·Xᵀ và dX = Wᵀ·dD; mẹo đáng nhớ là gradient luôn có cùng hình dạng với biến của nó, nên phân tích số chiều giúp tránh nhầm. Bài báo gốc về phương pháp này là Rumelhart, Hinton và Williams, Learning representations by back-propagating errors (Nature, 1986).
Gọi delta của lớp l là dL/dz của lớp đó. Công thức ta cài đặt gồm ba dòng:
| Bước | Công thức | Ý nghĩa |
|---|---|---|
| Lớp ra | delta = (sigmoid(z) - y) / n | đạo hàm loss theo logit |
| Gradient tham số | dW = a_prevᵀ @ delta, db = tổng delta theo mẫu | quy tắc nhân ma trận |
| Lùi một lớp | delta_prev = (delta @ Wᵀ) * f'(z_prev) | chain rule qua hàm kích hoạt |
Toàn bộ lớp MLP chỉ vài chục dòng. Phần forward lưu lại mọi z và a để lượt ngược tái dùng, đúng lời khuyên của CS231n là lưu giá trị trung gian của lượt xuôi:
def forward(self, X):
self.Z, self.A = [], [X]
for i, (W, b) in enumerate(zip(self.W, self.b)):
z = self.A[-1] @ W + b
self.Z.append(z)
self.A.append(z if i == len(self.W) - 1 else self.f(z))
return self.A[-1][:, 0]
def backward(self, y):
n = len(y)
z = self.A[-1][:, 0]
delta = ((1 / (1 + np.exp(-z)) - y) / n)[:, None]
gW, gb = [None] * len(self.W), [None] * len(self.W)
for i in reversed(range(len(self.W))):
gW[i] = self.A[i].T @ delta
gb[i] = delta.sum(axis=0)
if i > 0:
delta = (delta @ self.W[i].T) * self.df(self.Z[i - 1], self.A[i])
return gW, gbĐạo hàm các hàm kích hoạt được viết theo đầu ra a để dùng lại giá trị đã tính: sigmoid là a(1 - a), tanh là 1 - a², ReLU là 1 nếu z dương và 0 trong trường hợp còn lại. Công thức sigmoid σ(1 - σ) cũng được CS231n nêu đúng lý do này.
Kiểm tra gradient: tin backprop khi nó khớp sai phân
Lỗi trong backprop là loại lỗi im lặng nhất: mạng nơ-ron vẫn huấn luyện, chỉ chậm hoặc kém. Cách kiểm tra chuẩn là gradient checking: so gradient giải tích với sai phân trung tâm (L(w + e) - L(w - e)) / 2e cho từng trọng số. Ta thử trên mạng nơ-ron 3-5-4-1 với 20 mẫu ngẫu nhiên, cho cả ba hàm kích hoạt, và báo sai số tương đối lớn nhất trên mọi trọng số:
numpy 1.26.0 | scikit-learn 1.3.1
[1. Gradient check: backprop tự viết so với sai phân trung tâm]
sigmoid sai số tương đối lớn nhất = 4.82e-08
tanh sai số tương đối lớn nhất = 3.62e-09
relu sai số tương đối lớn nhất = 1.83e-09Sai số cỡ 1e-8 đến 1e-9, đủ nhỏ để kết luận backprop đúng. Theo ghi chú CS231n, sai số tương đối trên 1e-2 gần như chắc chắn là gradient sai, khoảng 1e-4 đến 1e-2 là vùng đáng ngờ cần xem lại, còn 1e-7 trở xuống thì yên tâm. Lưu ý ReLU không khả vi tại 0, nên gradient check có thể lệch nếu một điểm rơi gần góc gãy; với dữ liệu ngẫu nhiên liên tục, xác suất này rất nhỏ.
XOR: vì sao mạng nơ-ron cần lớp ẩn và phi tuyến
XOR là bài toán kinh điển: bốn điểm (0,0), (0,1), (1,0), (1,1) với nhãn 0, 1, 1, 0, không có đường thẳng nào tách hai lớp. Ta so một mô hình logistic không lớp ẩn với mạng nơ-ron một lớp ẩn bốn nơ-ron tanh, cùng learning rate 0,5:
[2. XOR]
không lớp ẩn (logistic): accuracy = 0.25
logit đầu ra của mô hình không lớp ẩn: ['1.6e-16', '-6.9e-17', '-7.6e-17', '-3.1e-16'] (gần 0 ở cả 4 điểm)
1 lớp ẩn, tanh, epoch 0: loss = 0.7046
1 lớp ẩn, tanh, epoch 1000: loss = 0.0055
1 lớp ẩn, tanh, epoch 3000: loss = 0.0013
1 lớp ẩn: accuracy = 1.00 | xác suất dự đoán: [0.0, 0.998, 0.998, 0.002]Con số 0,25 cần đọc cẩn thận: logit của mô hình tuyến tính gần 0 ở cả bốn điểm (cỡ 1e-16), tức nó không học được gì, nên con số 0,25 không mang thông tin gì: khi mọi logit bằng 0, dấu của sai số làm tròn quanh 0 quyết định nhãn dự đoán và accuracy chỉ là kết quả của sự ngẫu nhiên số học. Điều có ý nghĩa là không đường thẳng nào phân loại đúng cả bốn điểm (tối đa đúng 3 trên 4, tức 0,75), trong khi mạng nơ-ron có lớp ẩn đạt 1,00 với loss giảm từ 0,7046 xuống 0,0013.
Hàm kích hoạt và khởi tạo mạng nơ-ron trên dữ liệu hai vầng trăng
Bài toán thứ hai gần thực tế hơn: make_moons sinh 1.000 điểm hai vầng trăng với nhiễu 0,25, chia 700 train và 300 test. Baseline lớp đa số đạt 0,510, logistic không lớp ẩn đạt 0,837. Mạng nơ-ron có hai lớp ẩn 16 nơ-ron, huấn luyện 2.000 epoch gradient descent toàn batch với learning rate 0,5. Ta chạy chéo ba hàm kích hoạt với ba cách khởi tạo: small (0,01 nhân phân phối chuẩn), xavier (phân phối chuẩn chia căn n_in) và he (nhân căn của 2/n_in).
[3. Dữ liệu hai vầng trăng: 1.000 mẫu, nhiễu 0,25]
baseline lớp đa số: acc = 0.510
logistic (không lớp ẩn): test acc = 0.837
kích hoạt init loss@100 loss@2000 test acc
sigmoid small 0.6931 0.6931 0.490
sigmoid xavier 0.3920 0.1828 0.930
sigmoid he 0.3482 0.1719 0.940
tanh small 0.6929 0.3084 0.837
tanh xavier 0.2777 0.1467 0.950
tanh he 0.2156 0.1418 0.940
relu small 0.6931 0.1810 0.937
relu xavier 0.2181 0.1450 0.943
relu he 0.2057 0.1447 0.930Ba quan sát từ bảng. Thứ nhất, sigmoid với khởi tạo nhỏ không học được gì: loss đứng yên ở 0,6931, chính là ln 2, mức của mô hình luôn đoán xác suất 0,5, và test accuracy 0,490 tệ như đoán mò. Thứ hai, cùng một mạng nơ-ron, đổi khởi tạo từ small sang xavier đưa tanh từ 0,837 lên 0,950. Thứ ba, khi khởi tạo hợp lý, ba hàm kích hoạt cho kết quả khá sát nhau (0,93 đến 0,95); với một lần chạy và 300 mẫu test (mỗi mẫu sai là 0,33 điểm phần trăm), chênh lệch 0,01 không có ý nghĩa. Ta lặp lại với 5 hạt giống khởi tạo (dữ liệu và cách chia giữ nguyên) để biết độ dao động:
trung bình ± độ lệch chuẩn của test acc qua 5 hạt giống khởi tạo:
sigmoid small 0.490 ± 0.000
sigmoid xavier 0.916 ± 0.040
tanh small 0.835 ± 0.002
tanh xavier 0.943 ± 0.004
relu he 0.940 ± 0.008Kết quả ổn định: tanh với xavier (0,943) và ReLU với he (0,940) gần như ngang nhau, sigmoid chậm hơn và dao động nhiều (độ lệch chuẩn 0,040), còn khởi tạo nhỏ làm hỏng sigmoid ở mọi hạt giống.
Khởi tạo đồng nhất: đối xứng không bao giờ bị phá
Ghi chú CS231n về khởi tạo nêu lý do vì sao không được đặt mọi trọng số bằng nhau: nếu các nơ-ron cùng trọng số, chúng tính cùng đầu ra và nhận cùng gradient, nên đối xứng không bao giờ bị phá. Có hai trường hợp cần tách bạch. Trường hợp (a) toàn số 0 còn tệ hơn: kích hoạt tanh bằng 0 nên gradient của mọi trọng số lớp trước đó cũng bằng 0 và mạng đứng yên hoàn toàn. Trường hợp (b) đặt mọi trọng số bằng một hằng số khác 0 (0,5) mới minh hoạ đúng cơ chế: gradient khác 0, trọng số có cập nhật, nhưng cả 8 nơ-ron ẩn vẫn nhận cùng gradient. Ta kiểm chứng cả hai:
[4. Khởi tạo đồng nhất]
(a) toàn số 0: loss sau 500 bước = 0.6931 (ln 2 = 0.6931) | test acc = 0.490
max |W| của hai lớp sau huấn luyện: 0.0e+00, 0.0e+00 (gradient bằng 0, mạng đứng yên)
(b) mọi W = 0,5: loss sau 500 bước = 0.3107 | test acc = 0.837
W lớp ẩn đã đổi: 0.500 -> [0.206, -0.693] | chênh lệch lớn nhất giữa 8 cột (8 nơ-ron): 0.0e+00Ở (a), loss không rời ln 2 và mọi trọng số vẫn bằng 0 sau 500 bước, vì không có tín hiệu nào để cập nhật. Ở (b), mạng có học (loss 0,3107, test accuracy 0,837, bằng mô hình logistic) và trọng số đã đổi từ 0,5 sang các giá trị như 0,206 và -0,693, nhưng chênh lệch giữa 8 cột trọng số là đúng 0: tám nơ-ron ẩn vẫn là bản sao của nhau, nên mạng chỉ có sức biểu diễn của một nơ-ron ẩn và không vượt được mô hình tuyến tính. Khởi tạo ngẫu nhiên phá đối xứng; độ lớn của nó quyết định gradient có sống nổi qua nhiều lớp hay không, như phần sau cho thấy.
Gradient biến mất theo độ sâu của mạng nơ-ron
Vì sao sigmoid với khởi tạo nhỏ chết đứng? Lan truyền ngược nhân delta với W và đạo hàm kích hoạt qua từng lớp; nếu các thừa số này nhỏ hơn 1, tích co dần về 0 khi đi lùi. Ta đo chuẩn gradient của W ở từng lớp trên mạng nơ-ron sâu 8 lớp ẩn, mỗi lớp 32 nơ-ron, sau một lần backward (lớp 1 gần đầu vào, lớp 9 là lớp ra):
[5. Chuẩn gradient của W theo từng lớp, mạng 8 lớp ẩn x 32 nơ-ron, sau 1 lần backward]
(lớp 1 = gần đầu vào, lớp 9 = lớp ra)
sigmoid xavier 1.3e-06 1.2e-05 3.9e-05 1.6e-04 7.4e-04 5.5e-03 3.1e-02 1.2e-01 5.6e-01
tanh xavier 4.3e-02 1.4e-01 1.5e-01 1.5e-01 2.0e-01 3.3e-01 3.9e-01 4.0e-01 2.9e-01
relu he 1.2e-01 4.4e-01 5.3e-01 4.1e-01 3.3e-01 4.0e-01 2.9e-01 3.5e-01 3.3e-01Với sigmoid, gradient ở lớp đầu (1,3e-6) nhỏ hơn lớp ra (0,56) khoảng 4,3e5 lần: các lớp đầu hầu như không nhận tín hiệu học. Đạo hàm sigmoid không vượt quá 0,25, nên mỗi lớp nhân thêm một thừa số nhỏ; đây cũng chính là điều CS231n cảnh báo khi nói sigmoid bão hoà và làm gradient gần như bằng 0. Tanh co 7 lần (0,29 xuống 0,043), còn ReLU với He gần như phẳng (0,33 xuống 0,12). Tương tự, đo độ lệch chuẩn của kích hoạt theo lớp cho thấy khởi tạo quyết định cả lượt xuôi:
độ lệch chuẩn kích hoạt theo lớp (8 lớp ẩn):
tanh small 0.010 0.001 0.000 0.000 0.000 0.000 0.000 0.000
tanh xavier 0.496 0.406 0.345 0.350 0.322 0.283 0.248 0.184
relu xavier 0.430 0.319 0.191 0.146 0.091 0.052 0.034 0.025
relu he 0.608 0.638 0.540 0.582 0.515 0.414 0.380 0.393Với mạng nơ-ron dùng tanh và trọng số nhỏ, kích hoạt xẹp về 0 ngay từ lớp thứ hai. Với ReLU, khởi tạo kiểu xavier (chia căn fan-in, theo tinh thần bài báo Understanding the difficulty of training deep feedforward neural networks của Glorot và Bengio, AISTATS 2010) làm kích hoạt co từ 0,430 xuống 0,025 qua 8 lớp, trong khi He giữ ở mức 0,38 đến 0,64. Lý do là ReLU triệt khoảng một nửa số đầu ra, nên cần phương sai gấp đôi để bù; CS231n viết rõ rằng suy diễn 1/căn n giả định trung bình bằng 0, điều không đúng với ReLU, và khuyến nghị phương sai 2/n theo bài báo Delving Deep into Rectifiers của He và cộng sự (2015).
Đối chiếu với PyTorch autograd
Bước kiểm chứng cuối: dựng cùng mạng nơ-ron (2-8-8-1, tanh) trong PyTorch với đúng các trọng số của bản NumPy, tính loss trên cùng 64 mẫu và so gradient của mọi ma trận trọng số:
[6. Đối chiếu với PyTorch autograd]
torch 2.0.1
loss NumPy = 0.742936 | loss PyTorch = 0.742936
chênh lệch gradient lớn nhất giữa hai bên: 2.78e-17Loss khớp đến 6 chữ số thập phân và gradient chỉ lệch cỡ 1e-17, tức sai số làm tròn của số thực 64 bit. Backprop tự cài đặt cho mạng nơ-ron và autograd làm cùng một phép tính: autograd chỉ tự động hoá việc dựng đồ thị và áp chain rule như ta đã viết tay.
Khi nào không nên tự cài mạng nơ-ron, và lỗi thường gặp
Mạng nơ-ron bản NumPy ở đây dành để hiểu, không để dùng trong sản phẩm: không có GPU, không có tối ưu hoá như Adam, không có regularization, và huấn luyện toàn batch không mở rộng được cho dữ liệu lớn. Trong dự án thật hãy dùng PyTorch hoặc framework tương đương; với dữ liệu dạng bảng, mô hình cây thường mạnh hơn, như đã thấy ở bài Gradient boosting với XGBoost và LightGBM: khi nào thắng deep learning. Các lỗi hay gặp khi viết một mạng nơ-ron:
- Sai chiều ma trận: luôn kiểm tra gradient có cùng hình dạng với tham số; nhầm chỗ cần chuyển vị là lỗi phổ biến nhất.
- Quên chia cho n khi loss là trung bình: gradient lớn gấp n lần, learning rate hiệu dụng thay đổi theo kích thước batch.
- Sigmoid rồi mới tính cross-entropy: dễ tràn số khi |z| lớn. Code dùng dạng ổn định
max(z,0) - z*y + log1p(exp(-|z|)), cùng ý tưởng vớiBCEWithLogitsLosscủa PyTorch. - Khởi tạo đồng nhất (toàn số 0 hoặc cùng một hằng số) làm các nơ-ron trùng nhau mãi mãi, còn khởi tạo quá nhỏ với mạng sâu thì loss đứng ở ln 2 như đã đo.
- Không kiểm tra gradient: chạy gradient check trên mạng nhỏ, vài mẫu, trước khi huấn luyện thật; sau đó tắt đi vì rất chậm.
- ReLU chết: CS231n cảnh báo một cập nhật quá lớn có thể đẩy nơ-ron vào trạng thái không bao giờ kích hoạt lại; theo dõi tỉ lệ nơ-ron chết và giảm learning rate hoặc dùng Leaky ReLU.
- Kết luận từ một hạt giống: như bảng trên, sai khác 0,01 là nhiễu; hãy lặp nhiều hạt giống.
Checklist mạng nơ-ron từ đầu
- Mỗi lớp của mạng nơ-ron: biến đổi tuyến tính rồi phi tuyến; thiếu phi tuyến thì nhiều lớp chỉ bằng một.
- Lượt xuôi lưu
zvàa; lượt ngược dùng chain rule: dW = a_prevᵀ delta, delta_prev = (delta Wᵀ) nhân f'(z). - Luôn gradient check bằng sai phân trên mạng nhỏ; đối chiếu với autograd khi có thể.
- Khởi tạo mạng nơ-ron ngẫu nhiên có chia theo fan-in: xavier cho tanh và sigmoid, He cho ReLU; không bao giờ toàn số 0.
- Theo dõi mạng nơ-ron qua chuẩn gradient và độ lệch chuẩn kích hoạt theo lớp để phát hiện biến mất hoặc bùng nổ.
- Dùng loss ổn định số (logit cộng cross-entropy gộp) và so với baseline lớp đa số.
Mã nguồn đầy đủ trong code/s7-21 (nn_scratch.py, output.txt, requirements.txt), chạy bằng python nn_scratch.py. Mọi số liệu về mạng nơ-ron trong bài là output thật từ Apple M4, 16 GB, hạt giống cố định (chạy hai lần cho output giống hệt); dữ liệu giả lập.
Bài tiếp theo
Huấn luyện deep learning ổn định: optimizer, learning rate và regularization. Ta đã thấy khởi tạo và hàm kích hoạt quyết định mạng nơ-ron có học được hay không; bài kế tiếp chuyển sang các công cụ giữ cho huấn luyện ổn định khi mạng nơ-ron lớn dần: Adam, lịch learning rate và regularization.



