Свёрточные сети и компьютерное зрение
В прошлой статье мы научились обучать произвольную сеть: оптимизаторы, инициализация, нормализация, регуляризация. Всё это работает для любой архитектуры. Но архитектура — не пустое место: она вшивает в модель априорное знание о структуре данных. Первый по-настоящему успешный пример такого знания — свёртка.
1. Почему полносвязная сеть на картинке безнадёжна
Возьмём RGB-изображение 224×224. Развернём в вектор: 224 · 224 · 3 = 150 528 чисел. Подадим на полносвязный слой с 4096 нейронами — получим ≈ 616 миллионов весов в одном слое, 2.4 ГБ в float32. Но проблема даже не в памяти, а в трёх концептуальных вещах:
- Нет понятия «рядом». Развернув картинку в вектор, вы сообщили сети, что пиксели
(10,10)и(10,11)связаны не сильнее, чем первый и последний. Всю пространственную структуру — то есть суть изображения — сеть должна восстановить из данных с нуля. - Нет разделения знаний между позициями. Детектор вертикального края для левого верхнего угла — отдельный набор весов. Для правого нижнего придётся выучить такой же заново, из других примеров.
- Ноль устойчивости к сдвигу. Сдвиньте кота на 5 пикселей — вход меняется полностью. Сеть будет запоминать кота в каждой позиции отдельно.
Свёртка чинит ровно эти три вещи, вводя три индуктивных смещения (inductive biases):
| Смещение | Что означает | Что даёт |
|---|---|---|
| Локальность | нейрон смотрит только на маленькое окно | параметров O(k²), а не O(H·W) |
| Разделение весов | одно ядро применяется во всех позициях | пример из любого угла обучает все позиции |
| Эквивариантность к сдвигу | сдвиг входа ⇒ такой же сдвиг выхода | не нужно учить объект в каждой позиции |
Это классический размен: мы сужаем класс представимых функций и получаем взамен на порядки лучшее обобщение — на данных, где эти предположения верны. На изображениях они верны почти всегда; на табличных данных со случайным порядком колонок — нет, и свёртка там бесполезна.
Свёртка эквивариантна к сдвигу (сдвинулся вход — сдвинулся выход), а не инвариантна (выход не изменился). Инвариантность появляется позже — из пулинга, страйдов и глобального усреднения в конце сети.
2. Свёртка строго
Для входа x и ядра w размера K×K фреймворки считают:
$$y[i, j] = \sum_{u=0}^{K-1} \sum_{v=0}^{K-1} x[i \cdot S + u,\ j \cdot S + v] \cdot w[u, v] + b$$
Педантичное замечание: это кросс-корреляция, у настоящей свёртки ядро переворачивается (w[K-1-u, K-1-v]). Поскольку ядро обучаемое, разница сводится к перестановке весов. Все фреймворки реализуют кросс-корреляцию и называют её свёрткой.
Многоканальность. Реальный слой работает с тензором (N, C_in, H, W), ядро — четырёхмерный тензор (C_out, C_in, K, K). Каждый из C_out фильтров проходит по всем входным каналам сразу, складывает результат по каналам в одно число и порождает одну карту признаков. Отсюда число параметров:
$$P = C_{out} \cdot (C_{in} \cdot K \cdot K) + C_{out}$$
Для 3 → 64, K=3: 64 · 27 + 64 = 1792 веса — против 616 миллионов у полносвязного. И этот слой применим к картинке любого разрешения.
Гиперпараметры:
| Параметр | Смысл | Типично |
|---|---|---|
padding (P) |
сколько нулей дописать по краям | K//2 — сохраняет размер |
stride (S) |
шаг скольжения окна | 1, либо 2 для даунсэмплинга |
dilation (D) |
«дырки» в ядре | 1, либо 2–16 в сегментации |
groups (G) |
разбиение каналов на независимые группы | 1, либо C_in (depthwise) |
Формулу размера выхода нужно знать наизусть — 90% ошибок сборки архитектур это несведённые размерности:
$$O = \left\lfloor \frac{I + 2P - D \cdot (K - 1) - 1}{S} \right\rfloor + 1$$
Частые случаи: K=3, P=1, S=1 сохраняет размер («same»); K=3, P=1, S=2 делит пополам; K=1, P=0, S=1 сохраняет размер и работает только вдоль каналов. Про последний отдельно: свёртка 1×1 — это полносвязный слой, применённый независимо к каждой позиции. Она не смотрит на соседей, зато дёшево меняет и смешивает каналы. Из неё выросли bottleneck-блоки ResNet, Inception и вся современная экономия FLOPs.
Сложность. Для одного слоя (MAC — умножение-сложение):
$$\text{MACs} = H_{out} \cdot W_{out} \cdot C_{out} \cdot C_{in} \cdot K \cdot K$$
По времени — O(H·W·C_in·C_out·K²), по памяти на веса — O(C_in·C_out·K²), на активации при обучении — O(N·C·H·W) на каждый сохраняемый для backward тензор. Отсюда вывод, который многих удивляет: память при обучении CNN съедают не веса, а активации. ResNet-50 — это 25.6M параметров (≈100 МБ), но при батче 256 активации всех слоёв дают десятки гигабайт. Batch size упирается в память раньше, чем размер модели.
3. Пишем свёртку руками
Наивная реализация — прямо по формуле; медленная, но её полезно один раз написать, чтобы формула перестала быть абстракцией.
import numpy as np
def conv2d_naive(x, w, b, stride=1, padding=0):
"""x: (N, C_in, H, W); w: (C_out, C_in, K, K); b: (C_out,)
Сложность: O(N * C_out * H_out * W_out * C_in * K^2)."""
N, C_in, H, W = x.shape
C_out, _, K, _ = w.shape
# нулевой паддинг только по пространственным осям
xp = np.pad(x, ((0, 0), (0, 0), (padding, padding), (padding, padding)))
H_out = (H + 2 * padding - K) // stride + 1
W_out = (W + 2 * padding - K) // stride + 1
out = np.zeros((N, C_out, H_out, W_out), dtype=x.dtype)
for n in range(N):
for co in range(C_out):
for i in range(H_out):
for j in range(W_out):
hs, ws = i * stride, j * stride
patch = xp[n, :, hs:hs + K, ws:ws + K] # (C_in, K, K)
out[n, co, i, j] = np.sum(patch * w[co]) + b[co]
return out
Пять вложенных циклов на Python неприемлемы. Реальные библиотеки сводят свёртку к матричному умножению трюком im2col: разворачивают все окна в строки большой матрицы и делают один GEMM, который на GPU идёт на пике производительности.
def conv2d_im2col(x, w, b, stride=1, padding=0):
"""Та же математика через один GEMM — в сотни раз быстрее."""
N, C_in, H, W = x.shape
C_out, _, K, _ = w.shape
xp = np.pad(x, ((0, 0), (0, 0), (padding, padding), (padding, padding)))
H_out = (H + 2 * padding - K) // stride + 1
W_out = (W + 2 * padding - K) // stride + 1
# каждое окно становится строкой длины C_in*K*K
cols = np.empty((N, H_out, W_out, C_in * K * K), dtype=x.dtype)
for i in range(H_out):
for j in range(W_out):
hs, ws = i * stride, j * stride
cols[:, i, j, :] = xp[:, :, hs:hs + K, ws:ws + K].reshape(N, -1)
out = cols.reshape(-1, C_in * K * K) @ w.reshape(C_out, -1).T + b
return out.reshape(N, H_out, W_out, C_out).transpose(0, 3, 1, 2)
Сверяться с эталоном при ручной реализации — обязательная привычка:
import torch, torch.nn.functional as F
rng = np.random.default_rng(0)
x = rng.standard_normal((2, 3, 16, 16)).astype(np.float32)
w = rng.standard_normal((8, 3, 3, 3)).astype(np.float32)
b = rng.standard_normal(8).astype(np.float32)
mine = conv2d_im2col(x, w, b, stride=2, padding=1)
ref = F.conv2d(*map(torch.from_numpy, (x, w, b)), stride=2, padding=1).numpy()
print(mine.shape, np.abs(mine - ref).max() < 1e-4) # (2, 8, 8, 8) True
Цена im2col — память: каждый пиксель дублируется K² раз. Поэтому cuDNN и oneDNN выбирают между im2col+GEMM, прямыми свёртками, алгоритмом Винограда (быстрее для 3×3) и FFT (для больших ядер). Именно этот перебор включает torch.backends.cudnn.benchmark = True.
Backward свёртки — это тоже свёртка, только с перевёрнутым ядром; вывод разобран в статье о backprop.
4. Пулинг и понижение разрешения
Свёртка эквивариантна, но в конце нам нужна инвариантность: «кот есть» независимо от того, где он. Её добывают понижением разрешения.
- Max pooling 2×2, stride 2 — максимум в окне: локальная устойчивость к малым сдвигам, акцент на «сработал ли детектор вообще».
- Average pooling — мягче, внутри сети используется реже.
- Strided convolution — свёртка со
stride=2вместо пулинга. С 2015 года побеждает: даунсэмплинг становится обучаемым. - Global Average Pooling — усреднение по всей карте в конце. Заменил гигантские полносвязные головы (в VGG-16 на них уходило 90% из 138M параметров) и снял привязку к фиксированному разрешению входа.
Общая логика: вглубь сети разрешение падает, число каналов растёт: 224²×3 → 56²×64 → 28²×128 → 14²×256 → 7²×512. Информация перетекает из «где» в «что».
5. Рецептивное поле — величина, о которой забывают
Рецептивное поле (RF) нейрона — область входа, которая на него влияет. Если сеть должна отличать грузовик от легковушки, а RF последнего слоя — 60 пикселей на входе 512×512, она физически не видит объект целиком.
$$r_\ell = r_{\ell-1} + (k_\ell - 1) \cdot j_{\ell-1}, \qquad j_\ell = j_{\ell-1} \cdot s_\ell$$
где r — размер RF, j (jump) — расстояние между соседними позициями выхода в пикселях входа.
def receptive_field(layers):
"""layers: список (kernel, stride, dilation). -> (RF, суммарный страйд)"""
rf, jump = 1, 1
for k, s, d in layers:
k_eff = d * (k - 1) + 1 # эффективный размер ядра с дилатацией
rf += (k_eff - 1) * jump
jump *= s
return rf, jump
print(receptive_field([(3, 1, 1)] * 5)) # (11, 1) линейный рост
print(receptive_field([(3, 1, 1), (3, 2, 1)] * 4)) # (91, 16) страйды — геометрический
print(receptive_field([(3, 1, 2 ** i) for i in range(5)])) # (63, 1) дилатация без потери разрешения
Три вывода:
- Стек маленьких ядер лучше одного большого. Три 3×3 дают RF 7×7, но стоят
27C²весов вместо49C²и содержат три нелинейности вместо одной. Это центральный аргумент VGG (arXiv:1409.1556), после которого ядра 7×7 и 11×11 исчезли из середины сетей. - Dilated (atrous) свёртки (arXiv:1511.07122) наращивают RF экспоненциально, не понижая разрешение — незаменимы в сегментации, где на выходе нужна карта в исходном масштабе.
- Эффективное RF меньше теоретического: влияние распределено гауссиански от центра, края почти не работают (arXiv:1701.04128). Практическое правило — закладывайте теоретическое RF в 2–3 раза больше детектируемых объектов.
6. Анатомия современной свёрточной сети
Почти все CNN после 2015 года собраны по одной схеме: stem → стадии с понижением разрешения → голова.
карты сразу с нескольких стадий"] ST4 -.-> DET
Базовый кирпич — тройка Conv → Norm → Activation, и порядок именно такой: без нормализации масштабы активаций разъезжаются, а нормализация после нелинейности работает хуже. bias в свёртке перед BatchNorm бесполезен (BN его вычтет) — поэтому пишут bias=False. Детали про BN/GN — в статье об обучении сетей.
6.1 Остаточный блок
Главное изобретение десятилетия — skip connection из ResNet (arXiv:1512.03385). Наблюдение авторов было контринтуитивным: 56-слойная обычная сеть работала хуже 20-слойной не только на валидации, но и на обучении. Это не переобучение, а проблема оптимизации: глубокой сети трудно выучить даже тождественное отображение. Решение: пусть блок учит не H(x), а остаток F(x) = H(x) − x, а выход будет F(x) + x. Тождественное отображение достигается занулением весов, то есть находится тривиально, а градиент получает «магистраль» и не затухает.
import torch.nn as nn
class Bottleneck(nn.Module):
"""Блок ResNet-50/101/152: 1×1 сжатие → 3×3 работа → 1×1 расширение."""
expansion = 4
def __init__(self, in_ch, mid_ch, stride=1):
super().__init__()
out_ch = mid_ch * self.expansion
self.conv1 = nn.Conv2d(in_ch, mid_ch, 1, bias=False) # дёшево сжали каналы
self.bn1 = nn.BatchNorm2d(mid_ch)
self.conv2 = nn.Conv2d(mid_ch, mid_ch, 3, stride=stride, # единственная дорогая свёртка
padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(mid_ch)
self.conv3 = nn.Conv2d(mid_ch, out_ch, 1, bias=False) # вернули размерность
self.bn3 = nn.BatchNorm2d(out_ch)
self.relu = nn.ReLU(inplace=True)
# проекция нужна, только если меняются разрешение или число каналов
self.downsample = None
if stride != 1 or in_ch != out_ch:
self.downsample = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_ch))
# трюк из Bag of Tricks: зануляем последний BN — блок стартует
# как тождественное отображение, обучение заметно стабильнее
nn.init.zeros_(self.bn3.weight)
def forward(self, x):
identity = x if self.downsample is None else self.downsample(x)
out = self.relu(self.bn1(self.conv1(x)))
out = self.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
out += identity # суммирование, а не конкатенация
return self.relu(out) # ReLU ПОСЛЕ сложения
Две детали, которые в туториалах часто теряют: ReLU применяется после сложения, и последний BN инициализируется нулями (arXiv:1812.01187).
6.2 Depthwise separable свёртка
Второй по важности приём — разделение свёртки на два дешёвых шага (MobileNet, arXiv:1704.04861): depthwise K×K применяется к каждому каналу независимо (groups=C_in) и смешивает пространство, pointwise 1×1 смешивает каналы, не глядя на соседей.
def depthwise_separable(in_ch, out_ch, stride=1):
return nn.Sequential(
# groups=in_ch — ключевой аргумент: каждый канал сам по себе
nn.Conv2d(in_ch, in_ch, 3, stride=stride, padding=1, groups=in_ch, bias=False),
nn.BatchNorm2d(in_ch), nn.ReLU6(inplace=True),
nn.Conv2d(in_ch, out_ch, 1, bias=False),
nn.BatchNorm2d(out_ch), nn.ReLU6(inplace=True))
Экономия считается точно. Обычная свёртка: C_in · C_out · K² · H · W. Разделимая: C_in · K² · H · W + C_in · C_out · H · W. Отношение:
$$\frac{1}{C_{out}} + \frac{1}{K^2} \approx \frac{1}{9} \quad (K = 3)$$
То есть в 8–9 раз дешевле по FLOPs при почти той же точности. Но: FLOPs ≠ время. У depthwise низкая арифметическая интенсивность (мало операций на байт), и на GPU она упирается в пропускную способность памяти — реальное ускорение может оказаться двукратным, а не девятикратным. На мобильных CPU/NPU — ближе к обещанному.
7. Как эволюционировали архитектуры
Смысловые водоразделы этой линии:
- AlexNet (2012) не внёс принципиально нового в архитектуру — он показал, что «GPU + большие данные + ReLU + dropout» дают скачок, а не постепенное улучшение.
- VGG против Inception (2014) — два ответа на вопрос «как расти»: однородно вглубь или разными ядрами параллельно. Победила смесь.
- ResNet (2015) сделал глубину практически бесплатной; skip-соединения ушли потом вообще везде, включая трансформеры.
- ViT (2020) отменил свёртку как обязательное условие: при очень больших данных сеть выучивает локальность сама. На малых датасетах индуктивные смещения CNN всё ещё выигрывают.
- ConvNeXt (2022, arXiv:2201.03545) показал, что разрыв объяснялся не «свёртка хуже внимания», а рецептом обучения: большие ядра 7×7, LayerNorm, GELU, AdamW, сильные аугментации — и чистая CNN снова конкурентна.
8. Задачи зрения помимо классификации
зрение)) Классификация один ярлык multi-label fine-grained Детекция two-stage Faster R-CNN one-stage YOLO RetinaNet anchor-free FCOS DETR метрика mAP по IoU Сегментация семантическая FCN DeepLab инстансная Mask R-CNN паноптическая метрики IoU и Dice Плотное предсказание глубина оптический поток super-resolution Метрические задачи re-identification поиск похожих контрастное обучение Видео 3D-свёртки трекинг
У всех этих задач общий скелет: backbone (CNN, дающая карты признаков) + neck (смешивание масштабов, обычно FPN, arXiv:1612.03144) + head (задача-специфичная). Backbone почти всегда переиспользуется — в этом и есть главная практическая ценность предобученных CNN.
Для плотных задач нужен обратный ход: из карты низкого разрешения вернуться к исходному. Каноническая схема — U-Net (arXiv:1505.04597): симметричные энкодер и декодер, а между ними skip-соединения, которые несут другую нагрузку, чем в ResNet — они возвращают декодеру высокочастотные детали, потерянные при понижении разрешения. Без них границы масок получаются размытыми. Апсемплинг делают либо nn.ConvTranspose2d (обучаемый, но даёт шахматные артефакты при неудачном соотношении stride/kernel — distill.pub/2016/deconv-checkerboard), либо безопаснее: nn.Upsample(mode='bilinear') плюс обычная свёртка 3×3.
9. Обучение CNN на практике
С нуля свёрточную сеть обучают редко. Стандартный путь — transfer learning: взять backbone, предобученный на ImageNet, и дообучить. На датасете в 2–10 тысяч картинок это разница между 70% и 94% точности.
import torch, torch.nn as nn
from torchvision import models, transforms
from torchvision.models import ResNet50_Weights
weights = ResNet50_Weights.IMAGENET1K_V2
model = models.resnet50(weights=weights)
model.fc = nn.Linear(model.fc.in_features, NUM_CLASSES) # новая голова под свои классы
# препроцессинг берём ИЗ ВЕСОВ, а не пишем руками —
# это самый частый источник расхождения train/serve
val_tf = weights.transforms() # resize 232, crop 224, mean/std ImageNet
# аугментации — только для обучения; на валидации детерминированный val_tf
train_tf = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.6, 1.0)),
transforms.RandomHorizontalFlip(),
transforms.TrivialAugmentWide(), # сильная авто-аугментация
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
transforms.RandomErasing(p=0.25), # уже к тензору
])
# тело обучаем осторожно, новую голову — агрессивно
head = list(model.fc.parameters())
head_ids = {id(p) for p in head}
body = [p for p in model.parameters() if id(p) not in head_ids]
optimizer = torch.optim.AdamW(
[{"params": body, "lr": 1e-4}, {"params": head, "lr": 1e-3}], weight_decay=0.05)
scheduler = torch.optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=[1e-4, 1e-3], total_steps=EPOCHS * len(train_loader))
criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # +0.3–1% и лучше калибровка
scaler = torch.amp.GradScaler("cuda")
model.train()
for images, targets in train_loader:
images = images.to(device, non_blocking=True, memory_format=torch.channels_last)
targets = targets.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast("cuda", dtype=torch.bfloat16): # ~2× скорости, вдвое меньше памяти
loss = criterion(model(images), targets)
scaler.scale(loss).backward()
scaler.step(optimizer); scaler.update(); scheduler.step()
Что реально влияет на результат, по убыванию отдачи:
- Качество и объём данных. Сто честно размеченных примеров редкого класса дают больше, чем смена ResNet-50 на ConvNeXt.
- Аугментации. TrivialAugment/RandAugment, Mixup (arXiv:1710.09412), CutMix (arXiv:1905.04899). Но они должны быть физически осмысленными: вертикальный флип уместен для спутниковых снимков и катастрофичен для распознавания цифр.
- Разрешение входа. Часто самый дешёвый способ поднять качество на мелких объектах — перейти с 224 на 384. Стоимость растёт квадратично.
- Расписание LR. Cosine/OneCycle с прогревом почти всегда лучше константы.
- Архитектура. Обычно последний по важности пункт, хотя обсуждают его первым.
10. Trade-offs: что выбирать под задачу
VGG-16 в правом нижнем углу — иллюстрация прогресса: 138M параметров и 15.5 GFLOPs ради точности, которую сегодня даёт модель в 30 раз дешевле.
| Сценарий | Разумный выбор | Почему |
|---|---|---|
| Прототип, есть GPU | ResNet-50 / ConvNeXt-Tiny | предсказуемы, тонна готовых весов |
| Мобильное приложение | MobileNetV3, EfficientNet-Lite | оптимизированы под CPU/NPU |
| Пакетная обработка на сервере | большая модель + батчинг | важна пропускная способность, не латентность |
| Реалтайм-видео 30 FPS | YOLO-семейство + TensorRT | бюджет 33 мс на весь кадр |
| Мало данных (< 5k) | небольшая предобученная + сильная аугментация | большая переобучится |
Честное предупреждение: FLOPs — плохой прокси для латентности. Реальная скорость зависит от арифметической интенсивности, поддержки операций в рантайме, формата памяти (channels_last на тензорных ядрах даёт до 2×) и размера батча. Надёжный способ один — замерить на целевом железе.
11. Типичные ошибки
Данные и препроцессинг
- Расхождение препроцессинга train и serve. Обучали на
Resize(256) + CenterCrop(224)с нормализацией ImageNet, а в проде ресайзят до 224 и делят на 255. Точность падает на 10–20%, и это выглядит как «модель деградировала». - BGR против RGB.
cv2.imreadотдаёт BGR, torchvision ждёт RGB. Модель работает, но заметно хуже, и никто не понимает почему. - Разная интерполяция при ресайзе (
bilinearна обучении,nearestв проде) — реальный сдвиг распределения. - Аугментации на валидации — метрика шумит и систематически занижается.
- Утечка через дубликаты: кадры одного видео или фото товара с разных ракурсов попали и в train, и в val. Делите по группам и сессиям, а не случайно по кадрам.
Архитектура и обучение
- Забыли
model.eval(). BatchNorm продолжает использовать статистику батча, dropout активен — предсказание для одной картинки зависит от того, с кем она в батче. - BatchNorm при маленьком батче. При
batch_size ≤ 8(типично для сегментации в высоком разрешении) статистики шумные — берите GroupNorm. - Забыли заморозить BN при дообучении. Даже с
requires_grad=Falseбегущие средние обновляются в режимеtrain()и уползают на маленьком датасете; BN-модули нужно явно переводить вeval(). - Дообучение всей сети с большим LR мгновенно разрушает предобученные признаки (catastrophic forgetting).
paddingне согласован с ядром:K=5, P=1тихо режет карту на каждом слое — через десяток слоёв всё падает наsize mismatch.- Global pooling заменён на
flatten— сеть намертво привязана к одному разрешению входа.
Оценка
- Accuracy на несбалансированных данных. При 99% «нет дефекта» модель, всегда отвечающая «нет», даёт 99% accuracy и нулевую пользу. Смотрите PR-AUC и recall редкого класса.
- Порог 0.5 по умолчанию. Его подбирают на валидации под стоимость ошибок.
- Модель выучила фон, а не объект — классика вроде детектора рака кожи, выучившего линейку на снимках клиники. Проверяйте Grad-CAM, куда сеть смотрит; подробнее в статье об интерпретируемости.
- Игнор texture bias: ImageNet-CNN опираются на текстуру сильнее, чем на форму (arXiv:1811.12231) — отсюда провалы на стилизованных и низкокачественных изображениях.
12. CNN в продакшне
Обученная модель — половина работы; вторая половина это сервинг, и там свои законы.
или 10 мс — что раньше Q->>W: батч (32, 3, 224, 224) fp16 Note over W: декод JPEG на GPU (nvJPEG),
resize и normalize там же W->>W: forward ≈ 8 мс на батч W-->>Q: логиты (32, 1000) Q-->>GW: результат по request_id GW-->>C: 200 OK {class, confidence} W->>M: latency p50/p99, размер батча, utilization M->>M: детект дрейфа входного распределения
Неочевидное:
- Динамический батчинг — главный рычаг пропускной способности. На батче размера 1 GPU простаивает; собрав 32 запроса, вы получаете почти линейный рост throughput ценой единиц миллисекунд латентности. Triton Inference Server и TorchServe делают это из коробки.
- Препроцессинг часто дороже модели. Декод JPEG и ресайз на CPU легко съедают больше времени, чем forward на GPU; лечится nvJPEG / NVIDIA DALI и переносом ресайза на GPU.
- Экспорт и компиляция.
torch.export→ ONNX → TensorRT/OpenVINO даёт 2–5× за счёт fusion (Conv+BN+ReLU в одно ядро), fp16/int8 и подбора ядер. Сворачивание BatchNorm в веса предыдущей свёртки — бесплатная и всегда корректная оптимизация после обучения. - Прогрев обязателен. Первые запросы после старта в 10–50 раз медленнее (аллокации, выбор алгоритмов cuDNN, JIT). Без прогрева p99 после каждого деплоя выглядит как инцидент.
- Мониторить нужно вход, а не только выход. Сменили камеру, поменяли освещение на складе, обновили мобильное приложение — дрейф ударит по метрике раньше, чем это заметят по бизнес-показателям.
Квантизация, дистилляция и прунинг — тема статьи про инференс и деплой.
13. Мини-итог
- Свёртка — не «хитрая математика», а способ вшить в архитектуру знание о структуре изображения: локальность, разделение весов, эквивариантность к сдвигу.
- Слой описывают три формулы: размер выхода
⌊(I + 2P − D(K−1) − 1)/S⌋ + 1, число параметровC_out·(C_in·K²) + C_out, стоимостьH·W·C_in·C_out·K²MAC. - Рецептивное поле считают до обучения, а не после разочарования в метриках.
- Стек 3×3, skip-соединения, BatchNorm, global average pooling и depthwise separable — пять приёмов, из которых собрано почти всё современное зрение.
- Память при обучении тратят активации, а не веса; латентность определяется памятью и рантаймом, а не FLOPs.
- Свёртки никуда не делись после ViT: на малых данных они обучаются стабильнее, в гибридах живут как stem и локальный смеситель, в диффузии — как U-Net (см. генеративные модели), а на edge вне конкуренции. Признаки CNN сами по себе полезны как эмбеддинги — см. статью об эмбеддингах.
- Больше всего очков в проде приносит не архитектура, а данные, аугментации и идентичный препроцессинг на обучении и на инференсе.
Источники
- Goodfellow, Bengio, Courville. Deep Learning, гл. 9 — deeplearningbook.org/contents/convnets.html
- Dumoulin, Visin. A guide to convolution arithmetic — arXiv:1603.07285
- Stanford CS231n, конспект по CNN — cs231n.github.io/convolutional-networks
- Dive into Deep Learning, часть про CNN — d2l.ai
- LeCun et al. Gradient-Based Learning Applied to Document Recognition (LeNet) — PDF
- Krizhevsky et al. ImageNet Classification with Deep CNNs (AlexNet) — NeurIPS 2012
- Simonyan, Zisserman. Very Deep Convolutional Networks (VGG) — arXiv:1409.1556
- He et al. Deep Residual Learning (ResNet) — arXiv:1512.03385
- Howard et al. MobileNets — arXiv:1704.04861 · Tan, Le. EfficientNet — arXiv:1905.11946
- Liu et al. A ConvNet for the 2020s (ConvNeXt) — arXiv:2201.03545
- Ronneberger et al. U-Net — arXiv:1505.04597
- He et al. Bag of Tricks for Image Classification — arXiv:1812.01187
- PyTorch
nn.Conv2d— docs · torchvision models — docs timm, крупнейшая коллекция предобученных CV-моделей — github.com/huggingface/pytorch-image-models- Albumentations, быстрые аугментации для детекции и сегментации — albumentations.ai
Что дальше
Свёртка эксплуатирует структуру пространства: «соседние пиксели связаны». Но есть данные, где структура не пространственная, а временная — текст, речь, временные ряды, логи. Там нужен другой индуктивный биас: рекуррентность, то есть состояние, которое переносится из шага в шаг.
Следующая статья: Рекуррентные сети, LSTM и GRU — разберём, как сеть работает с последовательностями, почему наивная RNN не обучается на длинных зависимостях и как вентильные механизмы LSTM и GRU это чинят.