Обучаем CNN: от CIFAR-10 до переноса обучения
Простая CNN и маленький ResNet на PyTorch, CIFAR-10 с аугментациями, BatchNorm и расписанием lr, разбор ошибок по матрице ошибок, Grad-CAM, перенос обучения с предобученного ResNet на свои картинки и обзор детекции и сегментации с U-Net.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке мы разобрали свёрточный слой по винтикам. Теперь соберём из таких слоёв настоящие сети и обучим их, как это делают на практике: на датасете CIFAR-10, с аугментациями, BatchNorm и расписанием learning rate. Начнём с простой сети на процессоре, закончим маленьким ResNet на видеокарте. Затем возьмём сеть, предобученную на миллионе картинок ImageNet, и за минуты дообучим её на своих классах.
Этот урок — генеральная репетиция того, что вы будете делать с LLM. Цикл «данные → модель → обучение → разбор ошибок» тот же. Перенос обучения — прямой предок дообучения LLM (ступень 6): берём модель, которая уже многое знает, и немного подправляем под свою задачу. В конце урока разберём U-Net, без которой не понять диффузионные модели (урок 9.1.2).
Данные: CIFAR-10 и torchvision
Заголовок раздела «Данные: CIFAR-10 и torchvision»CIFAR-10 — 60 000 цветных картинок 32×32 в 10 классах: самолёт, автомобиль, птица, кошка, олень, собака, лягушка, лошадь, корабль, грузовик. 50 000 картинок для обучения, 10 000 для теста. Датасет маленький, поэтому эксперимент занимает минуты. Но он и не игрушечный: кошку от собаки на картинке 32×32 не всегда отличит и человек.
Для работы с картинками в PyTorch есть отдельная библиотека torchvision: датасеты, преобразования (transforms)
и готовые модели с весами. Ставится отдельно от torch, версию pip подберёт сам:
pip install torchvision # сборка с CUDA (Linux)pip install torchvision --index-url https://download.pytorch.org/whl/cpu # если torch у вас CPU-сборкиtorchvision.datasets.CIFAR10(..., download=True) при первом запуске скачает архив размером около 170 МБ. Каждый элемент
датасета — пара (картинка Pillow, номер класса). Цепочка transforms превращает картинку в тензор со
значениями от 0 до 1 и нормирует каждый канал: вычитает среднее по обучающей выборке и делит на стандартное
отклонение. Так входы оказываются около нуля с разбросом порядка 1, как мы добивались в
уроке об инициализации.
Шаг 1. Простая CNN на процессоре
Заголовок раздела «Шаг 1. Простая CNN на процессоре»Весь эксперимент — один скрипт cifar.py с тремя моделями. Цикл обучения тот же, что для MNIST в
уроке 3.3.2, добавились только аугментации и расписание lr. Их разберём на шаге 2.
"""CIFAR-10: простая CNN, CNN с BatchNorm и маленький ResNet.
Запуск: python cifar.py --model cnn --epochs 10 # CPU python cifar.py --model cnn_bn --aug --epochs 30 # GPU (на CPU — долго) python cifar.py --model resnet --aug --epochs 30 # GPU"""import argparseimport time
import torchimport torch.nn as nnimport torch.nn.functional as Fimport torchvisionimport torchvision.transforms as T
p = argparse.ArgumentParser()p.add_argument("--model", default="cnn", choices=["cnn", "cnn_bn", "resnet"])p.add_argument("--aug", action="store_true", help="аугментации: сдвиг, отражение, цвет")p.add_argument("--epochs", type=int, default=10)p.add_argument("--bs", type=int, default=128)p.add_argument("--lr", type=float, default=0.05)args = p.parse_args()torch.manual_seed(0)device = "cuda" if torch.cuda.is_available() else "cpu"
# ── Данные ────────────────────────────────────────────────────────────────────MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616) # средние и std каналов по trainbase = [T.ToTensor(), T.Normalize(MEAN, STD)] # картинка → тензор (3, 32, 32), нормировкаaug = [T.RandomCrop(32, padding=4), # случайный сдвиг до 4 пикселей T.RandomHorizontalFlip(), # зеркало слева направо T.ColorJitter(0.2, 0.2, 0.2)] # яркость, контраст, насыщенность ±20%train_tf = T.Compose((aug if args.aug else []) + base)train_ds = torchvision.datasets.CIFAR10("data", train=True, download=True, transform=train_tf)test_ds = torchvision.datasets.CIFAR10("data", train=False, download=True, transform=T.Compose(base))train_dl = torch.utils.data.DataLoader(train_ds, batch_size=args.bs, shuffle=True, num_workers=4, drop_last=True)test_dl = torch.utils.data.DataLoader(test_ds, batch_size=512, num_workers=4)CLASSES = train_ds.classes # ['airplane', 'automobile', 'bird', 'cat', ...]
# ── Модели ────────────────────────────────────────────────────────────────────def simple_cnn(): """LeNet-подобная сеть: две свёртки с пулингом и два полносвязных слоя.""" return nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32×32 → 16×16 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 16×16 → 8×8 nn.Flatten(), # 64·8·8 = 4096 чисел nn.Linear(64 * 8 * 8, 256), nn.ReLU(), nn.Linear(256, 10), )
def conv_bn(c_in, c_out): """Свёртка 3×3 → BatchNorm → ReLU. bias не нужен: BatchNorm всё равно вычтет среднее.""" return nn.Sequential(nn.Conv2d(c_in, c_out, 3, padding=1, bias=False), nn.BatchNorm2d(c_out), nn.ReLU())
def cnn_bn(): """VGG-подобная сеть: пары свёрток с BatchNorm, пулинг, в конце — глобальный средний пулинг.""" return nn.Sequential( conv_bn(3, 64), conv_bn(64, 64), nn.MaxPool2d(2), # 32 → 16 conv_bn(64, 128), conv_bn(128, 128), nn.MaxPool2d(2), # 16 → 8 conv_bn(128, 256), conv_bn(256, 256), nn.MaxPool2d(2), # 8 → 4 nn.AdaptiveAvgPool2d(1), nn.Flatten(), # (B, 256, 4, 4) → (B, 256) nn.Linear(256, 10), )
class BasicBlock(nn.Module): """Остаточный блок ResNet: y = ReLU(F(x) + shortcut(x)), F = conv-BN-ReLU-conv-BN."""
def __init__(self, c_in, c_out, stride=1): super().__init__() self.conv1 = nn.Conv2d(c_in, c_out, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(c_out) self.conv2 = nn.Conv2d(c_out, c_out, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(c_out) self.shortcut = nn.Identity() if stride != 1 or c_in != c_out: # форма меняется → обход через свёртку 1×1 self.shortcut = nn.Sequential(nn.Conv2d(c_in, c_out, 1, stride=stride, bias=False), nn.BatchNorm2d(c_out))
def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return F.relu(out + self.shortcut(x)) # та самая остаточная связь из урока 3.4.2
def resnet18_cifar(widths=(64, 128, 256, 512)): """ResNet-18 для картинок 32×32: вход 3×3 без пулинга, 4 стадии по 2 блока.""" layers = [conv_bn(3, widths[0])] c_in = widths[0] for i, w in enumerate(widths): layers += [BasicBlock(c_in, w, stride=1 if i == 0 else 2), BasicBlock(w, w)] c_in = w layers += [nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(c_in, 10)] return nn.Sequential(*layers)
model = {"cnn": simple_cnn, "cnn_bn": cnn_bn, "resnet": resnet18_cifar}[args.model]().to(device)print(f"{args.model}: {sum(p.numel() for p in model.parameters()):,} параметров, устройство {device}")
# ── Обучение ──────────────────────────────────────────────────────────────────opt = torch.optim.SGD(model.parameters(), lr=args.lr, momentum=0.9, nesterov=True, weight_decay=5e-4)sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=args.epochs * len(train_dl))
def evaluate(): model.eval() # BatchNorm — на сохранённые статистики correct = 0 with torch.no_grad(): for x, y in test_dl: correct += (model(x.to(device)).argmax(1).cpu() == y).sum().item() return correct / len(test_ds)
for epoch in range(1, args.epochs + 1): model.train() t0, seen, correct = time.time(), 0, 0 for x, y in train_dl: x, y = x.to(device), y.to(device) with torch.autocast(device_type=device, dtype=torch.bfloat16, enabled=(device == "cuda")): logits = model(x) loss = F.cross_entropy(logits, y) opt.zero_grad() loss.backward() opt.step() sched.step() # OneCycle меняет lr на каждом шаге seen += len(y) correct += (logits.argmax(1) == y).sum().item() print(f"эпоха {epoch:2d}: train acc {correct / seen:.3f}, test acc {evaluate():.3f}, {time.time() - t0:.0f} с")
torch.save(model.state_dict(), f"{args.model}.pt")Новые детали кода:
argparseчитает параметры командной строки:--model cnn --epochs 10попадут вargs.modelиargs.epochs.torch.autocast(..., dtype=torch.bfloat16)на GPU считает свёртки в 16-битном формате bfloat16 — вдвое меньше памяти и заметно быстрее, о форматах чисел подробно на ступени 5. На CPU он выключен (enabled=False).nn.AdaptiveAvgPool2d(1)— глобальный средний пулингглобальный средний пулингEN global average pooling (GAP)Усредняет каждую карту признаков целиком в одно число: (N, C, H, W) → (N, C). Стоит перед классификатором в ResNet и позволяет подавать картинки любого размера. из прошлого урока.
Запуск python cifar.py --model cnn --epochs 10 на процессоре:
cnn: 1,070,794 параметров, устройство cpuэпоха 1: train acc 0.396, test acc 0.552, 199 сэпоха 2: train acc 0.610, test acc 0.647, 220 сэпоха 3: train acc 0.687, test acc 0.669, 277 сэпоха 4: train acc 0.747, test acc 0.700, 422 сэпоха 5: train acc 0.802, test acc 0.706, 650 сэпоха 6: train acc 0.858, test acc 0.706, 539 сэпоха 7: train acc 0.912, test acc 0.726, 447 сэпоха 8: train acc 0.968, test acc 0.738, 394 сэпоха 9: train acc 0.995, test acc 0.745, 347 сэпоха 10: train acc 0.999, test acc 0.746, 244 сЭто наш прогон на загруженном сервере в два потока, поэтому эпоха шла по 3–10 минут. На 48-ядерном EPYC будет в разы быстрее, а точность совпадёт с точностью до процента. Почти 75% на тесте для сети из двух свёрток — неплохо: случайное угадывание дало бы 10%. Но сравните с train acc: 0.999 против 0.746. Сеть почти идеально запомнила обучающие картинки — это переобучениепереобучениеEN overfitting, и тестовая точность почти перестала расти с 7-й эпохи. Посмотрите и на параметры: из 1 070 794 весов 1 048 832 (98%) приходятся на первый полносвязный слой , а обе свёртки стоят меньше 20 тысяч. Именно этот слой и запоминает обучающие картинки.
Шаг 2. Аугментации, BatchNorm и расписание lr
Заголовок раздела «Шаг 2. Аугментации, BatchNorm и расписание lr»Простая CNN переобучается. Лечим тремя приёмами — каждый вы уже встречали в другой роли.
аугментация данныхаугментация данныхEN data augmentationСлучайные преобразования обучающих примеров, которые не меняют ответ: сдвиг, отражение, изменение цвета. Дают модели «бесплатные» новые примеры и снижают переобучение. Применяют только к обучающей выборке.. Сдвинутая на 4 пикселя или отражённая слева направо кошка — всё ещё кошка. Если перед каждой эпохой случайно сдвигать, отражать и слегка менять цвет обучающих картинок, сеть каждый раз видит немного новые примеры и вынуждена выучить инвариантность к этим преобразованиям. Это регуляризация, как dropout в уроке 3.4.2, только знание о задаче встроено в данные, а не в архитектуру. Аугментации подбирают под задачу: отражение слева направо годится для фотографий, но не для цифр («6» не должна превращаться в зеркальную шестёрку) и не для текста на картинке. К тестовой выборке аугментации не применяют: замер должен идти на настоящих картинках.
пакетная нормализацияпакетная нормализация (BatchNorm)EN batch normalizationНормирует каждый признак по примерам батча, хранит скользящие статистики для инференса (Ioffe, Szegedy, 2015). Стандарт CNN; в LLM не используется. в свёрточной сети нормирует каждый канал по осям : среднее и дисперсия
считаются по всем картинкам батча и всем позициям. У BatchNorm2d(64) 64 пары обучаемых параметров и
64 пары скользящих статистик для инференса. Разница BatchNorm и LayerNorm разобрана в уроке 3.4.1.
В CNN BatchNorm позволяет брать большой learning rate и обучаться в разы быстрее.
Расписание lr. OneCycleLR первую треть обучения поднимает lr от малого значения до max_lr (разогрев), а затем
плавно, по косинусу, опускает почти до нуля. Это расписание learning rateрасписание learning rateEN learning rate scheduleПравило, по которому learning rate меняется по ходу обучения — разогрев, плато, спад. из урока 1.4.2. Разогрев защищает от
взрыва на старте, а спад в конце позволяет «улечься» в минимум. У претрейна LLM расписание устроено так же: разогрев
и долгий спад.
python cifar.py --model cnn_bn --aug --epochs 30 — 6 свёрток с BatchNorm, глобальный пулинг и все три приёма. Эта
сеть в десятки раз дороже простой: на ноутбуке это часы, на 48-ядерном EPYC — порядка получаса, на одной RTX 4080 —
минуты (оценки). Ориентиры (оценки по типичным
результатам таких сетей, у вас числа будут немного другими):
| Модель | Параметров | Приёмы | Test acc (оценка) |
|---|---|---|---|
cnn, 10 эпох | 1.07 млн | — | 74.6% (наш замер) |
cnn --aug, 30 эпох | 1.07 млн | аугментации | ≈ 80% |
cnn_bn --aug, 30 эпох | 1.15 млн | + BatchNorm, глобальный пулинг | ≈ 90% |
resnet --aug, 30 эпох | 11.2 млн | + остаточные связи | ≈ 94–95% |
Обратите внимание: у cnn_bn почти столько же параметров, сколько у простой сети, а точность намного выше.
Параметры переехали из огромного полносвязного слоя в свёртки, где они работают во всех позициях.
Шаг 3. Маленький ResNet
Заголовок раздела «Шаг 3. Маленький ResNet»Остаточный блок вы знаете из урока 3.4.2: . В CNN
ветка — это две свёртки 3×3 с BatchNorm. Одна тонкость: в начале каждой стадии сеть уменьшает карту вдвое (свёртка
с шагом 2) и удваивает число каналов. Тогда и разной формы, и сложить их нельзя. Обход в таких блоках делает
свёртку 1×1 с шагом 2 (shortcut в коде): она приводит к нужной форме.
flowchart LR I["Вход 3×32×32"] --> S["Свёртка 3×3, 64"] S --> L1["Стадия 1: 2 блока<br/>64×32×32"] --> L2["Стадия 2: 2 блока<br/>128×16×16"] L2 --> L3["Стадия 3: 2 блока<br/>256×8×8"] --> L4["Стадия 4: 2 блока<br/>512×4×4"] L4 --> G["Глобальный пулинг<br/>512"] --> FC["Linear → 10"]
Свёрточных слоёв здесь 17 (одна входная свёртка и по две в каждом из 8 блоков), плюс выходной линейный слой — отсюда «18». Свёртки 1×1 на обходе в счёт не идут. ResNet-18 для ImageNet отличается только входом: там свёртка 7×7 с шагом 2 и max pooling сразу уменьшают картинку 224×224 до 56×56. Для картинок 32×32 такое сжатие убило бы все детали, поэтому вход заменили одной свёрткой 3×3.
Глубже: «бутылочный» блок ResNet-50
У ResNet-50 и более глубоких сетей блок-«бутылочное горлышко»блок-«бутылочное горлышко» (bottleneck)EN bottleneck blockОстаточный блок ResNet-50 и глубже: свёртка 1×1 сужает каналы в 4 раза, свёртка 3×3 работает на узком тензоре, свёртка 1×1 расширяет обратно. из трёх свёрток: 1×1 сужает 256 каналов до 64, 3×3 работает на узком тензоре, 1×1 расширяет обратно до 256. Посчитаем веса: две свёртки 3×3 на 256 каналах — млн, бутылочный блок — тыс. Он почти в 17 раз дешевле, поэтому при том же бюджете сеть можно сделать вдвое-втрое глубже.
Обучать ResNet-18 разумно на видеокарте: прямой проход одной картинки 32×32 стоит около 0.56 млрд умножений, а шаг
обучения — втрое больше (прошлый урок). На одной RTX 4080 эпоха займёт порядка 10–20 секунд, 30 эпох — 5–10 минут
(оценка; узким местом легко становится подготовка картинок в DataLoader). Результат — около 94–95% на тесте.
Для сравнения: в известном репозитории kuangliu/pytorch-cifar у ResNet-18 93.02% после 200 эпох с другим расписанием.
Разбор ошибок: матрица ошибок
Заголовок раздела «Разбор ошибок: матрица ошибок»Одно число «точность 75%» не говорит, где сеть ошибается. матрица ошибокматрица ошибокEN confusion matrix из
урока 2.2.3 отвечает на этот вопрос: строка — истинный класс,
столбец — предсказанный. Допишите в конец cifar.py:
# ── Матрица ошибок ──model.eval()cm = torch.zeros(10, 10, dtype=torch.long)with torch.no_grad(): for x, y in test_dl: pred = model(x.to(device)).argmax(1).cpu() for t, p_ in zip(y, pred): cm[t, p_] += 1 # строка — правда, столбец — ответ моделиrecall = cm.diag() / cm.sum(1) # доля верных ответов по каждому классуfor i in recall.argsort(): # от худшего класса к лучшему print(f"{CLASSES[i]:10s} {recall[i]:.2f}")off = cm.clone()off.fill_diagonal_(0) # верные ответы убираем, остаются ошибкиvals, idx = off.flatten().topk(5) # 5 самых частых ошибокfor v, k in zip(vals, idx): print(f"{CLASSES[k // 10]} → {CLASSES[k % 10]}: {v.item()}")Наш прогон простой CNN:
cat 0.56bird 0.64dog 0.66deer 0.69airplane 0.79horse 0.80frog 0.81truck 0.81automobile 0.84ship 0.85cat → dog: 174dog → cat: 153bird → deer: 91automobile → truck: 75deer → bird: 75Хуже всего сеть узнаёт кошек (56%), лучше всего — корабли (85%). Главная путаница — кошка и собака: 174 кошки названы собаками и 153 собаки — кошками. Дальше идут птица и олень (коричневое пятно на зелёном фоне) и автомобиль с грузовиком. Ошибки осмысленные: путаются похожие классы, а не случайные. Это подсказывает, что делать дальше: сети нужно больше деталей (морда, уши), а не больше памяти. Глубокая сеть с аугментациями поднимает именно худшие классы. Тот же разбор ошибок вы будете делать для LLM-грейдеров и классификаторов на ступенях 7–8.
Что видит сеть: фильтры, карты признаков, Grad-CAM
Заголовок раздела «Что видит сеть: фильтры, карты признаков, Grad-CAM»Фильтры первого слоя. Веса первой свёртки — это маленькие картинки: у ResNet-18 с ImageNet 64 ядра 7×7×3. Нарисуйте их:
import matplotlib.pyplot as pltfrom torchvision.models import ResNet18_Weights, resnet18from torchvision.utils import make_grid
model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1).eval() # скачает ≈45 МБ весов при первом запускеw = model.conv1.weight.detach() # (64, 3, 7, 7)lo = w.amin(dim=(1, 2, 3), keepdim=True)hi = w.amax(dim=(1, 2, 3), keepdim=True)grid = make_grid((w - lo) / (hi - lo), nrow=8, padding=1) # каждое ядро растянуть в [0, 1] и склеить сеткой 8×8plt.imshow(grid.permute(1, 2, 0)) # (C, H, W) → (H, W, C) для matplotlibplt.axis("off")plt.show()Вы увидите детекторы краёв разной ориентации — светлые и тёмные полоски под разными углами, — цветовые пятна и «шахматные» узоры. Никто не задавал их руками: градиентный спуск сам пришёл к фильтрам, похожим на фильтр Собеля из прошлого урока. Похожие детекторы краёв нашли и нейрофизиологи в первичной зрительной коре кошек и обезьян.
Карты признаков (карта признаковкарта признаковEN feature map (activation map)Выход одного ядра свёртки — двумерная «картинка» откликов. Слой с C_out ядрами выдаёт C_out карт признаков (каналов).) глубоких слоёв глазами читать трудно: 512 карт 7×7 в последней
стадии. Их можно достать «крючком» — model.layer4.register_forward_hook(f) вызовет функцию f(module, вход, выход)
при каждом прямом проходе. Этим же приёмом пользуется Grad-CAM.
Grad-CAMGrad-CAM (карта внимания по градиентам)EN Gradient-weighted Class Activation Mapping (Grad-CAM)Тепловая карта «куда смотрела сеть»: карты признаков последнего свёрточного слоя складываются с весами — средними градиентами логита класса по каждой карте (Selvaraju et al., 2017). отвечает на вопрос «какая часть картинки убедила сеть, что это класс ?». Пусть — карты признаков последнего свёрточного слоя (), — логит класса. Тогда
Словами: вес карты — насколько в среднем рост этой карты увеличивает логит класса. Складываем карты с такими весами и оставляем положительную часть — места, которые голосуют за класс. Карта 7×7 растягивается до 224×224 и накладывается на картинку.
import torchimport torch.nn.functional as Fimport torchvision.transforms as Tfrom PIL import Image
norm = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])val_tf = T.Compose([T.Resize(256), T.CenterCrop(224), T.ToTensor(), norm])feats = {}
def save_maps(module, inp, out): feats["A"] = out # (1, 512, 7, 7) out.register_hook(lambda g: feats.__setitem__("G", g)) # при backward сюда придёт ∂y_c/∂A
model.layer4.register_forward_hook(save_maps) # model — ResNet-18 из блока вышеimg = Image.open("cat.jpg").convert("RGB") # любая ваша фотографияlogits = model(val_tf(img).unsqueeze(0))c = logits.argmax(1).item()logits[0, c].backward() # градиент логита предсказанного классаA, G = feats["A"][0], feats["G"][0]alpha = G.mean(dim=(1, 2)) # (512,) — средний градиент по каждой картеcam = torch.relu((alpha[:, None, None] * A).sum(0)) # (7, 7)cam = F.interpolate((cam / cam.max())[None, None], size=(224, 224), mode="bilinear")[0, 0]print(ResNet18_Weights.IMAGENET1K_V1.meta["categories"][c]) # имя класса ImageNetplt.imshow(T.CenterCrop(224)(T.Resize(256)(img))) # та же обрезка, что видела сетьplt.imshow(cam.detach(), alpha=0.5, cmap="jet") # тепловая карта поверх фотоplt.axis("off")plt.show()На хорошей фотографии кошки пятно ляжет на морду и уши. Grad-CAM полезен и для отладки: если сеть «узнаёт» корабли по воде, а коров по траве, тепловая карта это покажет. Карта грубая (7×7) и показывает корреляцию, а не причину. Это инструмент для гипотез, а не доказательство.
Перенос обучения
Заголовок раздела «Перенос обучения»У вас 100 фотографий трёх видов кружек, или ваших кошек, или листьев с болезнями. Обучать ResNet с нуля на 300 картинках бесполезно: 11 миллионов параметров выучат шум. Но сеть, обученная на 1.3 млн картинок ImageNet, уже умеет находить края, текстуры, части объектов. Эти признаки годятся почти для любых фотографий. Нужно только научить последний слой новым классам. Это перенос обученияперенос обученияEN transfer learningБерём модель, обученную на большой задаче (ImageNet), и дообучаем на своей маленькой. Признаки нижних слоёв (края, текстуры) универсальны и переносятся..
Сеть делят на основу (backbone)основа сети (backbone)EN backboneЧасть сети, которая извлекает признаки (например, ResNet без последнего линейного слоя). К ней приставляют «голову» под конкретную задачу: классификацию, детекцию, сегментацию. — всё до последнего линейного слоя — и «голову». Голову заменяют на новую под ваши классы, а дальше два режима:
- линейный зондлинейный зонд (linear probing)EN linear probingВариант переноса обучения: веса предобученной сети заморожены, обучается только новый линейный слой поверх её признаков. Быстро и устойчиво на маленьких данных.. Основу замораживают (
requires_grad = False), учат только голову. Это быстро, устойчиво и годится, когда картинок десятки. - Полное дообучение (fine-tuning). Учатся все веса, но основа — с маленьким шагом (например, ), а голова — с большим (). Так предобученные признаки подстраиваются, но не разрушаются. В d2l-zh (13.2) шаг головы тоже в 10 раз больше.
Соберите датасет в папки — по папке на класс, имя папки станет именем класса:
my_data/ train/ cat/ 001.jpg … dog/ … horse/ … val/ cat/ … dog/ … horse/ …Если своих фотографий нет, возьмите три класса CIFAR-10 по 100 картинок:
"""Нет своих фото? Соберём маленький датасет из трёх классов CIFAR-10:по 100 картинок на класс для обучения и для проверки, в той же структуре папок."""import os
import torchvision
classes = {"cat": 3, "dog": 5, "horse": 7} # имя папки → номер класса в CIFAR-10for split, train in [("train", True), ("val", False)]: ds = torchvision.datasets.CIFAR10("data", train=train, download=True) for name, idx in classes.items(): os.makedirs(f"my_data/{split}/{name}", exist_ok=True) picked = [i for i, y in enumerate(ds.targets) if y == idx][:100] # первые 100 картинок класса for k, i in enumerate(picked): img, _ = ds[i] # ds[i] → (картинка PIL, метка) img.save(f"my_data/{split}/{name}/{k:03d}.png")Сам перенос — скрипт transfer.py с четырьмя режимами:
"""Перенос обучения: ResNet-18 с ImageNet → свои 3 класса по 100 картинок.
Запуск: python transfer.py scratch # та же сеть со случайными весами python transfer.py probe # заморозить всё, учить только новый линейный слой python transfer.py finetune # дообучить всю сеть с маленьким lr python transfer.py finetune_bn # то же, но статистики BatchNorm заморожены"""import sys
import torchimport torch.nn as nnimport torch.nn.functional as Fimport torchvision.transforms as Tfrom torchvision.datasets import ImageFolderfrom torchvision.models import ResNet18_Weights, resnet18
mode = sys.argv[1]torch.manual_seed(0)device = "cuda" if torch.cuda.is_available() else "cpu"
# Предобработка ровно как при обучении на ImageNet: 224×224 и нормировка средними ImageNetnorm = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])train_tf = T.Compose([T.RandomResizedCrop(224, scale=(0.6, 1.0)), T.RandomHorizontalFlip(), T.ToTensor(), norm])val_tf = T.Compose([T.Resize(256), T.CenterCrop(224), T.ToTensor(), norm])train_ds = ImageFolder("my_data/train", train_tf) # класс = имя папки: cat, dog, horseval_ds = ImageFolder("my_data/val", val_tf)train_dl = torch.utils.data.DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2)val_dl = torch.utils.data.DataLoader(val_ds, batch_size=64, num_workers=2)n_classes = len(train_ds.classes)
if mode == "scratch": model = resnet18(weights=None) # случайные весаelse: model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) # скачает ≈45 МБ при первом запускеmodel.fc = nn.Linear(model.fc.in_features, n_classes) # новая «голова»: 512 признаков → 3 классаmodel = model.to(device)
if mode == "probe": for name, p in model.named_parameters(): p.requires_grad = name.startswith("fc.") # учим только голову opt = torch.optim.AdamW(model.fc.parameters(), lr=1e-3)elif mode.startswith("finetune"): backbone = [p for n, p in model.named_parameters() if not n.startswith("fc.")] opt = torch.optim.AdamW([{"params": backbone, "lr": 1e-4}, # основа — маленький шаг {"params": model.fc.parameters(), "lr": 1e-3}]) # голова — в 10 раз большеelse: opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
for epoch in range(1, 11): model.train() if mode in ("probe", "finetune_bn"): model.eval() # BatchNorm берёт сохранённые статистики ImageNet и не пересчитывает их по нашим батчам for x, y in train_dl: loss = F.cross_entropy(model(x.to(device)), y.to(device)) opt.zero_grad() loss.backward() opt.step() model.eval() with torch.no_grad(): correct = sum((model(x.to(device)).argmax(1).cpu() == y).sum().item() for x, y in val_dl) print(f"{mode:11s} эпоха {epoch:2d}: loss {loss.item():.3f}, val acc {correct / len(val_ds):.3f}")ImageFolder сам находит классы по именам папок. opt с двумя группами параметров задаёт разный шаг основе и голове.
Это тот же механизм групп, что для weight decay в уроке 1.4.2.
Наш прогон на трёх классах CIFAR-10 (кошка, собака, лошадь, по 100 картинок; случайное угадывание — 33%):
| Режим | val acc после 10 эпох | лучшая эпоха |
|---|---|---|
scratch — ResNet-18 со случайными весами | 44.7% | 47.0% |
probe — линейный зонд | 69.0% | 69.7% |
finetune — полное дообучение | 62.3% | 62.3% |
finetune_bn — полное дообучение, BatchNorm заморожен | 72.7% | 75.0% |
Что здесь видно:
- С нуля сеть почти не учится. 11 миллионов параметров на 300 картинках — чуть лучше монетки.
- Линейный зонд сразу даёт +25 пунктов. Обучается только слой , то есть 1539 чисел, а всю работу делают признаки ImageNet.
- Полное дообучение «в лоб» оказалось хуже зонда. Виноват BatchNorm. В режиме
trainон нормирует по статистикам текущего батча из 32 растянутых картинок 32×32, не похожих на ImageNet, и сбивает признаки, к которым привыкли следующие слои. Если заморозить статистики (model.eval()при обучении — веса при этом по-прежнему учатся), полное дообучение становится лучшим режимом. - Числа шумят. На 300 проверочных картинках стандартная ошибка точности около , то есть разница в 2–3 пункта — в пределах шума. Доверительные интервалы вы считали в уроке 1.3.4.
Две оговорки. Мы проверяли код с другими весами ResNet-18 для ImageNet (из библиотеки timm), так что у вас числа будут немного другими. И картинки CIFAR размером 32×32 после растяжения до 224×224 очень размыты. На настоящих фотографиях трёх непохожих классов зонд и дообучение обычно дают 90% и больше (оценка). Проверьте на своих.
Не только классификация: детекция и сегментация
Заголовок раздела «Не только классификация: детекция и сегментация»детекция объектовдетекция объектовEN object detectionНайти на картинке все объекты: для каждого — рамку и класс. Семейства моделей: двухстадийные (Faster R-CNN), одностадийные (YOLO), трансформерные (DETR). — найти все объекты на картинке: для каждого ограничивающая рамкаограничивающая рамкаEN bounding boxПрямоугольник вокруг объекта, обычно (x1, y1, x2, y2) — левый верхний и правый нижний углы. и класс. Качество рамки меряют через IoUIoU (пересечение на объединение)EN intersection over union (IoU)Площадь пересечения двух рамок, делённая на площадь их объединения: 1 — рамки совпадают, 0 — не пересекаются. Предсказание обычно считают верным при IoU ≥ 0.5. — площадь пересечения, делённую на площадь объединения:
Пример: и — квадраты 4×4, пересечение — квадрат 2×2. IoU . Если же , пересечение и IoU . Предсказание обычно засчитывают, если IoU с правильной рамкой не меньше 0.5.
Как устроены детекторы:
- Двухстадийные (Faster R-CNN, 2015): сначала сеть предлагает несколько сотен «областей, где может быть объект», потом каждую классифицирует и уточняет рамку.
- YOLO («You Only Look Once», Редмон и соавт., 2016): одна свёрточная сеть делит картинку на сетку и в каждой клетке сразу предсказывает рамки, уверенность и класс. Один прямой проход — поэтому быстро, вплоть до видео в реальном времени. Соседние клетки предсказывают одну и ту же собаку много раз, и дубли вычищает подавление немаксимумовподавление немаксимумов (NMS)EN non-maximum suppression (NMS)Чистка дублей в детекции: берём рамку с наибольшей уверенностью, выкидываем все рамки, которые перекрываются с ней сильнее порога IoU, повторяем.: берём самую уверенную рамку, выкидываем все рамки, которые перекрываются с ней сильнее порога IoU, повторяем.
- DETR (Facebook, 2020): CNN-основа плюс трансформер. Он выдаёт фиксированный набор, например 100 «слотов»- предсказаний, и на обучении каждый объект однозначно сопоставляется одному слоту. Дублей не возникает, NMS не нужен. Это первый шаг к тому, чтобы зрение тоже делал трансформер (урок 9.1.1).
семантическая сегментациясемантическая сегментацияEN semantic segmentationКлассификация каждого пикселя: выход сети — карта классов того же размера, что картинка. Классическая архитектура — U-Net. — классифицировать каждый пиксель: дорога, машина, пешеход, небо. Выход сети — карта того же размера, что вход. Классическая архитектура — U-NetU-NetEN U-Netсвёрточная сеть «сжать — разжать» с обходными связями; классический денойзер диффузии до DiT (Роннебергер, Фишер и Брокс, 2015; придумана для сегментации клеток на снимках микроскопа):
flowchart LR X["Вход 64×64"] --> E1["Энкодер 1<br/>64×64, w"] E1 -->|"пулинг"| E2["Энкодер 2<br/>32×32, 2w"] E2 -->|"пулинг"| M["Дно<br/>16×16, 4w"] M -->|"ConvTranspose ×2"| D2["Декодер 2<br/>32×32"] E2 -.->|"skip: склейка"| D2 D2 -->|"ConvTranspose ×2"| D1["Декодер 1<br/>64×64"] E1 -.->|"skip: склейка"| D1 D1 --> H["Свёртка 1×1<br/>класс каждого пикселя"]
- Энкодер — обычная CNN: свёртки и пулинг. Карта уменьшается, каналов становится больше, рецептивное поле растёт, и сеть понимает «что» на картинке, но теряет «где именно».
- Декодер увеличивает карту обратно транспонированными свёрткамитранспонированная свёрткаEN transposed convolutionОперация, которая увеличивает карту признаков (например, 16×16 → 32×32). Математически это обратный проход обычной свёртки по входу; в PyTorch — nn.ConvTranspose2d. Встречается в декодере U-Net. — это обратный проход свёртки из прошлого урока, превращённый в слой.
- Skip-связи склеивают (
torch.catпо каналам) карты энкодера и декодера одного размера. Мелкие детали — точные края — приходят с ранних слоёв напрямую, а смысл — снизу. Это не сложение, как в ResNet, а склейка: декодер сам решает, как смешать.
import torchimport torch.nn as nn
def double_conv(c_in, c_out): """Две свёртки 3×3 с BatchNorm и ReLU — «кирпич» U-Net; размер карты не меняется.""" return nn.Sequential( nn.Conv2d(c_in, c_out, 3, padding=1, bias=False), nn.BatchNorm2d(c_out), nn.ReLU(), nn.Conv2d(c_out, c_out, 3, padding=1, bias=False), nn.BatchNorm2d(c_out), nn.ReLU(), )
class TinyUNet(nn.Module): """U-Net с двумя уровнями: энкодер сжимает 64 → 16, декодер разжимает обратно, skip-связи склеивают уровни."""
def __init__(self, c_in=3, n_classes=2, w=32): super().__init__() self.enc1 = double_conv(c_in, w) # 64×64, w каналов self.enc2 = double_conv(w, 2 * w) # 32×32 self.mid = double_conv(2 * w, 4 * w) # 16×16 — «дно» буквы U self.pool = nn.MaxPool2d(2) self.up2 = nn.ConvTranspose2d(4 * w, 2 * w, kernel_size=2, stride=2) # 16 → 32 self.dec2 = double_conv(4 * w, 2 * w) # 4w = 2w (из up2) + 2w (skip из enc2) self.up1 = nn.ConvTranspose2d(2 * w, w, kernel_size=2, stride=2) # 32 → 64 self.dec1 = double_conv(2 * w, w) self.head = nn.Conv2d(w, n_classes, 1) # свёртка 1×1: класс для каждого пикселя
def forward(self, x): e1 = self.enc1(x) # (B, w, 64, 64) e2 = self.enc2(self.pool(e1)) # (B, 2w, 32, 32) m = self.mid(self.pool(e2)) # (B, 4w, 16, 16) d2 = self.dec2(torch.cat([self.up2(m), e2], dim=1)) # склейка по каналам — skip-связь d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.head(d1) # (B, n_classes, 64, 64)
net = TinyUNet()x = torch.randn(4, 3, 64, 64)print(net(x).shape, f"{sum(p.numel() for p in net.parameters()):,} параметров")torch.Size([4, 2, 64, 64]) 467,202 параметровОбучим её на синтетической задаче, для которой не нужно ничего скачивать: на сильно зашумлённом фоне 1–3 круга, и нужно отметить пиксели внутри кругов. Сравним с наивным порогом по яркости:
import torchimport torch.nn.functional as F
torch.manual_seed(0)
def make_batch(B=16, S=64): """Синтетическая сегментация: на шумном фоне 1–3 круга; маска — пиксели внутри кругов.""" yy, xx = torch.meshgrid(torch.arange(S), torch.arange(S), indexing="ij") # координаты всех пикселей mask = torch.zeros(B, S, S, dtype=torch.long) for b in range(B): for _ in range(torch.randint(1, 4, ()).item()): cx, cy = torch.randint(8, S - 8, (2,)) r = torch.randint(4, 12, ()) mask[b] |= ((xx - cx) ** 2 + (yy - cy) ** 2 <= r ** 2).long() # |= — «или»: добавить круг к маске img = mask.float() * 0.6 + 0.5 * torch.randn(B, S, S) # круги чуть ярче, но фон сильно шумит return img[:, None], mask # (B, 1, S, S) и (B, S, S)
net = TinyUNet(c_in=1, n_classes=2, w=16)opt = torch.optim.Adam(net.parameters(), lr=3e-3)for step in range(1, 301): x, y = make_batch() loss = F.cross_entropy(net(x), y) # cross-entropy по каждому пикселю: (B, 2, S, S) против (B, S, S) opt.zero_grad() loss.backward() opt.step() if step % 100 == 0: net.eval() with torch.no_grad(): x, y = make_batch(64) pred = net(x).argmax(1) iou = ((pred == 1) & (y == 1)).sum() / ((pred == 1) | (y == 1)).sum() thr = (x[:, 0] > 0.3).long() # наивный порог по яркости для сравнения iou_thr = ((thr == 1) & (y == 1)).sum() / ((thr == 1) | (y == 1)).sum() net.train() print(f"шаг {step}: loss {loss.item():.3f}, IoU U-Net {iou:.2f}, IoU порога яркости {iou_thr:.2f}")шаг 100: loss 0.055, IoU U-Net 0.87, IoU порога яркости 0.20шаг 200: loss 0.028, IoU U-Net 0.91, IoU порога яркости 0.19шаг 300: loss 0.026, IoU U-Net 0.91, IoU порога яркости 0.19IoU здесь — то же пересечение на объединение, только для множеств пикселей. Порог по яркости тонет в шуме: каждый пиксель решает сам за себя. U-Net смотрит на окрестность и уже через сотню шагов выделяет круги с IoU около 0.9. На CPU это несколько минут, на GPU — секунды.
Именно такая сеть, только глубже и с подмешиванием номера шага зашумления, была денойзером Stable Diffusion: на входе зашумлённая картинка, на выходе — предсказанный шум того же размера. Её сменил трансформер DiT, но идея «вход и выход одного размера, детали — по skip-связям» осталась. Подробно — в уроке 9.1.2.
Практика
Заголовок раздела «Практика»В браузере: матрица ошибок, IoU и NMS
Заголовок раздела «В браузере: матрица ошибок, IoU и NMS»Реализуйте на NumPy инструменты, которые встретились в уроке: матрицу ошибок с полнотой по классам и самыми частыми путаницами, IoU рамок и подавление немаксимумов. Именно NMS чистит выход YOLO.
import numpy as np
def confusion_matrix(y_true, y_pred, n_classes):
"""Матрица ошибок формы (n_classes, n_classes): cm[t, p] — сколько примеров класса t модель отнесла к классу p.
Строки — истинные классы, столбцы — предсказанные; верные ответы стоят на диагонали.
Подсказка: np.add.at(cm, (y_true, y_pred), 1) прибавляет 1 в каждую пару (t, p) и учитывает повторы.
"""
# TODO
...
def per_class_recall(cm):
"""Для каждого класса — доля его примеров, распознанных верно: диагональ / сумма строки."""
# TODO
...
def top_confusions(cm, classes, k=3):
"""k самых частых ошибок: список кортежей (истинный класс, предсказанный класс, число), по убыванию числа.
Диагональ (верные ответы) не учитывайте. classes — список имён классов.
Подсказка: обнулите диагональ копии (np.fill_diagonal), np.argsort(-off, axis=None) отсортирует
«расплющенную» матрицу; индекс i в ней — это строка i // n и столбец i % n.
"""
# TODO
...
def box_iou(box, boxes):
"""IoU рамки box = (x1, y1, x2, y2) с каждой рамкой массива boxes формы (M, 4). Возвращает массив (M,).
Пересечение двух рамок — тоже прямоугольник: левый верхний угол — поэлементный максимум углов,
правый нижний — минимум. Если рамки не пересекаются, ширина или высота выйдет отрицательной — обрежьте до 0.
IoU = пересечение / (площадь box + площадь рамки − пересечение).
"""
# TODO
...
def nms(boxes, scores, iou_threshold=0.5):
"""Подавление немаксимумов. Возвращает список индексов оставленных рамок в порядке убывания scores.
Алгоритм: отсортировать рамки по убыванию уверенности; пока список не пуст — взять первую (лучшую),
добавить её в ответ и выкинуть из списка все рамки, у которых IoU с ней больше iou_threshold.
"""
# TODO
...
# ── Проверка для кнопки «Запустить» ──
classes = ["кошка", "собака", "лошадь"]
y_true = np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2])
y_pred = np.array([0, 0, 1, 1, 1, 1, 1, 0, 2, 2, 2, 1])
cm = confusion_matrix(y_true, y_pred, 3)
print(cm)
if cm is not None:
print("recall по классам:", per_class_recall(cm))
print("частые ошибки:", top_confusions(cm, classes, k=2))
print("IoU:", box_iou(np.array([0, 0, 4, 4]), np.array([[2, 2, 6, 6], [1, 0, 5, 4]]))) # ожидается [0.143 0.6]
boxes = np.array([[10, 10, 50, 50], [12, 8, 52, 48], [60, 60, 90, 90], [11, 12, 49, 51]])
print("NMS оставил:", nms(boxes, np.array([0.9, 0.8, 0.7, 0.6]), 0.5)) # ожидается [0, 2]
Локально: CIFAR-10 и перенос обучения
Заголовок раздела «Локально: CIFAR-10 и перенос обучения»| Уровень | Что сделать | Порог (test / val acc) |
|---|---|---|
| 🥉 Simple | cnn, 10 эпох на CPU | ≥ 70% |
| 🥈 Medium | cnn_bn --aug, 30 эпох (GPU или многоядерный CPU) | ≥ 88% |
| 🥇 Strong | resnet --aug на одной RTX 4080 | ≥ 93% |
| 🐉 Boss | ≥ 94% быстрее чем за 1 минуту обучения на одной карте | изучите приёмы airbench |
| ✳️ Перенос | все четыре режима transfer.py на своих 3 классах | своя таблица, как выше, + Grad-CAM на 3 фото |
Порог Simple проверен нашим прогоном на CPU. Пороги Medium и Strong — оценки по типичным результатам
таких сетей с запасом. Для Boss посмотрите CIFAR-10 Airbench: там
94% достигаются за ≈2.6 секунды на A100. Приёмы: аугментации и загрузка данных прямо на GPU, тщательно подобранная
узкая сеть, оптимизатор Muon из урока 1.4.2, вывод по картинке и её отражению. Прочитайте airbench94_muon.py —
это около 450 строк, и почти каждую из них вы теперь можете прочитать.
Проверь себя
Заголовок раздела «Проверь себя»- CIFAR-10 — быстрый полигон: простая CNN на CPU даёт около 75%, CNN с BatchNorm и аугментациями — около 90%, ResNet-18 на одной RTX 4080 за минуты — 94–95%. Почти все параметры простой сети сидят в полносвязном слое, а глобальный пулинг переносит их в свёртки.
- Аугментации — регуляризация знанием о задаче, BatchNorm в CNN нормирует каналы по и разрешает большой lr, OneCycle — разогрев и косинусный спуск. Матрица ошибок и Grad-CAM показывают, где и почему сеть ошибается.
- Перенос обучения: замените голову предобученной сети. Линейный зонд — заморозить основу (в
eval()), полное дообучение — маленький lr для основы, большой для головы и (на маленьких данных) замороженные статистики BatchNorm. Предобработка должна совпадать с той, что была при обучении. - Детекция — рамки, IoU и NMS (YOLO) или набор слотов без NMS (DETR). Сегментация — U-Net: энкодер, декодер на транспонированных свёртках и skip-склейки. Такая сеть — денойзер диффузии до DiT.
Дальше: ступень 4 — языковые модели. Начнём с вопроса, что такое языковая модель и как из «предсказателя следующего слова» получается ChatGPT или DeepSeek, а затем шаг за шагом соберём GPT своими руками.