Mục lục
- Mục tiêu bài học
- Backpropagation là gì
- Vì sao cần một thuật toán riêng
- Setup forward / backward
- Chain rule recap
- Computation graph
- Backprop trên 2-layer MLP
- Cache intermediate — tính một lần
- Local gradient các op phổ biến
- Gradient descent update
- Pseudocode training loop
- Autodiff vs backprop
- Vanishing / exploding gradient
- Chi phí tính toán và memory
- Implement NumPy cho XOR
- Verify gradient bằng finite difference
- PyTorch autograd — preview
- Tổng kết Module 1
- Bài tập
- Tóm tắt
Mục tiêu bài học
Sau bài học, bạn sẽ:
- Hiểu backpropagation = chain rule áp dụng systematic trên computation graph.
- Phân biệt forward pass (tính output + cache) với backward pass (tính gradient ngược).
- Dẫn ra công thức backward cho 2-layer MLP (softmax + cross-entropy).
- Liệt kê local gradient các op phổ biến: add, multiply, sigmoid, ReLU, softmax + CE.
- Hiểu vì sao backprop là reverse-mode automatic differentiation và lý do framework hiện đại (PyTorch, JAX) không cần viết backward tay.
- Nhận diện vanishing / exploding gradient và biết các biện pháp xử lý cơ bản.
- Implement backprop bằng NumPy cho XOR và verify gradient bằng finite difference.
Bài này nối Bài 10 — Cross-Entropy và kết Module 1. Sau đó Bài 12 — PyTorch vs TensorFlow mở đầu Module 2 — framework.
Backpropagation là gì
Backpropagation (viết tắt backprop) là thuật toán tính gradient của loss theo MỌI parameter của neural network. Cốt lõi là chain rule (Series 1 — Bài 25), được áp dụng systematic trên computation graph từ phải sang trái: bắt đầu từ loss \( L \) ở cuối, lan gradient ngược qua từng node về tới tham số \( W^{(1)}, \mathbf{b}^{(1)}, W^{(2)}, \mathbf{b}^{(2)}, \ldots \).
Khi đã có gradient \( \partial L / \partial W \) cho mọi \( W \), gradient descent (Series 1 — Bài 27) update parameter theo công thức quen thuộc:
\[ W \leftarrow W - \eta \cdot \frac{\partial L}{\partial W} \]
Backprop không phải là phương pháp tối ưu — nó chỉ là máy tính gradient. Phần "học" của network nằm ở optimizer (gradient descent, Adam, ...). Phần "đo độ sai" nằm ở loss function. Backprop là cây cầu nối giữa hai phần đó.
Backprop được đề xuất rời rạc trong nhiều ngữ cảnh từ thập niên 1960s–70s; bài báo phổ biến nó cho neural network là Rumelhart, Hinton, Williams (1986). Mọi DL framework hiện đại đều cài đặt backprop dưới dạng reverse-mode automatic differentiation.
Vì sao cần một thuật toán riêng
Có thể đặt câu hỏi: tại sao không tính gradient bằng định nghĩa giới hạn?
\[ \frac{\partial L}{\partial w_i} \approx \frac{L(w_i + \epsilon) - L(w_i)}{\epsilon} \]
Cách này gọi là finite difference. Nó hoạt động — và là cách verify gradient — nhưng không scale:
- Mỗi parameter \( w_i \) cần một lần forward pass riêng \( \Rightarrow \) chi phí \( O(P \cdot F) \) với \( P \) là số parameter, \( F \) là chi phí 1 forward.
- Model hiện đại có \( P \) ở quy mô triệu / tỷ. ResNet-50 có \( \approx 25 \) triệu parameter; GPT-3 \( 175 \) tỷ. Finite difference đòi 25 triệu / 175 tỷ lần forward để cập nhật 1 batch — không khả thi.
- Finite difference cũng kém chính xác do sai số làm tròn float.
Backprop tính gradient cho mọi parameter trong 1 lần backward với chi phí \( \approx 2 \times \) forward (sẽ phân tích ở mục 14). Tức là \( O(F) \), không phụ thuộc số parameter. Đây là lý do duy nhất cho phép train model triệu / tỷ parameter trên hardware hiện tại.
Setup forward / backward
Một network \( L \) layer biến input \( \mathbf{x} \) thành prediction \( \hat{\mathbf{y}} \) qua chuỗi biến đổi:
\[ \mathbf{x} \to \text{layer 1} \to \mathbf{a}^{(1)} \to \text{layer 2} \to \mathbf{a}^{(2)} \to \cdots \to \mathbf{a}^{(L)} = \hat{\mathbf{y}} \to L(\hat{\mathbf{y}}, \mathbf{y}) \]
Đây là forward pass: dữ liệu chảy từ trái sang phải, đầu ra cuối là loss \( L \) — một scalar.
Backward pass đi ngược lại: bắt đầu từ \( \partial L / \partial L = 1 \), lan gradient sang trái qua từng layer:
\[ L \to \frac{\partial L}{\partial \mathbf{a}^{(L)}} \to \frac{\partial L}{\partial \mathbf{a}^{(L-1)}} \to \cdots \to \frac{\partial L}{\partial \mathbf{a}^{(1)}} \to \frac{\partial L}{\partial W^{(1)}}, \frac{\partial L}{\partial \mathbf{b}^{(1)}} \]
Tại mỗi layer ta cần hai loại gradient:
- Gradient theo input (\( \partial L / \partial \mathbf{a}^{(l-1)} \)): để truyền tiếp ngược về layer trước.
- Gradient theo parameter (\( \partial L / \partial W^{(l)}, \partial L / \partial \mathbf{b}^{(l)} \)): để optimizer dùng update weight.
Quan sát chiều thông tin: forward dùng input và parameter, ra output. Backward dùng output gradient và input forward (đã cache), ra input gradient + parameter gradient. Mỗi layer là "ống nối" hai chiều.
Chain rule recap
Series 1 — Bài 25 đã chứng minh: với hàm hợp \( f(g(x)) \),
\[ \frac{df}{dx} = \frac{df}{dg} \cdot \frac{dg}{dx} \]
Tổng quát cho chuỗi nhiều bước: nếu \( y = f_n(f_{n-1}(\cdots f_1(x))) \) thì
\[ \frac{dy}{dx} = \frac{df_n}{df_{n-1}} \cdot \frac{df_{n-1}}{df_{n-2}} \cdots \frac{df_1}{dx} \]
Toàn bộ backprop chỉ là công thức trên. Khác biệt nằm ở việc input / output là vector hoặc ma trận, lúc đó "đạo hàm" trở thành Jacobian:
\[ \mathbf{f}: \mathbb{R}^n \to \mathbb{R}^m, \quad J = \frac{\partial \mathbf{f}}{\partial \mathbf{x}} \in \mathbb{R}^{m \times n}, \quad J_{ij} = \frac{\partial f_i}{\partial x_j} \]
Chain rule cho hàm vector: với \( \mathbf{z} = \mathbf{g}(\mathbf{x}), \mathbf{y} = \mathbf{f}(\mathbf{z}) \),
\[ \frac{\partial \mathbf{y}}{\partial \mathbf{x}} = \frac{\partial \mathbf{y}}{\partial \mathbf{z}} \cdot \frac{\partial \mathbf{z}}{\partial \mathbf{x}} \]
(tích Jacobian, từ trái sang phải). Khi \( y \) là scalar (như loss \( L \)), \( \partial L / \partial \mathbf{x} \) là vector cùng shape với \( \mathbf{x} \) — gọi là gradient vector.
Quy ước trong bài này (denominator layout): \( \partial L / \partial \mathbf{x} \) có shape giống \( \mathbf{x} \). Quy ước này tiện trong implementation vì gradient nhận shape khớp với parameter để update trực tiếp.
Computation graph
Mọi biểu thức toán có thể biểu diễn bằng đồ thị có hướng computation graph: mỗi node là một phép toán cơ bản (add, mul, exp, log, matmul, ...), mỗi edge là một tensor dữ liệu.
Ví dụ: \( L = (wx + b - y)^2 \) phân rã thành các node:
- \( u = w \cdot x \) (mul).
- \( v = u + b \) (add).
- \( e = v - y \) (sub).
- \( L = e^2 \) (square).
Forward chạy theo thứ tự topological (từ lá đến gốc): \( x, w, b, y \to u \to v \to e \to L \). Backward chạy ngược: bắt đầu \( \partial L / \partial L = 1 \), tính ngược qua từng node, tích lũy gradient về \( w \) và \( b \).
Tại mỗi node, ta chỉ cần biết local gradient — đạo hàm của output node theo input node — không cần biết global structure. Backprop = nhân local gradient theo chain rule, gom từ ngọn về gốc. Đây là tính chất modular: thêm 1 op mới chỉ cần định nghĩa local gradient của op đó.
Có hai cách build graph trong framework hiện đại:
- Define-and-run (TensorFlow 1.x, Theano): khai báo graph tĩnh trước, rồi feed dữ liệu chạy. Khó debug nhưng dễ optimize cấp graph.
- Define-by-run (PyTorch, JAX, TensorFlow 2.x mặc định eager): graph build động khi code Python chạy. Dễ debug, dễ control flow Python, hơi chậm hơn nhưng XLA / TorchScript có thể compile.
Backprop trên 2-layer MLP
Bài toán cụ thể: 2-layer MLP cho multi-class classification (B3, B7, B8, B10). Input \( \mathbf{x} \in \mathbb{R}^d \), một hidden layer kích thước \( h \), output \( K \) class.
Forward pass (1 sample, không bias để gọn — bias xử lý tương tự):
\[ \mathbf{z}^{(1)} = W^{(1)} \mathbf{x} + \mathbf{b}^{(1)}, \quad \mathbf{a}^{(1)} = f(\mathbf{z}^{(1)}) \]
\[ \mathbf{z}^{(2)} = W^{(2)} \mathbf{a}^{(1)} + \mathbf{b}^{(2)}, \quad \hat{\mathbf{y}} = \text{softmax}(\mathbf{z}^{(2)}) \]
\[ L = -\sum_k y_k \log \hat{y}_k \]
Với shape: \( W^{(1)} \in \mathbb{R}^{h \times d} \), \( W^{(2)} \in \mathbb{R}^{K \times h} \), \( f \) là activation phi tuyến (ReLU, sigmoid, tanh — B5, B6, B7).
Backward pass đi từ phải sang trái. Bước 1, gradient tại logit ngay sau pair softmax + CCE (đã chứng minh ở B10):
\[ \frac{\partial L}{\partial \mathbf{z}^{(2)}} = \hat{\mathbf{y}} - \mathbf{y} \quad \in \mathbb{R}^K \]
Bước 2, từ logit ngược về parameter của layer 2. Vì \( \mathbf{z}^{(2)} = W^{(2)} \mathbf{a}^{(1)} + \mathbf{b}^{(2)} \), áp dụng chain rule:
\[ \frac{\partial L}{\partial W^{(2)}} = \frac{\partial L}{\partial \mathbf{z}^{(2)}} \cdot (\mathbf{a}^{(1)})^\top \quad \in \mathbb{R}^{K \times h} \]
\[ \frac{\partial L}{\partial \mathbf{b}^{(2)}} = \frac{\partial L}{\partial \mathbf{z}^{(2)}} \quad \in \mathbb{R}^K \]
Bước 3, lan gradient ngược qua \( W^{(2)} \) về hidden activation:
\[ \frac{\partial L}{\partial \mathbf{a}^{(1)}} = (W^{(2)})^\top \cdot \frac{\partial L}{\partial \mathbf{z}^{(2)}} \quad \in \mathbb{R}^h \]
Bước 4, qua activation \( f \) — local gradient là đạo hàm phần tử của \( f \):
\[ \frac{\partial L}{\partial \mathbf{z}^{(1)}} = \frac{\partial L}{\partial \mathbf{a}^{(1)}} \odot f'(\mathbf{z}^{(1)}) \]
(\( \odot \) là tích Hadamard — phần tử với phần tử). Với ReLU: \( f'(z) = \mathbb{1}[z > 0] \) — gradient pass-through nếu \( z > 0 \), bị tắt nếu \( z \le 0 \).
Bước 5, gradient parameter layer 1:
\[ \frac{\partial L}{\partial W^{(1)}} = \frac{\partial L}{\partial \mathbf{z}^{(1)}} \cdot \mathbf{x}^\top \quad \in \mathbb{R}^{h \times d} \]
\[ \frac{\partial L}{\partial \mathbf{b}^{(1)}} = \frac{\partial L}{\partial \mathbf{z}^{(1)}} \quad \in \mathbb{R}^h \]
Quan sát pattern lặp lại: gradient theo \( W^{(l)} \) = (gradient theo pre-activation \( \mathbf{z}^{(l)} \)) nhân với (input của layer = activation \( \mathbf{a}^{(l-1)} \) hoặc \( \mathbf{x} \)). Đây là quy luật chung cho mọi linear layer trong network sâu hơn — chỉ cần lặp lại Bước 3–5 cho từng layer.
Với batch \( n \) sample, các công thức giữ nguyên dạng — chỉ thay vector bằng matrix shape \( n \times d \), \( n \times h \), \( n \times K \) và tính trung bình gradient parameter qua batch (do reduction mean của loss).
Cache intermediate — tính một lần
Quan sát Bước 2 và 5: gradient của \( W^{(l)} \) cần đến \( \mathbf{a}^{(l-1)} \) — giá trị tính ra trong forward pass. Nếu không lưu lại, backward phải tính lại forward một lần nữa (đắt). Giải pháp: lưu cache tất cả giá trị intermediate (\( \mathbf{z}^{(l)}, \mathbf{a}^{(l)} \) cho mọi \( l \)) ngay khi forward, để backward dùng lại.
Tradeoff: cache tốn RAM. Với batch lớn, model sâu, activation map nặng (như CNN ảnh 224×224), tổng activation có thể vượt vài chục GB — vấn đề bottleneck thực tế trên GPU.
Cách khắc phục bộ nhớ: gradient checkpointing (mục 14) — chỉ cache subset, recompute phần còn lại trong backward. Tradeoff: tốn thêm thời gian forward thứ hai trên subset cần recompute, đổi lấy memory.
Trong implementation đơn giản (NumPy ở mục 15), ta cache mọi thứ vào dict để code rõ:
cache = {"x": x, "z1": z1, "a1": a1, "z2": z2, "y_hat": y_hat}
PyTorch / TensorFlow tự cache cho ta — không cần code thủ công.
Local gradient các op phổ biến
Bảng "thẻ rút" cho các op cơ bản — đủ để build backprop cho phần lớn network.
| Op | Forward | Local gradient (theo input) | Intuition |
|---|---|---|---|
| Add | \( y = a + b \) | \( \partial y / \partial a = 1, \quad \partial y / \partial b = 1 \) | Gradient pass-through (copy upstream cho cả hai nhánh). |
| Multiply | \( y = a \cdot b \) | \( \partial y / \partial a = b, \quad \partial y / \partial b = a \) | Gradient swap — đạo hàm theo nhánh nào thì lấy giá trị nhánh còn lại. |
| Matmul | \( \mathbf{y} = W \mathbf{x} \) | \( \partial L / \partial W = (\partial L/\partial \mathbf{y}) \mathbf{x}^\top, \quad \partial L / \partial \mathbf{x} = W^\top (\partial L/\partial \mathbf{y}) \) | Mở rộng "gradient swap" cho ma trận. |
| Sigmoid | \( a = \sigma(z) \) | \( \sigma'(z) = a(1 - a) \) | Saturate ở 0 và 1 — dễ vanishing. |
| Tanh | \( a = \tanh(z) \) | \( \tanh'(z) = 1 - a^2 \) | Saturate ở \( \pm 1 \) — vẫn vanishing nhưng zero-centered. |
| ReLU | \( a = \max(0, z) \) | \( 1 \) nếu \( z > 0 \), \( 0 \) nếu \( z \le 0 \) | Pass-through phần dương, kill phần âm. Không saturate ở phía dương. |
| Softmax + CE | \( L = -\sum_k y_k \log \hat{y}_k, \quad \hat{\mathbf{y}} = \text{softmax}(\mathbf{z}) \) | \( \partial L / \partial \mathbf{z} = \hat{\mathbf{y}} - \mathbf{y} \) | Residual đơn giản nhờ Jacobian softmax triệt tiêu với \( 1/\hat{p} \) trong CE. |
| Sigmoid + BCE | \( L = -[y \log \hat{p} + (1-y)\log(1-\hat{p})], \quad \hat{p} = \sigma(z) \) | \( \partial L / \partial z = \hat{p} - y \) | Cùng dạng residual như softmax + CE. |
| Sum (reduce) | \( y = \sum_i x_i \) | \( \partial y / \partial x_i = 1 \) cho mọi \( i \) | Broadcast gradient upstream tới mọi phần tử. |
| Mean | \( y = \frac{1}{n} \sum_i x_i \) | \( \partial y / \partial x_i = 1/n \) | Sum nhưng chia thêm \( n \). |
Nguyên tắc thiết kế: mọi op "vector → scalar" hoặc "tensor → tensor" đều có local gradient có thể viết ra cụ thể. Tích chập, BatchNorm, LayerNorm, attention — đều có công thức backward đã biết, framework đã cài sẵn.
Gradient descent update
Khi đã có gradient \( \partial L / \partial W \) (gọi gọn \( \nabla_W L \)), gradient descent update parameter:
\[ W \leftarrow W - \eta \cdot \nabla_W L \]
Với \( \eta > 0 \) là learning rate (Series 1 — Bài 27). Cùng công thức áp dụng cho mọi parameter — \( \mathbf{b}, W^{(1)}, W^{(2)}, \ldots \). Optimizer phức tạp hơn (Momentum, RMSProp, Adam) thay biểu thức update bằng các hàm có moment, adaptive learning rate — nhưng input vẫn là gradient từ backprop.
Tóm lại, ba bước core của training:
- Forward — tính prediction và loss, lưu cache.
- Backward — chạy backprop, tính gradient cho mọi parameter.
- Update — optimizer dùng gradient để chỉnh parameter.
Pseudocode training loop
initialize W (He / Xavier init - B20 future)
for epoch in range(N_epochs):
for batch in dataloader: # mini-batch
y_pred, cache = forward(batch.X, W) # forward + cache
loss = criterion(y_pred, batch.y) # scalar
grads = backward(loss, cache, W) # gradient mọi W
W = optimizer.step(W, grads) # update
log(epoch, train_loss, val_loss)
Trong PyTorch / TensorFlow, "cache" và "backward" được framework tự lo. Vòng lặp thực tế gọn còn 5 dòng:
for x, y in dataloader:
optimizer.zero_grad() # reset gradient cũ
logits = model(x) # forward, autograd tự build graph
loss = criterion(logits, y)
loss.backward() # tự backprop, gradient lưu trong .grad của mỗi parameter
optimizer.step() # update
Hai điểm dễ sai với người mới:
- Quên
optimizer.zero_grad()trước backward — PyTorch cộng dồn gradient mặc định (do thiết kế cho RNN / accumulation). Không reset thì gradient cũ "rò" vào batch mới. - Apply softmax trước
CrossEntropyLoss— đã giải thích chi tiết ở B10.
Autodiff vs backprop
Automatic differentiation (autodiff) là kỹ thuật chung tính đạo hàm chính xác (không phải xấp xỉ như finite difference, không phải symbolic) bằng cách áp dụng chain rule trên computation graph. Có hai mode:
- Forward-mode autodiff: lan gradient cùng chiều forward. Hiệu quả khi số input ít, số output nhiều.
- Reverse-mode autodiff: lan gradient ngược chiều forward. Hiệu quả khi số input nhiều, số output ít (1 scalar loss). Đây chính là backpropagation.
Neural network có \( P \) triệu / tỷ parameter (nhiều input) và 1 scalar loss (1 output) — đúng tình huống tối ưu cho reverse-mode. Mỗi parameter chỉ cần 1 lần backward pass.
Framework hiện đại (PyTorch, TensorFlow, JAX) cung cấp autograd — module thực hiện reverse-mode autodiff tự động:
- Mỗi tensor có
requires_gradđánh dấu cần track. - Mỗi op tạo node trên graph động, lưu local gradient.
- Gọi
.backward()trên scalar → framework đi ngược graph, tính gradient cho mọi tensor córequires_grad=True.
Điểm quan trọng: không ai viết backward bằng tay nữa trong production. Hiểu công thức để debug, kiểm chứng, tự định nghĩa op custom, hoặc đọc paper — nhưng coding production thì dùng autograd.
Vanishing / exploding gradient
Backprop nhân local gradient qua \( L \) layer. Nếu mỗi local gradient có magnitude \( g \), gradient ở layer đầu \( \approx g^L \).
- Nếu \( g < 1 \) (ví dụ \( g = 0.25 \) với sigmoid ở vùng saturate): \( g^L \to 0 \) khi \( L \) lớn. Layer đầu nhận gradient gần 0 — không học được. Đây là vanishing gradient.
- Nếu \( g > 1 \) (ví dụ weight không kiểm soát trong RNN): \( g^L \to \infty \). Gradient quá lớn, NaN, parameter "nhảy" khỏi miền hợp lý. Đây là exploding gradient.
Ví dụ vanishing với sigmoid: \( \sigma'(z) = \sigma(z)(1 - \sigma(z)) \) đạt max ở \( z = 0 \) với giá trị 0.25. Stack 10 layer sigmoid: gradient layer đầu tỷ lệ với \( 0.25^{10} \approx 10^{-6} \) — quá nhỏ để cập nhật weight. Đây là lý do network sâu rất khó train trước thời ReLU.
Các biện pháp xử lý đã được phát triển và sẽ học chi tiết ở các bài sau:
- ReLU (B6) — derivative = 1 cho phần dương, không saturate ở phía dương; dead neuron là tradeoff.
- He init / Xavier init (Module 2) — scale weight ban đầu sao cho variance gradient ổn định qua layer.
- BatchNorm / LayerNorm (Module 2) — normalize activation để giữ scale ổn định.
- Gradient clipping — chặn norm gradient không vượt ngưỡng (ví dụ \( \|\mathbf{g}\| \le 1.0 \)). Phổ biến trong RNN, Transformer.
- Residual connection (ResNet, Module 3) — \( \mathbf{x} + F(\mathbf{x}) \) cho gradient pass-through qua skip path, tránh nhân quá nhiều factor.
Tất cả đều là cách "giữ scale gradient" trong khoảng \( O(1) \) khi backprop xuyên nhiều layer.
Chi phí tính toán và memory
FLOPs:
- Forward: tổng phép toán = \( F \) (mỗi layer \( O(\text{shape input} \times \text{shape weight}) \)).
- Backward: rule of thumb \( \approx 2F \) — tính gradient theo input và gradient theo parameter, mỗi cái có chi phí \( \approx F \) cho layer linear.
- Tổng forward + backward \( \approx 3F \).
Để ước lượng nhanh: nếu forward một step ResNet-50 batch 256 mất \( \sim 30 \) ms trên A100, một step train (forward + backward + update) mất \( \sim 90 \) ms.
Memory:
- Parameter: \( P \) tham số, cần lưu cả tensor và gradient → \( 2P \) cells. Optimizer như Adam thêm 2 moment → \( 4P \).
- Activation cache: lưu output từng layer cho backward → tỷ lệ thuận với batch size, depth, feature map size. Trên model lớn (Transformer 1B+, batch 4096) activation có thể vượt parameter memory hàng chục lần.
Gradient checkpointing (Chen và cộng sự, 2016) là tradeoff phổ biến:
- Chỉ cache activation tại một số node "checkpoint" — ví dụ 1 lần mỗi \( \sqrt{L} \) layer.
- Khi backward đến node không cache, recompute forward một đoạn ngắn từ checkpoint gần nhất.
- Memory: \( O(\sqrt{L}) \) thay vì \( O(L) \). Compute: \( \approx 1.3 \times \) so với baseline (do recompute).
PyTorch hỗ trợ qua torch.utils.checkpoint.checkpoint. Dùng phổ biến khi train Transformer lớn trên GPU bộ nhớ giới hạn.
Implement NumPy cho XOR
XOR là bài toán binary classification kinh điển cho MLP (B3) — không tách tuyến tính, cần ít nhất 1 hidden layer. Implement 2-2-1 (input 2, hidden 2, output 1) với sigmoid hidden, sigmoid output, BCE loss. Code tuân đúng bố cục forward / cache / backward / update.
import numpy as np
np.random.seed(42)
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-z))
def bce_with_logits(z, y):
# stable: max(z,0) - z*y + log1p(exp(-|z|))
return np.mean(np.maximum(z, 0) - z * y + np.log1p(np.exp(-np.abs(z))))
def forward(X, W1, b1, W2, b2):
z1 = X @ W1.T + b1 # (n, h)
a1 = sigmoid(z1) # (n, h)
z2 = a1 @ W2.T + b2 # (n, 1) - logit
y_hat = sigmoid(z2) # (n, 1) - probability (chỉ để in)
cache = {"X": X, "z1": z1, "a1": a1, "z2": z2, "y_hat": y_hat}
return z2, cache
def backward(cache, y, W2):
X, z1, a1, z2, y_hat = cache["X"], cache["z1"], cache["a1"], cache["z2"], cache["y_hat"]
n = X.shape[0]
# 1. Gradient tại logit output (sigmoid + BCE -> residual y_hat - y)
dz2 = (y_hat - y) / n # (n, 1)
# 2. Parameter layer 2
dW2 = dz2.T @ a1 # (1, h)
db2 = dz2.sum(axis=0) # (1,)
# 3. Ngược về hidden activation
da1 = dz2 @ W2 # (n, h)
# 4. Qua sigmoid hidden: f'(z1) = a1*(1-a1)
dz1 = da1 * a1 * (1.0 - a1) # (n, h)
# 5. Parameter layer 1
dW1 = dz1.T @ X # (h, d)
db1 = dz1.sum(axis=0) # (h,)
return dW1, db1, dW2, db2
# XOR data
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = np.array([[0.0], [1.0], [1.0], [0.0]])
# Init weight (Xavier-like)
d, h, K = 2, 4, 1
W1 = np.random.randn(h, d) * np.sqrt(1.0 / d)
b1 = np.zeros(h)
W2 = np.random.randn(K, h) * np.sqrt(1.0 / h)
b2 = np.zeros(K)
lr = 0.5
for epoch in range(5000):
z2, cache = forward(X, W1, b1, W2, b2)
loss = bce_with_logits(z2, y)
dW1, db1, dW2, db2 = backward(cache, y, W2)
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
if epoch % 500 == 0:
print(f"epoch {epoch:5d} loss = {loss:.4f}")
# Kiểm tra prediction
z2, _ = forward(X, W1, b1, W2, b2)
print("Pred:", sigmoid(z2).round(3).flatten())
print("True:", y.flatten())
Output mẫu (random seed 42):
epoch 0 loss = 0.7128
epoch 500 loss = 0.6909
epoch 1000 loss = 0.6354
epoch 1500 loss = 0.2287
epoch 2000 loss = 0.0438
epoch 2500 loss = 0.0185
epoch 3000 loss = 0.0114
epoch 3500 loss = 0.0082
epoch 4000 loss = 0.0064
epoch 4500 loss = 0.0052
Pred: [0.006 0.994 0.994 0.007]
Pred: [0. 1. 1. 0.]
Loss giảm từ 0.71 về \( < 0.01 \), prediction round về đúng nhãn — backprop hoạt động đúng. Lưu ý hai điểm: (1) hidden \( h = 4 \) học ổn hơn \( h = 2 \) tối thiểu; (2) loss đầu giảm chậm vì sigmoid saturate, sau khi vượt khỏi vùng saturate thì hội tụ nhanh.
Verify gradient bằng finite difference
Khi viết backward tay, sai một dấu trừ là đủ làm network không học mà code vẫn chạy. Cách an toàn: so analytic gradient với centered finite difference:
\[ \frac{\partial L}{\partial w_i} \approx \frac{L(w_i + \epsilon) - L(w_i - \epsilon)}{2 \epsilon} \]
(centered chính xác hơn forward difference, sai số \( O(\epsilon^2) \) thay vì \( O(\epsilon) \)). So sánh với gradient tự tính: sai khác tương đối phải nhỏ (\( \le 10^{-5} \) trên float64).
def loss_at(W1, b1, W2, b2, X, y):
z2, _ = forward(X, W1, b1, W2, b2)
return bce_with_logits(z2, y)
def grad_check(param, name, get_grad, eps=1e-6):
"""param: ndarray; get_grad: callable trả về analytic gradient cho param hiện tại."""
analytic = get_grad()
numeric = np.zeros_like(param)
it = np.nditer(param, flags=["multi_index"])
while not it.finished:
idx = it.multi_index
old = param[idx]
param[idx] = old + eps
L_plus = loss_at(W1, b1, W2, b2, X, y)
param[idx] = old - eps
L_minus = loss_at(W1, b1, W2, b2, X, y)
param[idx] = old
numeric[idx] = (L_plus - L_minus) / (2 * eps)
it.iternext()
diff = np.linalg.norm(analytic - numeric) / (np.linalg.norm(analytic) + np.linalg.norm(numeric) + 1e-12)
print(f"{name:>4} rel_diff = {diff:.3e}")
# Sau khi đã train hoặc giữ init, chạy 1 lần forward + backward:
z2, cache = forward(X, W1, b1, W2, b2)
dW1, db1, dW2, db2 = backward(cache, y, W2)
grad_check(W1, "W1", lambda: dW1)
grad_check(b1, "b1", lambda: db1)
grad_check(W2, "W2", lambda: dW2)
grad_check(b2, "b2", lambda: db2)
Output mong đợi: rel_diff ở quy mô \( 10^{-7} \) hoặc nhỏ hơn cho mọi parameter. Nếu thấy \( 10^{-2} \) trở lên, gần như chắc có bug trong backward (sai dấu, sai transpose, quên chia \( n \)).
Trong PyTorch có hàm torch.autograd.gradcheck làm cùng việc, dùng khi tự viết custom op.
PyTorch autograd — preview
So sánh: cùng bài XOR nhưng dùng PyTorch — không viết backward.
import torch
import torch.nn as nn
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = torch.tensor([[0.], [1.], [1.], [0.]])
model = nn.Sequential(
nn.Linear(2, 4),
nn.Sigmoid(),
nn.Linear(4, 1), # logit ra, KHÔNG sigmoid - dùng BCEWithLogitsLoss
)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
for epoch in range(5000):
logits = model(X)
loss = criterion(logits, y)
optimizer.zero_grad()
loss.backward() # autograd tự backprop
optimizer.step()
if epoch % 500 == 0:
print(f"epoch {epoch:5d} loss = {loss.item():.4f}")
with torch.no_grad():
pred = torch.sigmoid(model(X))
print("Pred:", pred.flatten().round(decimals=3).tolist())
Hành vi giống code NumPy nhưng:
- Không có hàm
backward(...)tự viết — chỉ một dòngloss.backward(). - Optimizer step gọn, support GPU bằng
.to("cuda"). - Cùng công thức cross-entropy, sigmoid, linear — tất cả autograd đã định nghĩa local gradient.
Có thể inspect graph bằng logits.grad_fn (tên op tạo ra tensor) và tensor.grad sau khi backward (gradient của tensor parameter). Bài 15 (Module 2) sẽ đi sâu autograd.
Tổng kết Module 1
11 bài Module 1 đã đi qua:
- B0 — Tại sao Deep Learning.
- B1 — Perceptron.
- B2 — MLP — multi-layer perceptron.
- B3 — Forward propagation.
- B4 — Vai trò activation phi tuyến.
- B5 — Sigmoid.
- B6 — ReLU và biến thể.
- B7 — Tanh.
- B8 — Softmax cho multi-class output.
- B9 — Loss regression: MSE, MAE.
- B10 — Loss classification: Cross-Entropy.
- B11 — Backpropagation (bài này).
Reader đến đây có nền tảng:
- Mô hình hoá: perceptron → MLP → forward propagation.
- Hàm phi tuyến: sigmoid, tanh, ReLU, softmax — mỗi loại có ngữ cảnh dùng riêng.
- Đo độ sai: MSE / MAE cho regression, BCE / CCE cho classification.
- Tính gradient: backpropagation = reverse-mode autodiff.
- Cập nhật weight: gradient descent với learning rate.
Module 2 bắt đầu chuyển từ math sang framework: PyTorch, TensorFlow, autograd, dataloader, optimizer phức tạp (Adam), regularization (dropout, BatchNorm), init (He, Xavier). Toàn bộ vẫn dựa trên backprop — chỉ là dùng API thay vì viết tay.
Bài tập
- Tính tay gradient của \( L = (y - \sigma(wx + b))^2 \) theo \( w \) và \( b \) cho 1 sample. Áp công thức với \( x = 2, y = 1, w = 0.5, b = -0.3 \); kết quả số cụ thể bao nhiêu?
- Implement backprop bằng NumPy cho MLP 2-2-1 trên XOR (mục 15 dùng \( h = 4 \), bài này yêu cầu \( h = 2 \)). Train 10 000 epoch, log loss mỗi 1000 epoch. Có phải lúc nào cũng hội tụ không? Thử 5 seed khác nhau.
- Thêm bias vào code mục 15 nếu chưa có. Verify lại gradient bằng finite difference cho cả 4 parameter — relative diff phải \( \le 10^{-6} \) trên float64.
- Đổi activation hidden từ sigmoid sang ReLU trong code mục 15. Lưu ý đạo hàm ReLU: \( 1 \) nếu \( z > 0 \), \( 0 \) ngược lại. Train lại — hội tụ nhanh hơn hay chậm hơn? Quan sát loss curve.
- Chứng minh tại sao backprop có chi phí \( \approx 2 \times \) forward cho một linear layer \( \mathbf{y} = W \mathbf{x} \). Gợi ý: đếm FLOPs của hai phép matmul \( (\partial L / \partial \mathbf{y}) \mathbf{x}^\top \) và \( W^\top (\partial L / \partial \mathbf{y}) \).
- Bài "giải tay 1 step": cho 2-layer MLP với \( W^{(1)} = [[1, -1], [0.5, 0.5]] \), \( \mathbf{b}^{(1)} = [0, 0] \), \( W^{(2)} = [[1, -2]] \), \( b^{(2)} = 0 \), sigmoid hidden, input \( \mathbf{x} = [1, 1] \), label \( y = 1 \), BCE. Forward ra \( z^{(1)}, \mathbf{a}^{(1)}, z^{(2)}, \hat{p} \), loss. Backward tay: ra mọi gradient.
- Tại sao thông thường
BCEWithLogitsLossvàCrossEntropyLosskhông cần "softmax + sigmoid manual"? Trả lời theo numerical stability (mục 14 của B10) và theo chi phí backward.
Đáp án ngắn
- Gọi \( z = wx + b, \hat{p} = \sigma(z) \). \( \partial L/\partial \hat{p} = -2(y - \hat{p}) = 2(\hat{p} - y) \). \( \partial \hat{p}/\partial z = \hat{p}(1-\hat{p}) \). Chain rule: \( \partial L/\partial z = 2(\hat{p}-y)\hat{p}(1-\hat{p}) \), rồi \( \partial L/\partial w = (\partial L/\partial z) \cdot x \), \( \partial L/\partial b = \partial L/\partial z \). Với \( x=2, y=1, w=0.5, b=-0.3 \): \( z=0.7 \), \( \hat{p}=\sigma(0.7)\approx 0.668 \), \( \partial L/\partial z \approx 2(-0.332)(0.668)(0.332) \approx -0.147 \), \( \partial L/\partial w \approx -0.295 \), \( \partial L/\partial b \approx -0.147 \).
- Hội tụ phụ thuộc seed và hidden size. Với \( h=2 \), một số seed bị stuck ở local optima (loss \( \approx 0.69 \), predict trung bình 0.5). Khi đó cần restart hoặc dùng init khác. Đây là lý do thực tế người ta dùng \( h \) đủ lớn so với tối thiểu lý thuyết.
- Centered finite difference phải khớp analytic gradient ở quy mô \( 10^{-7} \) trên float64 với \( \epsilon = 10^{-6} \). Nếu sai, tự kiểm các điểm: transpose của matmul, chia \( n \) trong BCE mean, dấu trừ trong gradient activation.
- ReLU hội tụ nhanh hơn nhiều vì không saturate ở phía dương — vùng đầu của training gradient không bị nén như sigmoid. Trade-off: với \( h \) nhỏ, ReLU dễ "dead neuron" (mọi \( z < 0 \) cho 1 neuron qua mọi sample) — dùng leaky ReLU hoặc \( h \) lớn hơn.
- Forward: 1 matmul \( W \mathbf{x} \) chi phí \( O(mn) \) với \( W \in \mathbb{R}^{m \times n} \). Backward cần 2 matmul: \( \partial L/\partial W = (\partial L/\partial \mathbf{y}) \mathbf{x}^\top \) chi phí \( O(mn) \), \( \partial L/\partial \mathbf{x} = W^\top (\partial L/\partial \mathbf{y}) \) chi phí \( O(mn) \). Tổng backward \( \approx 2 \times \) forward.
- \( \mathbf{z}^{(1)} = [1 \cdot 1 + (-1) \cdot 1, 0.5 \cdot 1 + 0.5 \cdot 1] = [0, 1] \). \( \mathbf{a}^{(1)} = [\sigma(0), \sigma(1)] = [0.5, 0.731] \). \( z^{(2)} = 1 \cdot 0.5 + (-2) \cdot 0.731 = 0.5 - 1.462 = -0.962 \). \( \hat{p} = \sigma(-0.962) \approx 0.276 \). \( L = -\log 0.276 \approx 1.287 \). Backward: \( \partial L/\partial z^{(2)} = \hat{p} - y = -0.724 \). \( \partial L/\partial W^{(2)} = -0.724 \cdot [0.5, 0.731] = [-0.362, -0.529] \). \( \partial L/\partial b^{(2)} = -0.724 \). \( \partial L/\partial \mathbf{a}^{(1)} = [1, -2]^\top \cdot (-0.724) = [-0.724, 1.448] \). \( \partial L/\partial \mathbf{z}^{(1)} = [-0.724 \cdot 0.25, 1.448 \cdot 0.731 \cdot 0.269] \approx [-0.181, 0.285] \). \( \partial L/\partial W^{(1)} = [\partial L/\partial \mathbf{z}^{(1)}]^\top \cdot [1, 1] \) trải ra ma trận \( 2 \times 2 \) tương ứng.
- (a) Numerical stability: log-sum-exp trick bên trong loss tránh overflow / NaN khi \( z \) lớn. (b) Backward cost: gradient pair softmax+CE và sigmoid+BCE rút gọn về residual \( \hat{p} - y \), không cần Jacobian softmax / nhân \( \sigma'(z) \) — nhanh hơn và stable hơn separate.
Tóm tắt
- Backpropagation = chain rule áp dụng systematic trên computation graph để tính gradient của loss theo mọi parameter.
- Forward pass chạy trái sang phải, lưu cache; backward pass chạy phải sang trái, dùng cache để tính gradient.
- Tại mỗi layer cần hai gradient: theo input (để truyền tiếp) và theo parameter (để optimizer update).
- Trên 2-layer MLP với softmax + cross-entropy: \( \partial L / \partial \mathbf{z}^{(2)} = \hat{\mathbf{y}} - \mathbf{y} \), rồi lan ngược qua \( W^{(2)} \to \mathbf{a}^{(1)} \to f'(\mathbf{z}^{(1)}) \to W^{(1)} \).
- Pattern chung: \( \partial L / \partial W^{(l)} = (\partial L / \partial \mathbf{z}^{(l)}) \cdot (\mathbf{a}^{(l-1)})^\top \).
- Local gradient các op cơ bản: add pass-through, multiply swap, sigmoid \( a(1-a) \), ReLU \( \mathbb{1}[z > 0] \), softmax + CE residual.
- Backprop = reverse-mode automatic differentiation. PyTorch / TensorFlow / JAX cung cấp autograd, không cần code backward tay.
- Chi phí: backward \( \approx 2 \times \) forward FLOPs. Memory: phải cache activation cho backward — gradient checkpointing trade compute lấy memory.
- Vanishing gradient (sigmoid / tanh sâu) và exploding gradient (RNN không clip) là hệ quả của tích nhiều local gradient. Xử lý bằng ReLU, He init, BatchNorm, gradient clipping, residual connection.
- Khi viết backward tay, verify bằng centered finite difference — relative diff phải \( \le 10^{-6} \) trên float64.
- Module 1 kết thúc: từ perceptron đến backprop, đủ nền tảng cho Module 2 (PyTorch / framework).
- Wikipedia - Backpropagation
- Wikipedia - Automatic differentiation
- Wikipedia - Chain rule
- Rumelhart, Hinton, Williams (1986) - Learning representations by back-propagating errors (Nature)
- Chen et al. (2016) - Training Deep Nets with Sublinear Memory Cost (gradient checkpointing)
- Baydin et al. (2018) - Automatic Differentiation in Machine Learning: a Survey (JMLR)
- Stanford CS231n - Backpropagation, Intuitions
- Goodfellow, Bengio, Courville - Deep Learning (Chương 6: Deep Feedforward Networks)
- PyTorch Docs - Autograd mechanics
- PyTorch Docs - torch.autograd.gradcheck
- PyTorch Docs - torch.utils.checkpoint
- JAX Docs - The Autodiff Cookbook
- Christopher Olah - Calculus on Computational Graphs: Backpropagation
- Michael Nielsen - How the backpropagation algorithm works
