Нейронные сети Свёрточные сети и компьютерное зрение
0%

Свёрточные сети и компьютерное зрение

Свёрточные сети и компьютерное зрение

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

1. Почему полносвязная сеть на картинке безнадёжна

Возьмём RGB-изображение 224×224. Развернём в вектор: 224 · 224 · 3 = 150 528 чисел. Подадим на полносвязный слой с 4096 нейронами — получим ≈ 616 миллионов весов в одном слое, 2.4 ГБ в float32. Но проблема даже не в памяти, а в трёх концептуальных вещах:

  1. Нет понятия «рядом». Развернув картинку в вектор, вы сообщили сети, что пиксели (10,10) и (10,11) связаны не сильнее, чем первый и последний. Всю пространственную структуру — то есть суть изображения — сеть должна восстановить из данных с нуля.
  2. Нет разделения знаний между позициями. Детектор вертикального края для левого верхнего угла — отдельный набор весов. Для правого нижнего придётся выучить такой же заново, из других примеров.
  3. Ноль устойчивости к сдвигу. Сдвиньте кота на 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]). Поскольку ядро обучаемое, разница сводится к перестановке весов. Все фреймворки реализуют кросс-корреляцию и называют её свёрткой.

Механика свёртки 3×3: скользящее окно, ядро, карта признаков

Многоканальность. Реальный слой работает с тензором (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 — память: каждый пиксель дублируется раз. Поэтому 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, она физически не видит объект целиком.

Рост рецептивного поля при стеке свёрток 3×3

$$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)  дилатация без потери разрешения

Три вывода:

  1. Стек маленьких ядер лучше одного большого. Три 3×3 дают RF 7×7, но стоят 27C² весов вместо 49C² и содержат три нелинейности вместо одной. Это центральный аргумент VGG (arXiv:1409.1556), после которого ядра 7×7 и 11×11 исчезли из середины сетей.
  2. Dilated (atrous) свёртки (arXiv:1511.07122) наращивают RF экспоненциально, не понижая разрешение — незаменимы в сегментации, где на выходе нужна карта в исходном масштабе.
  3. Эффективное RF меньше теоретического: влияние распределено гауссиански от центра, края почти не работают (arXiv:1701.04128). Практическое правило — закладывайте теоретическое RF в 2–3 раза больше детектируемых объектов.

6. Анатомия современной свёрточной сети

Почти все CNN после 2015 года собраны по одной схеме: stem → стадии с понижением разрешения → голова.

Базовый кирпич — тройка 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. Задачи зрения помимо классификации

У всех этих задач общий скелет: 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()

Что реально влияет на результат, по убыванию отдачи:

  1. Качество и объём данных. Сто честно размеченных примеров редкого класса дают больше, чем смена ResNet-50 на ConvNeXt.
  2. Аугментации. TrivialAugment/RandAugment, Mixup (arXiv:1710.09412), CutMix (arXiv:1905.04899). Но они должны быть физически осмысленными: вертикальный флип уместен для спутниковых снимков и катастрофичен для распознавания цифр.
  3. Разрешение входа. Часто самый дешёвый способ поднять качество на мелких объектах — перейти с 224 на 384. Стоимость растёт квадратично.
  4. Расписание LR. Cosine/OneCycle с прогревом почти всегда лучше константы.
  5. Архитектура. Обычно последний по важности пункт, хотя обсуждают его первым.

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 в продакшне

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

Неочевидное:

  • Динамический батчинг — главный рычаг пропускной способности. На батче размера 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 сами по себе полезны как эмбеддинги — см. статью об эмбеддингах.
  • Больше всего очков в проде приносит не архитектура, а данные, аугментации и идентичный препроцессинг на обучении и на инференсе.

Источники

Что дальше

Свёртка эксплуатирует структуру пространства: «соседние пиксели связаны». Но есть данные, где структура не пространственная, а временная — текст, речь, временные ряды, логи. Там нужен другой индуктивный биас: рекуррентность, то есть состояние, которое переносится из шага в шаг.

Следующая статья: Рекуррентные сети, LSTM и GRU — разберём, как сеть работает с последовательностями, почему наивная RNN не обучается на длинных зависимостях и как вентильные механизмы LSTM и GRU это чинят.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов