Kiến trúc đột phá (He et al., 2015) cho phép huấn luyện những mạng nơ-ron RẤT SÂU — hàng trăm tầng — một cách ổn định nhờ kết nối tắt (skip connection). ResNet thắng ImageNet 2015 và đến nay vẫn là backbone phổ biến nhất trong thị giác máy tính.
Trực giác thông thường cho rằng càng nhiều tầng thì mạng càng mạnh. Nhưng thực nghiệm cho thấy điều ngược lại: khi chồng quá nhiều tầng, độ chính xác bị bão hòa rồi suy giảm nhanh. Quan trọng hơn, đây không phải overfitting.
Nếu là overfitting thì lỗi huấn luyện phải thấp còn lỗi kiểm tra mới cao. Ở đây ngược lại: một mạng 56 tầng cho lỗi huấn luyện cao hơn cả mạng 20 tầng. Tức là mạng sâu hơn còn không khớp nổi dữ liệu train — đây là vấn đề tối ưu hóa, không phải khả năng tổng quát.
Hai nguyên nhân chính:
Gọi \(H(x)\) là ánh xạ lý tưởng mà một khối (vài tầng chồng lên nhau) cần học. Thay vì ép khối học thẳng \(H(x)\), ResNet đổi mục tiêu: cho khối học phần dư (residual)
rồi khôi phục lại ánh xạ gốc bằng cách cộng đầu vào \(x\) vào kết quả:
Phép cộng \(+\,x\) này được thực hiện qua một kết nối tắt (shortcut / skip connection) — đường đi thẳng từ đầu vào đến đầu ra của khối. Khi shortcut là ánh xạ đồng nhất, nó không thêm tham số và gần như không tốn tính toán.
Lợi ích then chốt: nếu ánh xạ tối ưu gần với hàm đồng nhất (điều rất hay xảy ra ở các tầng sâu), mạng chỉ cần đẩy các trọng số sao cho \(F(x) \to 0\). Đưa một chồng tầng phi tuyến về 0 dễ hơn rất nhiều so với bắt chúng tự học chính xác hàm đồng nhất. Nói cách khác, ResNet đặt điểm khởi đầu ngay tại "không làm gì cả" và để mạng học phần tinh chỉnh nhỏ quanh đó.
Thành phần \(+1\) đảm bảo gradient luôn có một "lối thoát" về các tầng trước dù \(\dfrac{\partial F}{\partial x}\) có nhỏ đến đâu. Nhờ vậy gradient không tiêu biến, các tầng đầu vẫn học được — đây là chìa khóa giúp mạng vài trăm tầng huấn luyện ổn định.
Basic block (dùng cho ResNet-18 / 34): gồm hai lớp conv 3x3, mỗi conv kèm BatchNorm và ReLU; sau lớp conv thứ hai thì cộng shortcut \(x\) rồi mới qua ReLU cuối.
Bottleneck block (dùng cho ResNet-50 / 101 / 152): thay vì hai conv 3x3 đắt đỏ, dùng ba lớp 1x1 → 3x3 → 1x1. Lớp 1x1 đầu giảm chiều số kênh, lớp 3x3 xử lý ở chiều thấp (rẻ), lớp 1x1 cuối khôi phục số kênh để cộng với shortcut. Cách này tiết kiệm rất nhiều phép tính khi mạng rất sâu.
Projection shortcut. Phép cộng \(F(x)+x\) đòi hỏi hai vế cùng kích thước. Khi khối làm thay đổi số kênh hoặc giảm độ phân giải (downsample), shortcut không thể là identity thuần. Khi đó dùng một conv 1x1 với stride 2 trên nhánh tắt để khớp số kênh và kích thước không gian trước khi cộng.
| Mô hình | Số tầng | Loại block | Dùng khi nào |
|---|---|---|---|
| ResNet-18 | 18 | Basic (2×3x3) | Nhẹ, nhanh; baseline, dữ liệu nhỏ, thiết bị hạn chế |
| ResNet-34 | 34 | Basic (2×3x3) | Cân bằng nhẹ/chính xác khi chưa cần bottleneck |
| ResNet-50 | 50 | Bottleneck (1x1-3x3-1x1) | Mặc định phổ biến nhất; backbone cho detection/segmentation |
| ResNet-101 | 101 | Bottleneck | Cần độ chính xác cao hơn, chấp nhận nặng hơn |
| ResNet-152 | 152 | Bottleneck | Hiệu năng tối đa khi tài nguyên dư dả |
Hậu duệ & biến thể mở rộng:
PyTorch — basic block với projection shortcut khi đổi số kênh:
import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) # shortcut: identity, hoặc conv 1x1 khi đổi chiều/độ phân giải self.shortcut = nn.Sequential() if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): out = self.relu(self.bn1(self.conv1(x))) # conv-bn-relu out = self.bn2(self.conv2(out)) # conv-bn out = out + self.shortcut(x) # cộng skip: F(x) + x return self.relu(out) # ReLU sau cùng
Keras (TensorFlow) — cùng ý tưởng bằng functional API:
from tensorflow.keras import layers def residual_block(x, filters, stride=1): shortcut = x y = layers.Conv2D(filters, 3, strides=stride, padding="same", use_bias=False)(x) y = layers.BatchNormalization()(y) y = layers.ReLU()(y) y = layers.Conv2D(filters, 3, padding="same", use_bias=False)(y) y = layers.BatchNormalization()(y) # projection shortcut khi đổi số kênh / downsample if stride != 1 or x.shape[-1] != filters: shortcut = layers.Conv2D(filters, 1, strides=stride, use_bias=False)(x) shortcut = layers.BatchNormalization()(shortcut) y = layers.Add()([y, shortcut]) # F(x) + x return layers.ReLU()(y)