Chương 5·Bài 5.2

CNN và ResNet

CNN (convolutional neural network) xếp nhiều lớp convolution nối nhau: lớp đầu dò nét, các lớp sau ghép nét thành hình phức tạp hơn, cuối cùng ra một nhãn cho cả ảnh. Nhưng thêm lớp không phải lúc nào cũng tốt hơn: mạng quá sâu có thể học kém hơn mạng nông. ResNet sửa việc này bằng đường đi tắt (skip connection), cộng thẳng đầu vào của một khối vào đầu ra của nó. Bài này xây một CNN nhỏ, so một mạng 18 lớp có và không có đường đi tắt, rồi đọc cấu trúc ResNet-18 và ResNet-50.

Từ nét đến nhãn

CNN (convolutional neural network) dùng convolution để tạo feature map, rồi activation như ReLU để ghép các đặc trưng một cách phi tuyến. Thiếu activation, hai convolution liên tiếp vẫn chỉ là một phép affine của ảnh đầu vào (không padding thì đúng bằng một convolution 5×55\times5), nên xếp thêm lớp không tạo được ranh giới quyết định phức tạp hơn.

TinyCNN: hai lần conv + ReLU xen max pool, lấy trung bình mỗi kênh, rồi linear ra hai logits.1×12×12ảnh8×12×12conv + ReLU8×6×6max pool8×6×6conv + ReLU8trung bìnhz₀z₁2 logitslinear head
TinyCNN: hai lần conv + ReLU xen max pool, lấy trung bình mỗi kênh, rồi linear ra hai logits.

CNN nhỏ ở đây dùng conv 1→81\to8, ReLU, max pooling, rồi conv 8→88\to8, ReLU. Global average pooling lấy trung bình mỗi feature map thành một số. Tám số đi vào linear head để tạo hai logits. Logit là điểm chưa chuẩn hóa, không phải xác suất; CrossEntropyLoss nhận trực tiếp chúng.

Bước Shape với ảnh xám 12×1212\times12
Ảnh đầu vào (N, 1, 12, 12)
Conv 3, padding 1, ReLU (N, 8, 12, 12)
MaxPool 2 (N, 8, 6, 6)
Conv 3, padding 1, ReLU (N, 8, 6, 6)
Global average pooling (N, 8)
Linear head (N, 2)

Trung bình toàn ảnh khiến head không cần một trọng số riêng cho từng vị trí, và head luôn nhận đúng 8 số dù ảnh vào lớn hay nhỏ; còn nếu flatten cả feature map thì số đặc trưng đổi theo kích thước ảnh.

Global max pooling lấy giá trị lớn nhất thay cho trung bình: nó trả lời “có chỗ nào khớp mẫu không”, còn trung bình đo “khớp nhiều đến đâu trên cả ảnh”. Đổi lại, cả hai làm head mất phần lớn thông tin tọa độ. Với câu hỏi “nét đứng hay ngang”, lựa chọn này hợp lý; với câu hỏi “nét nằm bên trái hay phải”, phải giữ hoặc bổ sung thông tin vị trí.

Nhờ global pooling, đặc trưng cuối 512×7×7512 \times 7 \times 7 chỉ còn 512 số, nên head 10 lớp có 512⋅10+10=5130512 \cdot 10 + 10 = 5130 tham số.

Hỏi nhanh

Head trả về logits (N, 2). Nhãn đưa vào CrossEntropyLoss cùng logits ấy phải có shape và kiểu gì?

Một bộ dữ liệu biết rõ quy tắc

Ta tự sinh ảnh nét đứng và ngang dài 8 ô, rộng 2 ô, đặt ở một vị trí ngẫu nhiên rồi thêm nhiễu nhỏ. Mỗi ảnh chỉ có một nét, hai lớp xuất hiện cân bằng. Vì biết chính xác quy tắc sinh, ta biết model phải học được gì: nếu model không học nổi, nghi ngờ đầu tiên là cấu trúc hay vòng train, không phải dữ liệu.

Hàm dưới đặt nét tránh sát biên. seed của train và validation khác nhau để hai tập không dùng chung dãy nhiễu. Với ảnh thật, mọi ảnh của cùng một học sinh hay cùng một buổi chụp phải nằm cùng một phía của split, như group split ở bài 1.3.

Python
import numpy as np
import torch
from torch import nn

torch.set_num_threads(1)
torch.manual_seed(52)

def make_lines(count, seed):
    rng = np.random.default_rng(seed)
    images = np.zeros((count, 1, 12, 12), dtype=np.float32)
    labels = np.arange(count) % 2
    for index, label in enumerate(labels):
        position = int(rng.integers(3, 8))
        if label == 0:
            images[index, 0, 2:10, position:position + 2] = 1.0
        else:
            images[index, 0, position:position + 2, 2:10] = 1.0
    images += rng.normal(0, 0.04, images.shape).astype(np.float32)
    images = np.clip(images, 0, 1)
    return torch.from_numpy(images), torch.tensor(labels, dtype=torch.long)

train_images, train_labels = make_lines(80, 1)
valid_images, valid_labels = make_lines(40, 2)
print(tuple(train_images.shape), torch.bincount(train_labels).tolist())
print(tuple(valid_images.shape), torch.bincount(valid_labels).tolist())

Ta kiểm cả shape và số ảnh mỗi lớp trước khi train. Hai lớp cân bằng nên đoán một lớp cố định đã được accuracy 0.5; model phải vượt mức đó trên validation mới chứng tỏ học được tín hiệu.

Tám ảnh train: nét đứng hoặc nét ngang, ở năm vị trí, cộng nhiễu nhỏ.đứngngangđứngngangđứngngangđứngngang8 ảnh train đầu tiên của make_lines (12×12, nhiễu σ = 0.04)chỉ 5 vị trí × 2 hướng: validation khác train ở nhiễu
Tám ảnh train: nét đứng hoặc nét ngang, ở năm vị trí, cộng nhiễu nhỏ.

Hỏi nhanh

make_lines chọn vị trí nét bằng rng.integers(3, 8). Bỏ nhiễu đi thì có bao nhiêu ảnh sạch khác nhau, và validation kiểm được điều gì?

Dựng model và kiểm một batch

Trước khi chạy 100 bước, ta đưa thử hai ảnh qua model: lỗi shape lộ ra ngay ở đây, chỉ tốn một giây. flatten(1) giữ chiều batch, chỉ gộp các chiều còn lại.

Python
class TinyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(8, 8, 3, padding=1),
            nn.ReLU(),
            nn.AdaptiveAvgPool2d(1),
        )
        self.head = nn.Linear(8, 2)

    def forward(self, images):
        features = self.features(images).flatten(1)
        return self.head(features)

model = TinyCNN()
print(tuple(model(train_images[:2]).shape))
print("parameters:", sum(p.numel() for p in model.parameters()))

Số tham số là 682: conv đầu có 8(9+1)=808(9+1)=80, conv sau có 8(8⋅9+1)=5848(8\cdot9+1)=584, head có 2(8+1)=182(8+1)=18. Shape kiểm các lớp có nối đúng với nhau không; số tham số kiểm cấu trúc có đúng như thiết kế không, chẳng hạn một lớp bị thêm nhầm hay số kênh gõ sai.

Hỏi nhanh

Tensor trước head có shape (8, 16, 4, 4). Gọi flatten(0) thay cho flatten(1) thì chuyện gì xảy ra?

Hỏi nhanh

Đổi conv thứ hai thành Conv2d(8, 16, 3, padding=1) và head thành Linear(16, 2). TinyCNN mới có bao nhiêu tham số?

Huấn luyện rồi đọc kết quả

Mỗi bước dùng cả 80 ảnh (full batch): tập nhỏ nên vừa bộ nhớ và kết quả tái lập được; với tập ảnh lớn, ta chia mini-batch bằng DataLoader. Loss và accuracy trả lời hai câu khác nhau: loss còn đo mức tự tin, còn accuracy chỉ đếm nhãn đúng.

Python
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)

def evaluate(images, labels):
    model.eval()
    with torch.no_grad():
        logits = model(images)
        loss = criterion(logits, labels).item()
        accuracy = (logits.argmax(1) == labels).float().mean().item()
    return loss, accuracy

print("before:", tuple(round(v, 4) for v in evaluate(valid_images, valid_labels)))
for step in range(100):
    model.train()
    optimizer.zero_grad()
    logits = model(train_images)
    loss = criterion(logits, train_labels)
    loss.backward()
    optimizer.step()

print("train:", tuple(round(v, 4) for v in evaluate(train_images, train_labels)))
print("valid:", tuple(round(v, 4) for v in evaluate(valid_images, valid_labels)))

Validation đi từ accuracy 0.5, đúng mức đoán bừa, lên 1.0 sau 100 bước, và train với validation khớp nhau. Kết luận được phép: cấu trúc và vòng train học được cách phân biệt hai hướng nét trong quy tắc sinh đã mô tả.

Loss in 0.0 là số đã làm tròn bốn chữ số: loss thật nhỏ hơn 0.000050.00005 nhưng không bằng 0, vì logits hữu hạn không bao giờ cho xác suất đúng bằng 1.

Hình dưới lấy một ảnh validation và ba kênh có phương sai lớn nhất sau Conv + ReLU đầu tiên của model đã train. Muốn kiểm tiếp, đổi một yếu tố có lý do (nhiễu lớn hơn, nét sát biên, nét chéo) và giữ nguyên validation khi so các cấu hình.

Ba kênh sau lớp conv đầu: mỗi kênh nhấn một phần khác nhau của nét.ảnh validationkênh 1kênh 2kênh 0
Ba kênh sau lớp conv đầu: mỗi kênh nhấn một phần khác nhau của nét.

Hỏi nhanh

Đọc thấy train: (0.0, 1.0) và valid: (0.0, 1.0), một bạn kết luận “model overfit, vì loss train đã bằng 0”. Nhận xét nào đúng?

Vì sao một mạng sâu cần đường đi tắt?

Giả sử một lớp mới chưa có gì hữu ích để thêm. Ta mong mạng sâu có thể ít nhất giữ hành vi mạng nông. Nhưng một chuỗi lớp thông thường phải học lại phép giữ nguyên qua nhiều trọng số và activation. Tối ưu có thể khó dù mạng đủ khả năng biểu diễn lời giải.

Residual block viết đầu ra dưới dạng y=x+F(x)y=x+F(x). Nhánh FF học phần cần thay đổi; nếu F(x)=0F(x)=0 thì còn đường giữ xx. Bài báo ResNet đặt trọng tâm vào việc học residual để xử lý khó khăn tối ưu khi tăng chiều sâu. Mục sau dựng một block nhỏ để thấy ý tưởng; cuối bài đọc ResNet-18 và ResNet-50 thật.

Residual block: đường tắt cộng thẳng x vào F(x) rồi mới qua ReLU.xconvReLUconvF(x)+đường tắt: x đi thẳngReLUy = ReLU(x + F(x))
Residual block: đường tắt cộng thẳng x vào F(x) rồi mới qua ReLU.

Nếu tạm bỏ activation sau phép cộng, gradient có dạng

∂L∂x=∂L∂y(I+∂F∂x).\frac{\partial L}{\partial x}=\frac{\partial L}{\partial y}\left(I+\frac{\partial F}{\partial x}\right).

II là ánh xạ giữ nguyên. Chồng nhiều block, gradient nhân các thừa số (I+∂Fk/∂x)(I+\partial F_k/\partial x); khai triển tích ấy luôn có số hạng II, tức một đường đi về các lớp đầu mà không phải nhân qua đạo hàm của mọi nhánh. Mạng không có đường tắt (gọi là mạng plain) chỉ có tích ∂FL/∂x⋯∂F1/∂x\partial F_L/\partial x\cdots\partial F_1/\partial x, nhỏ dần theo cấp số nhân khi mỗi thừa số nhỏ hơn 1.

Khai triển tích nn thừa số (I+∂Fk/∂x)(I + \partial F_k/\partial x) cho 2n2^n số hạng: tín hiệu đi qua 2n2^n đường, mỗi đường chọn đi tắt hay đi qua nhánh ở từng block. Đường tắt không bảo đảm gradient luôn tốt: ∂F/∂x\partial F/\partial x có thể triệt tiêu một phần II hoặc khuếch đại nó, và ReLU sau phép cộng vẫn chặn gradient ở các ô âm.

Một mẹo hay dùng là khởi tạo γ\gamma của BatchNorm cuối mỗi nhánh bằng 0, để lúc đầu mỗi block đúng là identity và mạng sâu khởi đầu như một mạng nông.

Hỏi nhanh

Mười block một chiều số y=x+F(x)y = x + F(x), không có ReLU, block nào cũng có ∂F/∂x=a\partial F/\partial x = a. Gradient đi qua cả mười block được nhân với bao nhiêu khi a=−0.5a = -0.5, và khi a=0.5a = 0.5?

Tính thử một block. Với x=(2,−1)x=(2,-1) và F(x)=(0.3,0.4)F(x)=(0.3,0.4), phép cộng ra (2.3,−0.6)(2.3,-0.6). Nếu block có ReLU sau cộng, kết quả là (2.3,0)(2.3,0). Cần nói rõ vị trí ReLU; “residual block” là một họ cấu trúc, không phải mọi biến thể đều giống nhau.

Python
class ResidualBlock(nn.Module):
    def __init__(self, channels):
        super().__init__()
        self.branch = nn.Sequential(
            nn.Conv2d(channels, channels, 3, padding=1),
            nn.ReLU(),
            nn.Conv2d(channels, channels, 3, padding=1),
        )

    def forward(self, x):
        return torch.relu(x + self.branch(x))

block = ResidualBlock(8)
with torch.no_grad():
    for parameter in block.parameters():
        parameter.zero_()
positive_input = torch.ones(2, 8, 6, 6)
print(torch.equal(block(positive_input), positive_input))
mixed_input = torch.tensor([[[[2., -1.]]]]).repeat(1, 8, 1, 1)
print(block(mixed_input)[0, 0].tolist())

True ở dữ liệu dương xác nhận nhánh bằng 0 giữ được đầu vào qua ReLU. Dữ liệu âm bị cắt còn 0, nên ta không gọi block này là identity trên mọi xx. Nếu stride làm ảnh nhỏ đi hoặc số kênh đổi, nhánh skip cần projection, thường là conv 1×11\times1 có stride phù hợp: hai tensor khác số kênh hay khác kích thước không cộng được.

Hỏi nhanh

Nhánh chính cho ra (N, 16, 3, 3), còn đường tắt là (N, 8, 6, 6). Làm cách nào để cộng được, mà vẫn đúng nghĩa?

Mười tám lớp có và không có đường tắt

Bài 3.3 và 3.6 đã đo mạng ReLU 10 lớp khởi tạo N(0,0.012)\mathcal N(0,0.01^2): tín hiệu và gradient co theo cấp số nhân, còn khởi tạo He hoặc BatchNorm giữ lại được thang. Thí nghiệm sau hỏi điều mới: giữ khởi tạo mặc định, chỉ thêm phép cộng xx, thì gradient có về tới lớp đầu không, và đường tắt còn cần BatchNorm không?

Mạng chồng 8 block lên sau conv đầu của TinyCNN: một conv, 16 conv trong các block và lớp linear cuối, tổng cộng 18 lớp có trọng số. DeepBlock có hai công tắc: skip bật phép cộng xx, norm chèn BatchNorm sau mỗi conv của nhánh và bỏ bias của conv. Bốn tổ hợp dùng cùng seed, cùng dữ liệu, cùng Adam và 100 bước, nên mỗi khác biệt chỉ do công tắc.

Python
class DeepBlock(nn.Module):
    def __init__(self, channels, skip, norm):
        super().__init__()
        Norm = nn.BatchNorm2d if norm else nn.Identity
        self.skip = skip
        self.branch = nn.Sequential(
            nn.Conv2d(channels, channels, 3, padding=1, bias=not norm),
            Norm(channels),
            nn.ReLU(),
            nn.Conv2d(channels, channels, 3, padding=1, bias=not norm),
            Norm(channels),
        )

    def forward(self, x):
        out = self.branch(x)
        return torch.relu(x + out) if self.skip else torch.relu(out)

def deep_net(skip, norm, blocks=8):
    torch.manual_seed(0)
    layers = [nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2)]
    layers += [DeepBlock(8, skip, norm) for _ in range(blocks)]
    layers += [nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(8, 2)]
    return nn.Sequential(*layers)

def train_deep(net, steps=100):
    optimizer = torch.optim.Adam(net.parameters(), lr=0.01)
    losses = []
    for step in range(steps):
        net.train()
        optimizer.zero_grad()
        loss = criterion(net(train_images), train_labels)
        loss.backward()
        if step == 0:
            first_grad = net[0].weight.grad.norm().item()
        optimizer.step()
        losses.append(loss.item())
    net.eval()
    with torch.no_grad():
        accuracy = (net(valid_images).argmax(1) == valid_labels).float().mean().item()
    return first_grad, losses, accuracy

curves = {}
for name, skip, norm in [
    ("plain", False, False),
    ("plain + BN", False, True),
    ("residual", True, False),
    ("residual + BN", True, True),
]:
    grad, losses, accuracy = train_deep(deep_net(skip, norm))
    curves[name] = losses
    print(f"{name:<13} grad {grad:.1e}  loss@10 {losses[10]:.4f}  max {max(losses):7.4f}"
          f"  loss@99 {losses[-1]:.4f}  valid {accuracy}")

Cột grad là norm gradient của conv đầu ở bước đầu tiên; loss@10 và loss@99 là loss train sau 10 và 99 bước cập nhật; max là loss lớn nhất trong 100 bước. Đọc theo từng hàng:

  • Plain, không BatchNorm không học gì: gradient tới conv đầu chỉ còn 2.5×10−112.5\times10^{-11}, loss đứng ở 0.6931≈ln⁡20.6931\approx\ln2 và validation 0.5, đúng mức đoán đều hai lớp. Đây vẫn là hiện tượng của bài 3.3: khởi tạo mặc định của PyTorch nhỏ hơn He, nên qua 17 conv + ReLU, phần tín hiệu phụ thuộc ảnh và gradient đi về đều co theo cấp số nhân.
  • Plain + BN học được: BatchNorm kéo thang về sau mỗi conv, như ở bài 3.6.
  • Residual, không BatchNorm là hàng mới: cùng khởi tạo, chỉ thêm phép cộng xx, gradient tới conv đầu lớn hơn khoảng 10910^9 lần. Đó là số hạng II trong tích các thừa số (I+∂Fk/∂x)(I+\partial F_k/\partial x) ở mục trước: một lối đi về conv đầu không phải nhân qua các nhánh đang co. Mạng học tới loss 0.0006 mà không cần BatchNorm hay khởi tạo He, nhưng không ổn định: không gì giữ thang tín hiệu, nên khi các nhánh lớn lên trong lúc train, phần mỗi block cộng thêm vào xx dồn lại qua 8 block, logits phình ra và có bước loss vọt lên 19.1213.
  • Residual + BN đi nhanh nhất: sau 10 bước loss đã là 0.0061, trong khi plain + BN còn 0.3354.
Gradient tới lớp conv đầu: mạng thẳng không BatchNorm gần như bằng 0; có đường tắt hoặc BatchNorm thì không.10⁻¹²10⁻⁹10⁻⁶10⁻³10⁰plain2.5e-11plain + BN4.1e-01residual3.1e-02residual + BN2.6e-01norm gradient của conv đầu ở bước 1
Gradient tới lớp conv đầu: mạng thẳng không BatchNorm gần như bằng 0; có đường tắt hoặc BatchNorm thì không.

Đây đúng là công thức của ResNet: phép cộng giữ đường đi cho gradient, BatchNorm giữ thang của tín hiệu.

Bài báo ResNet thấy cùng hình ảnh ở quy mô lớn: trên ImageNet, mạng plain 18 lớp và ResNet-18 đạt độ chính xác gần nhau nhưng ResNet-18 hội tụ nhanh hơn; lên 34 lớp, mạng plain dù có BatchNorm lại có lỗi train cao hơn mạng plain 18 lớp, còn ResNet-34 tốt hơn ResNet-18. Mạng sâu hơn biểu diễn được mọi thứ mạng nông làm được (các lớp thừa chỉ cần là identity), nên lỗi train cao hơn là dấu hiệu khó tối ưu, không phải overfit.

Mạng 18 lớp: không BatchNorm, mạng thẳng kẹt ở ln 2; residual học được.05099bước10⁻⁴10⁻²10⁰không BatchNormplain kẹt ở ln 219.105099bước10⁻⁴10⁻²10⁰có BatchNormplain + BNresidual + BNloss train
Mạng 18 lớp: không BatchNorm, mạng thẳng kẹt ở ln 2; residual học được.

Hỏi nhanh

Theo bốn dòng output, thí nghiệm này hỗ trợ kết luận nào?

Từ block nhỏ đến ResNet-18 và ResNet-50

ResNet thật ghép các block như DeepBlock(8, skip=True, norm=True) thành bốn stage. Cấu trúc:

  • Stem (phần đầu): conv 7×77\times7 stride 2 và max pooling stride 2, giảm ảnh 224×224224\times224 còn 56×5656\times56.
  • Bốn stage: từ stage thứ hai, block đầu của mỗi stage dùng stride 2 để chia đôi cạnh và nhân đôi số kênh; đường tắt của block ấy là projection: conv 1×11\times1 stride 2 kèm BatchNorm.
  • Cuối mạng: global average pooling và một lớp linear ra 1000 lớp của ImageNet.

weights=None dựng kiến trúc với trọng số ngẫu nhiên, không tải gì từ Internet.

Python
from torchvision.models import resnet18, resnet50

resnet = resnet18(weights=None).eval()  # chỉ dựng kiến trúc, trọng số ngẫu nhiên
h = torch.zeros(1, 3, 224, 224)
with torch.no_grad():
    h = resnet.maxpool(resnet.relu(resnet.bn1(resnet.conv1(h))))
    print("stem  ", tuple(h.shape))
    for name in ["layer1", "layer2", "layer3", "layer4"]:
        h = getattr(resnet, name)(h)
        print(name, tuple(h.shape))
print(resnet.layer2[0].downsample[0])

def conv_parameters(module):
    return sum(m.weight.numel() for m in module.modules() if isinstance(m, nn.Conv2d))

resnet_50 = resnet50(weights=None)
print("ResNet-18:", sum(p.numel() for p in resnet.parameters()))
print("ResNet-50:", sum(p.numel() for p in resnet_50.parameters()))
print("bottleneck layer1[1]:", conv_parameters(resnet_50.layer1[1]))

Đọc output:

  • Cạnh ảnh giảm 32 lần (năm lần chia đôi) từ đầu vào đến layer4: ảnh 224 cho map 7×77 \times 7, ảnh 448 cho 14×1414 \times 14. Số kênh tăng từ 64 lên 512.
  • Dòng Conv2d(64, 128, ..., stride=(2, 2), bias=False) là projection của block đầu layer2.
  • Số 18 đếm các lớp có trọng số: conv stem, 16 conv trong 8 block cơ bản (mỗi block hai conv 3×33\times3) và lớp linear cuối; projection không tính.
  • Một block cơ bản 64 kênh có 2⋅64⋅64⋅9=73 7282 \cdot 64 \cdot 64 \cdot 9 = 73\,728 trọng số conv cộng 256 tham số γ\gamma, β\beta của hai BatchNorm.

Ở ResNet-50, layer2 trả (B, 512, 28, 28) và layer4 trả 2048 kênh; cả mạng có khoảng 25.6 triệu tham số, ResNet-18 khoảng 11.7 triệu.

ResNet-18: mỗi stage chia đôi cạnh, nhân đôi số kênh, tới 512 kênh 7×7.stem6456×56layer16456×56layer212828×28layer325614×14layer45127×71000linearảnh 224×224 · số trong khối là số kênh
ResNet-18: mỗi stage chia đôi cạnh, nhân đôi số kênh, tới 512 kênh 7×7.
Feature map thật của ResNet-18 trên ảnh tháp: càng sâu, bảng càng nhỏ và nét càng trừu tượng.

ResNet-50 trở lên dùng bottleneck: conv 1×11\times1 thu hẹp số kênh, conv 3×33\times3 làm việc ở chiều hẹp, rồi conv 1×11\times1 mở rộng lại gấp 4. Ở layer1, block nhận và trả 256 kênh nhưng conv 3×33\times3 chỉ chạy ở 64 kênh: 256⋅64+64⋅64⋅9+64⋅256=69 632256\cdot64+64\cdot64\cdot9+64\cdot256=69\,632 tham số, khớp dòng cuối của output. Hai conv 3×33\times3 chạy thẳng ở 256 kênh cần 2⋅256⋅256⋅9=1 179 6482\cdot256\cdot256\cdot9=1\,179\,648, gấp gần 17 lần. Nhờ vậy ResNet-50 sâu gần gấp ba ResNet-18 mà chỉ nhiều tham số khoảng 2.2 lần.

Bottleneck: conv 1×1 thu kênh lại, conv 3×3 chạy ở ít kênh, conv 1×1 mở ra; ít tham số hơn 17 lần.1×1 256 → 643×3 64 → 641×1 64 → 256bottleneck69 632 tham số3×3 256 → 2563×3 256 → 256hai conv 3×3 ở 256 kênh1 179 648 tham sốít hơn≈ 17 lần
Bottleneck: conv 1×1 thu kênh lại, conv 3×3 chạy ở ít kênh, conv 1×1 mở ra; ít tham số hơn 17 lần.

Mọi conv trong ResNet đều có bias=False: BatchNorm ngay sau trừ mean theo từng kênh, nên hằng số bias cộng vào cả kênh bị trừ đi đúng bằng nó; việc dịch chuyển do β\beta của BatchNorm đảm nhận.

Hỏi nhanh

Conv2d(64, 128, 3) đứng ngay trước BatchNorm2d(128). Để bias=True thì thêm bao nhiêu tham số, và chúng có tác dụng gì không?

Mạng cho điện thoại như MobileNet tiết kiệm hơn nữa bằng depthwise separable convolution: conv k×kk\times k với groups bằng số kênh vào, mỗi bộ lọc chỉ đọc một kênh (depthwise), rồi conv 1×11\times1 trộn các kênh (pointwise). Số tham số không bias đi từ CoutCink2C_{\mathrm{out}}C_{\mathrm{in}}k^2 xuống Cink2+CinCoutC_{\mathrm{in}}k^2+C_{\mathrm{in}}C_{\mathrm{out}}, nhỏ hơn gần k2k^2 lần khi CoutC_{\mathrm{out}} lớn; số phép nhân–cộng giảm cùng tỉ lệ. Ví dụ 3×33 \times 3 từ 32 ra 64 kênh: 18 432 trọng số thường, còn 32⋅9+32⋅64=233632 \cdot 9 + 32 \cdot 64 = 2336 khi tách.

Khi một model đã fine-tune chạy quá chậm trên điện thoại, thử kiến trúc nhẹ như MobileNet, ảnh đầu vào nhỏ hơn hoặc trọng số lượng tử hóa.

Hỏi nhanh

nn.Conv2d(64, 64, 3, groups=64, bias=False).weight.shape là gì?

CNN và ResNet đang giả định gì?

CNN và ResNet vẫn dựa vào tính cục bộ, chia sẻ trọng số và việc ghép nhiều mức chi tiết. Đường tắt giúp tối ưu mạng sâu; nó không thêm dữ liệu và không chống học thuộc.

Train từ đầu ResNet-50 với hơn 25 triệu tham số trên vài trăm ảnh dễ học thuộc tập train, trong khi một CNN nhỏ có thể tổng quát tốt hơn. Trọng số pretrained trên ImageNet có ích khi đặc trưng chuyển được sang ảnh mới (bài 5.3); ảnh y khoa, camera hồng ngoại hay ký hiệu rất nhỏ khác xa ImageNet, nên phải so với baseline trên cùng validation trước khi tin.

Với ảnh nét phấn thật, thứ tự hợp lý là: TinyCNN làm baseline dễ debug, rồi một ResNet nhỏ train từ đầu hoặc encoder ResNet pretrained thay head (nếu đề cho phép), so tất cả trên cùng split và metric.

Hỏi nhanh

Có 300 ảnh, đề cấm dùng model pretrained. ResNet-50 train từ đầu đạt 100% trên train, 55% trên validation. Một CNN nhỏ đạt 85% trên train, 78% trên validation. Bước tiếp theo hợp lý là gì?

Khi đi thi

  • Chạy một batch qua model, in shape sau từng stage và đếm tham số trước khi train.
  • Bắt đầu bằng CNN nhỏ hoặc ResNet-18; chỉ tăng độ sâu khi loss train còn cao.
  • Đề cho phép pretrained thì thay head của ResNet pretrained và dùng đúng preprocessing của checkpoint.

Tóm tắt

  • CNN ghép các feature map cục bộ thành nhãn ảnh qua activation, pooling và head; global average pooling cho head số đặc trưng cố định nhưng xóa phần lớn thông tin vị trí.
  • Mạng plain sâu có thể không học nổi vì tín hiệu và gradient tắt dần qua nhiều lớp.
  • Residual y=x+F(x)y=x+F(x) giữ một đường đi thẳng cho gradient; BatchNorm giữ thang của tín hiệu. Hai nhánh phải cùng shape, dùng projection khi đổi kênh hay stride.
  • ResNet ghép các block thành bốn stage; ResNet-50 dùng bottleneck để sâu mà vẫn gọn.
  • Đường tắt giúp tối ưu, không chống được học thuộc.

Kiểm tra cuối bài

Câu 1

Trong TinyCNN, mỗi ô của feature map sau conv thứ hai, có shape (N, 8, 6, 6), chịu ảnh hưởng của một vùng ảnh đầu vào rộng bao nhiêu?

Câu 2

Trên ảnh vật thể 224×224224\times 224, một đội so một CNN chỉ có hai lớp conv 3×33\times 3 liền nhau với ResNet-18, cùng dữ liệu và cùng số epoch, và ResNet-18 có validation cao hơn hẳn. Lý do chính là gì?

Phỏng theoNOAI 2026 Singapore · Vòng sơ loại · Câu 206

Câu 3

TinyCNN được train trên ảnh 12×1212\times 12. Một bản khác thay AdaptiveAvgPool2d(1) bằng Flatten() và head Linear(288, 2). Đưa ảnh test 16×1616\times 16 vào hai model thì sao?

Câu 4

Bảng cấu hình ResNet-152 có conv 3×33\times 3, BatchNorm, pooling và phép cộng đường tắt. Thành phần nào là đóng góp riêng của ResNet, giúp train được mạng hơn 100 lớp?

Đề thiNOAI 2026 Singapore · Vòng sơ loại · Câu 216

Câu 5

Một chuỗi 10 neuron một chiều có đường tắt: oi=oi−1+σ(wioi−1+bi)o_i = o_{i-1} + \sigma(w_io_{i-1} + b_i), với o0=xo_0 = x, σ\sigma là sigmoid và mọi wi=1w_i = 1. Đạo hàm ∂o10/∂x\partial o_{10}/\partial x nằm trong khoảng nào?

Phỏng theoCMU 10-601 · Exam 2 Practice · Q4.5

Câu 6

Một đội train hai CNN thường, không có đường tắt, cùng dữ liệu, cùng BatchNorm và khởi tạo He. Mạng 20 lớp có lỗi 6% trên train, 9% trên validation. Mạng 56 lớp có lỗi 11% trên train, 14% trên validation. Chẩn đoán và cách sửa nào hợp lý?

Phỏng theoNOAI 2025 Singapore · Phần 1 · Câu 9

Câu 7

Mỗi block tính y=x+F(x)y = x + F(x), không có ReLU sau phép cộng, và nhánh FF là conv, ReLU, rồi conv. Khởi tạo conv cuối của mọi nhánh bằng 0, cả weight lẫn bias. Ở bước train đầu tiên, mạng 50 block hoạt động thế nào?

Câu 8

Đưa ảnh 32×3232\times 32, cỡ của CIFAR-10, vào ResNet-18 của torchvision, giữ nguyên phần stem cho ImageNet. Output của layer4 có kích thước không gian bao nhiêu?

Câu 9

Thay một conv 3×33\times 3 thường, từ 64 lên 128 kênh, bằng depthwise separable: depthwise 3×33\times 3 trên từng kênh, tức groups=64, rồi pointwise 1×11\times 1 lên 128 kênh. Mọi conv đều không có bias. Số tham số trước và sau là bao nhiêu?

Phỏng theoNOAI 2025 Singapore · Phần 1 · Câu 14

Câu 10

Đề cho 4000 ảnh 64×6464\times 64, 10 lớp, chấm bằng accuracy, cấm dùng model pretrained, ngân sách 3 giờ GPU. Kế hoạch nào hợp lý nhất?

Đọc thêm

Nguồn ảnh: ảnh tháp là ảnh mẫu china.jpg kèm scikit-learn (danielbuechele, Flickr, CC BY 2.0).

Tải gói recipe của chương — gồm các module phụ và hướng dẫn chuẩn bị asset.