Фаза 04 · урок 03

CNN: от LeNet до ResNet

Цель урока: В 2011 году лучший классификатор ImageNet набирал около 74 % точности top-5. В 2012 AlexNet набрал 85 %. В 2015 ResNet набрал 96 %. Ни новых данных, ни нового поколения GPU. Прирост дали архитектурные идеи. Практикующему инженеру…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Компьютерное зрение
Чтение
15 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Четыре идеи, изменившие компьютерное зрение
  5. LeNet-5 (1998)
  6. AlexNet (2012)
  7. VGG (2014)
  8. Inception (2014, тот же год)
  9. Проблема деградации
  10. ResNet (2015)
  11. Почему остаточные связи важны за пределами компьютерного зрения
  12. Соберите сами
  13. Шаг 1: LeNet-5
  14. Шаг 2: Блок VGG
  15. Шаг 3: ResNet BasicBlock
  16. Шаг 4: Маленькая ResNet
  17. Шаг 5: Сравните эффективность «параметры–признаки»
  18. Используйте
  19. Подготовьте к поставке
  20. Упражнения
  21. Ключевые термины
  22. Дополнительные материалы

Каждая крупная CNN последних тридцати лет — это один и тот же рецепт «свёртка–нелинейность–понижение разрешения» с одной добавленной новой идеей. Изучите идеи по порядку.

Тип: Изучение + сборка Языки: Python Предварительные требования: Фаза 03, урок 11 (PyTorch), фаза 04, урок 01 (Основы изображений), фаза 04, урок 02 (Свёртки с нуля) Время: ~75 минут

Цели обучения

  • Проследить архитектурную линию LeNet-5 -> AlexNet -> VGG -> Inception -> ResNet и назвать одну новую идею, которую внесло каждое семейство
  • Реализовать LeNet-5, блок в стиле VGG и ResNet BasicBlock в PyTorch — каждый менее чем за 40 строк
  • Объяснить, почему остаточные соединения превращают сеть из 1 000 слоёв из необучаемой в передовую
  • Прочитать современную backbone-сеть (ResNet-18, ResNet-50) и предсказать форму её выхода, рецептивное поле и число параметров до просмотра исходного кода

Проблема

В 2011 году лучший классификатор ImageNet набирал около 74 % точности top-5. В 2012 AlexNet набрал 85 %. В 2015 ResNet набрал 96 %. Ни новых данных, ни нового поколения GPU. Прирост дали архитектурные идеи. Практикующему инженеру компьютерного зрения нужно знать, какая идея пришла из какой статьи, потому что любая backbone-сеть для продакшена, которую вы выпустите в 2026 году, — это перекомбинация тех же частей; кроме того, идеи продолжают переноситься: групповые свёртки пришли из CNN в трансформеры, остаточные соединения — из ResNet во все существующие LLM, а пакетная нормализация живёт в диффузионных моделях.

Изучение этих сетей по порядку также защищает от распространённой ошибки: брать самую большую доступную модель, когда сеть размера LeNet решила бы задачу. MNIST не нужна ResNet. Знание кривой масштабирования каждого семейства подсказывает, где на ней остановиться.

Концепция

Четыре идеи, изменившие компьютерное зрение

Диаграмма к уроку «CNN: от LeNet до ResNet»

Ничто другое в классическом компьютерном зрении не имело такого значения, как эти четыре скачка.

LeNet-5 (1998)

Распознаватель цифр Яна ЛеКуна. 60 000 параметров. Два блока «свёртка–пулинг», два полносвязных слоя, активации tanh. Он задал шаблон, который наследует каждая CNN:

input (1, 32, 32)
  conv 5x5 -> (6, 28, 28)
  avg pool 2x2 -> (6, 14, 14)
  conv 5x5 -> (16, 10, 10)
  avg pool 2x2 -> (16, 5, 5)
  flatten -> 400
  dense -> 120
  dense -> 84
  dense -> 10

Всё, что современный мир называет CNN, — чередование свёрток и понижения разрешения, питающее небольшую голову-классификатор, — это LeNet с большим числом слоёв, более широкими каналами и лучшими активациями.

AlexNet (2012)

Три изменения, которые вместе покорили ImageNet:

  1. ReLU вместо tanh. Градиенты перестают исчезать. Обучение ускоряется в шесть раз.
  2. Dropout в полносвязной голове. Регуляризация становится слоем, а не приёмом.
  3. Глубина и ширина. Пять свёрточных слоёв, три плотных слоя, 60M параметров, обучение на двух GPU с разделённой между ними моделью.

На рисунке 2 статьи всё ещё показано разделение GPU на два параллельных потока. Этот параллелизм был обходным решением для аппаратных ограничений, а не архитектурной идеей, но три идеи выше до сих пор есть в каждой используемой вами модели.

VGG (2014)

VGG задалась вопросом: что произойдёт, если использовать только свёртки 3x3 и сделать сеть глубокой?

stack:   conv 3x3 -> conv 3x3 -> pool 2x2
repeat:  16 or 19 conv layers

Две свёртки 3x3 видят ту же входную область 5x5, что и одна свёртка 5x5, но имеют меньше параметров (29C^2 = 18C^2 вместо 25*C^2) и дополнительную ReLU между ними. VGG превратила это наблюдение в целую архитектуру. Простота — один повторяющийся тип блока — сделала её точкой отсчёта для всего последующего.

Цена: 138M параметров, медленное обучение, дорогое выполнение инференса.

Inception (2014, тот же год)

Ответ Google на вопрос «какой размер ядра использовать?» был таким: все сразу, параллельно.

Диаграмма к уроку «CNN: от LeNet до ResNet»

Каждая ветвь специализируется: 1x1 — на смешивании каналов, 3x3 — на локальной текстуре, 5x5 — на более крупных паттернах, пулинг — на признаках, инвариантных к сдвигу; конкатенация позволяет следующему слою выбрать полезную ветвь. Inception v1 использовала свёртки 1x1 внутри каждой ветви как bottleneck, чтобы удерживать число параметров в разумных пределах.

Проблема деградации

К 2015 году VGG-19 работала, а VGG-32 — нет. Глубина должна была помогать, но после примерно 20 слоёв и обучающая, и тестовая потери становились хуже. Это не переобучение. Оптимизатор не может найти полезные веса, потому что градиенты мультипликативно сжимаются на каждом слое.

Plain deep network:
  y = f_L( f_{L-1}( ... f_1(x) ... ) )

Gradient wrt early layer:
  dL/dW_1 = dL/dy * df_L/df_{L-1} * ... * df_2/df_1 * df_1/dW_1

Each multiplicative term has magnitude roughly (weight magnitude) * (activation gain).
Stack 100 of them with gains < 1 and the gradient is effectively zero.

VGG работала на 19 слоях, потому что пакетная нормализация (опубликованная одновременно) удерживала активации в хорошо масштабированном диапазоне. Но даже пакетная нормализация не могла спасти глубину свыше примерно 30 слоёв.

ResNet (2015)

Хэ, Чжан, Жэнь и Сунь предложили одно изменение, исправившее всё:

standard block:   y = F(x)
residual block:   y = F(x) + x

+ x означает, что слой всегда может выбрать «ничего не делать», сведя F(x) к нулю. Теперь ResNet из 1 000 слоёв как минимум не хуже сети из одного слоя, потому что у каждого дополнительного блока есть простой путь отхода. С такой гарантией оптимизатор готов сделать каждый блок немного полезным, а немного полезное, сложенное 100 раз, — это передовой результат.

Диаграмма к уроку «CNN: от LeNet до ResNet»

Повсюду встречаются два варианта блока:

  • BasicBlock (ResNet-18, ResNet-34): две свёртки 3x3, пропуск проходит вокруг обеих.
  • Bottleneck (ResNet-50, -101, -152): 1x1 вниз, 3x3 в середине, 1x1 вверх, пропуск проходит вокруг тройки. Дешевле при большом числе каналов.

Когда пропуск должен пересечь понижение разрешения (stride=2), путь идентичности заменяется свёрткой 1x1 со stride=2, чтобы согласовать формы.

Почему остаточные связи важны за пределами компьютерного зрения

Идея была на самом деле не о классификации изображений. Она превращала глубокие сети из «скрестите пальцы и надейтесь, что градиенты переживут проход» в надёжный масштабируемый инженерный инструмент. Каждый трансформер, о котором вы прочитаете в следующей фазе, содержит точно такое же остаточное соединение в каждом блоке. Без ResNet не было бы GPT.

pooling

Соберите сами

Шаг 1: LeNet-5

Минимальная, верная LeNet. Активации tanh, усредняющий пулинг. Единственная уступка современности: ниже используется nn.CrossEntropyLoss, а не исходные гауссовы соединения.

import torch
import torch.nn as nn
import torch.nn.functional as F

class LeNet5(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 6, kernel_size=5)
        self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
        self.pool = nn.AvgPool2d(2)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, num_classes)

    def forward(self, x):
        x = self.pool(torch.tanh(self.conv1(x)))
        x = self.pool(torch.tanh(self.conv2(x)))
        x = torch.flatten(x, 1)
        x = torch.tanh(self.fc1(x))
        x = torch.tanh(self.fc2(x))
        return self.fc3(x)

net = LeNet5()
x = torch.randn(1, 1, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")

Ожидаемый вывод: output: torch.Size([1, 10]), params: 61,706. Это весь классификатор цифр, с которого началось современное компьютерное зрение.

Шаг 2: Блок VGG

Один переиспользуемый блок: две свёртки 3x3, ReLU, пакетная нормализация, max pool.

class VGGBlock(nn.Module):
    def __init__(self, in_c, out_c):
        super().__init__()
        self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(out_c)
        self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(out_c)
        self.pool = nn.MaxPool2d(2)

    def forward(self, x):
        x = F.relu(self.bn1(self.conv1(x)))
        x = F.relu(self.bn2(self.conv2(x)))
        return self.pool(x)

class MiniVGG(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.stack = nn.Sequential(
            VGGBlock(3, 32),
            VGGBlock(32, 64),
            VGGBlock(64, 128),
        )
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1),
            nn.Flatten(),
            nn.Linear(128, num_classes),
        )

    def forward(self, x):
        return self.head(self.stack(x))

net = MiniVGG()
x = torch.randn(1, 3, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")

Три блока VGG на входе размера CIFAR, адаптивный пулинг, один линейный слой. Около 290k параметров. Этого достаточно для CIFAR-10.

Шаг 3: ResNet BasicBlock

Основной строительный блок ResNet-18 и ResNet-34.

class BasicBlock(nn.Module):
    def __init__(self, in_c, out_c, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_c)
        self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_c)
        if stride != 1 or in_c != out_c:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_c, out_c, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_c),
            )
        else:
            self.shortcut = nn.Identity()

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out = out + self.shortcut(x)
        return F.relu(out)

bias=False у свёрточных слоёв — соглашение при пакетной нормализации: параметр beta у BN уже обрабатывает смещение, поэтому одновременно хранить смещение свёртки — пустая трата. shortcut требует настоящей свёртки только когда меняется stride или число каналов; в противном случае это тождественное преобразование без операции.

Шаг 4: Маленькая ResNet

Сложите четыре группы BasicBlock, чтобы получить работающую ResNet для входов размера CIFAR.

class TinyResNet(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.stem = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
        )
        self.layer1 = self._make_group(32, 32, num_blocks=2, stride=1)
        self.layer2 = self._make_group(32, 64, num_blocks=2, stride=2)
        self.layer3 = self._make_group(64, 128, num_blocks=2, stride=2)
        self.layer4 = self._make_group(128, 256, num_blocks=2, stride=2)
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1),
            nn.Flatten(),
            nn.Linear(256, num_classes),
        )

    def _make_group(self, in_c, out_c, num_blocks, stride):
        blocks = [BasicBlock(in_c, out_c, stride=stride)]
        for _ in range(num_blocks - 1):
            blocks.append(BasicBlock(out_c, out_c, stride=1))
        return nn.Sequential(*blocks)

    def forward(self, x):
        x = self.stem(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        return self.head(x)

net = TinyResNet()
x = torch.randn(1, 3, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")

Четыре группы по два блока. Stride 2 в начале групп 2, 3, 4. Число каналов удваивается при каждом понижении разрешения. Примерно 2.8M параметров. Это стандартный рецепт, который чисто масштабируется вплоть до ResNet-152.

Шаг 5: Сравните эффективность «параметры–признаки»

Пропустите один и тот же вход через все три сети и сравните число параметров.

def summary(name, net, x):
    y = net(x)
    params = sum(p.numel() for p in net.parameters())
    print(f"{name:12s}  input {tuple(x.shape)} -> output {tuple(y.shape)}  params {params:>10,}")

x = torch.randn(1, 3, 32, 32)
summary("LeNet5",     LeNet5(),       torch.randn(1, 1, 32, 32))
summary("MiniVGG",    MiniVGG(),      x)
summary("TinyResNet", TinyResNet(),   x)

Три модели, три эпохи, три порядка величины в числе параметров. Для точности CIFAR-10 примерно нужно: LeNet — 60 %, MiniVGG — 89 %, TinyResNet — 93 % после нескольких эпох обучения.

Используйте

torchvision.models предоставляет предобученные версии всего перечисленного. Сигнатура вызова одинакова для всех семейств, и именно в этом смысл абстракции backbone-сети.

from torchvision.models import resnet18, ResNet18_Weights, vgg16, VGG16_Weights

r18 = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
r18.eval()

print(f"ResNet-18 params: {sum(p.numel() for p in r18.parameters()):,}")
print(r18.layer1[0])
print()

v16 = vgg16(weights=VGG16_Weights.IMAGENET1K_V1)
v16.eval()
print(f"VGG-16   params: {sum(p.numel() for p in v16.parameters()):,}")

У ResNet-18 11.7M параметров. У VGG-16 — 138M. Сходная точность ImageNet top-1 (69.8 % против 71.6 %). Остаточные соединения дают выигрыш в эффективности параметров в 12 раз. Поэтому варианты ResNet доминировали с 2016 года до появления ViT в 2021 году — и всё ещё доминируют в реальных развёртываниях, где ограничением служат вычисления.

Для transfer learning рецепт всегда один: загрузить предобученную модель, заморозить backbone, заменить голову-классификатор.

for p in r18.parameters():
    p.requires_grad = False
r18.fc = nn.Linear(r18.fc.in_features, 10)

Три строки. Теперь у вас есть классификатор CIFAR на 10 классов, наследующий представления, за которые ImageNet уже заплатил.

Подготовьте к поставке

Этот урок создаёт:

  • outputs/prompt-backbone-selector.md — промпт, выбирающий подходящее семейство CNN (LeNet/VGG/ResNet/MobileNet/ConvNeXt) по задаче, размеру датасета и вычислительному бюджету.
  • outputs/skill-residual-block-reviewer.md — навык, который читает модуль PyTorch и отмечает ошибки skip-соединений (отсутствие shortcut при смене stride, порядок активации shortcut, размещение BN относительно сложения).

Упражнения

  1. (Легко) Посчитайте параметры TinyResNet вручную, слой за слоем. Сравните с sum(p.numel() for p in net.parameters()). На что уходит основная часть бюджета параметров — на свёртки, BN или голову-классификатор?
  2. (Средне) Реализуйте блок Bottleneck (1x1 -> 3x3 -> 1x1 с пропуском) и используйте его для сборки сети в стиле ResNet-50 для CIFAR. Сравните число параметров с TinyResNet.
  3. (Сложно) Уберите skip-соединение из BasicBlock, обучите 34-блочную «обычную» сеть и 34-блочную ResNet на CIFAR-10 по 10 эпох каждую. Постройте график обучающей потери по эпохам для обеих. Воспроизведите результат рисунка 1 из He et al., где обычная глубокая сеть сходится к большей потере, чем её более мелкая версия.

Ключевые термины

Термин Как говорят Что это на самом деле означает
Backbone «Модель» Стек свёрточных блоков, создающий карту признаков, которая подаётся в голову задачи
Остаточное соединение «Skip connection» y = F(x) + x; позволяет оптимизатору выучить тождественное преобразование, задав F равным нулю, благодаря чему обучается сеть произвольной глубины
BasicBlock «Две свёртки 3x3 с пропуском» Строительный блок ResNet-18/34: conv-BN-ReLU-conv-BN-add-ReLU
Bottleneck «1x1 вниз, 3x3, 1x1 вверх» Блок ResNet-50/101/152; дешёв при большом числе каналов, потому что 3x3 работает на уменьшенной ширине
Проблема деградации «Глубже — хуже» После примерно 20 обычных свёрточных слоёв растут и обучающая, и тестовая ошибка; её решают остаточные соединения, а не дополнительные данные
Stem «Первый слой» Начальная свёртка, преобразующая 3-канальный вход в базовую ширину признаков; обычно 7x7 со stride 2 для ImageNet, 3x3 со stride 1 для CIFAR
Head «Классификатор» Слои после последнего блока backbone: адаптивный пулинг, flatten, линейный слой (слои)
Transfer learning «Предобученные веса» Загрузка backbone, обученной на ImageNet, и дообучение только головы на своей задаче

Дополнительные материалы


Источник: CNNs — LeNet to ResNet 04.02 — Свёртки с нуля · Фаза 04 — Компьютерное зрение · 04.04 — Классификация изображений · Полный каталог