Сверточные нейросети (CNN) и Transfer Learning

Курс посвящен изучению сверточных архитектур глубокого обучения для задач компьютерного зрения. Вы освоите математику и механику сверточных слоев, пулинга и нормализации, разберете архитектуру ResNet и примените методологию переноса обучения (Transfer Learning) для высокоточной классификации изображений на PyTorch.

Сверточный слой в компьютерном зрении: обучаемые 3D-фильтры, отступы, шаг и разделение весов

Сверточный слой в компьютерном зрении: обучаемые 3D-фильтры, отступы, шаг и разделение весов

Представьте скромное изображение размером 256×256256 \times 256 пикселей с тремя цветовыми каналами RGB. Если подать его в полносвязный слой всего с 512 скрытыми нейронами, матрица весов потребует 256×256×3×512100,6256 \times 256 \times 3 \times 512 \approx 100{,}6 миллионов параметров. Для одной только первой связи потребуется около 400 мегабайт оперативной памяти, а модель мгновенно переобучится, запоминая шум. Но главная проблема даже не в памяти: выпрямляя матрицу в одномерный вектор через Flatten, мы полностью разрушаем пространственную структуру сцены — пиксель (0,0)(0, 0) перестает быть соседом пикселя (1,0)(1, 0) и оказывается оторван от своего локального контекста.

Как обрабатывать изображения высокого разрешения, не раздувая число весов до сотен миллионов и сохраняя пространственные связи между соседними пикселями? Ответом стали сверточные нейронные сети (Convolutional Neural Networks, CNN), в основе которых лежит операция локальной многоканальной свертки.


От фиксированных ядер к обучаемым 3D-фильтрам

В классической обработке изображений вы уже применяли операцию дискретной свертки: ядра Собеля выделяли градиенты яркости, а фильтр Гаусса сглаживал высокочастотный шум. Однако в OpenCV ядра были жестко зафиксированы разработчиком. Если освещение менялось или дефект поворачивался под нестандартным углом, эвристические матрицы переставали работать.

Сверточный слой нейросети использует ту же математическую операцию скользящего окна, но с принципиальным отличием: значения внутри ядра свертки — это обучаемые параметры W\mathbf{W}, которые оптимизируются алгоритмом градиентного спуска через обратное распространение ошибки.

Кроме того, реальные тензоры изображений имеют три измерения: высоту HH, ширину WW и число каналов CinC_{in} (например, 3 для RGB или десятки/сотни на глубоких слоях сети). Поэтому фильтр сверточного слоя — это не плоская матрица, а трехмерный параллелепипед весов формы (Cin,Kh,Kw)(C_{in}, K_h, K_w), где Kh×KwK_h \times K_w — пространственный размер ядра (обычно 3×33 \times 3 или 5×55 \times 5).

Ключевой принцип глубины фильтра: Пространственная глубина сверточного фильтра всегда строго равна количеству входных каналов CinC_{in} обрабатываемого тензора.

Математика многоканальной свертки

Когда 3D-фильтр прикладывается к локальному участку входного тензора X\mathbf{X}, происходит следующее:

  1. Для каждого входного канала c[0,Cin1]c \in [0, C_{in}-1] вычисляется поэлементное произведение между срезом входного тензора и соответствующим двумерным ядром фильтра.
  2. Результаты произведений суммируются по всей площади окна и по всем входным каналам.
  3. К полученной скалярной сумме прибавляется общее для данного фильтра скалярное смещение bb (bias).

Математически значение выходного элемента на позиции (i,j)(i, j) для mm-го фильтра выражается формулой:

Ym,i,j=bm+c=0Cin1u=0K1v=0K1Xc,i+u,j+vWm,c,u,vY_{m, i, j} = b_m + \sum_{c=0}^{C_{in}-1} \sum_{u=0}^{K-1} \sum_{v=0}^{K-1} X_{c, \, i+u, \, j+v} \cdot W_{m, c, u, v}

Разберем эту формулу на практике:

  • Ym,i,jY_{m, i, j} — результирующее числовое значение в выходной карте признаков на строке ii и столбце jj, сгенерированное mm-м фильтром;
  • bmb_m — обучаемое числовое смещение (bias), добавляемое ко всему результату одного mm-го фильтра;
  • CinC_{in} — количество каналов на входе (например, 3 для RGB-кадра);
  • KK — размер стороны квадратного ядра (например, K=3K = 3);
  • Xc,i+u,j+vX_{c, \, i+u, \, j+v} — значение пикселя входного тензора в канале cc со смещением (u,v)(u, v) относительно начала текущего окна;
  • Wm,c,u,vW_{m, c, u, v} — конкретный обучаемый вес mm-го фильтра для канала cc в позиции ядра (u,v)(u, v).

Один трехмерный фильтр, проходя по всему входному тензору (Cin,H,W)(C_{in}, H, W), формирует одну двумерную матрицу — карту признаков (feature map) размером (Hout,Wout)(H_{out}, W_{out}). Если мы хотим извлечь на данном слое CoutC_{out} различных признаков (например, вертикальные линии, текстуры и цветовые переходы), мы берем ансамбль из CoutC_{out} независимых 3D-фильтров. В результате на выходе получается 3D-тензор формы (Cout,Hout,Wout)(C_{out}, H_{out}, W_{out}).


Разделение весов и трансляционная эквивариантность

Почему свертка требует на порядки меньше параметров, чем полносвязный слой? Причина кроется в концепции разделения весов (weight sharing).

В полносвязном слое каждый выходной нейрон имеет свой уникальный набор весов для каждого входного пикселя. В сверточном слое один и тот же 3D-фильтр скользит по всему изображению. Веса ядра остаются неизменными независимо от того, обрабатывает ли фильтр левый верхний угол, центр или правый нижний край кадра.

Из разделения весов вытекают два важнейших свойства сверточных сетей:

  • Резкое сокращение числа параметров: для фильтра 3×33 \times 3 на RGB-входе требуется всего 3×3×3=273 \times 3 \times 3 = 27 весов (плюс 1 смещение), и это число вообще не зависит от разрешения входного изображения (256×256256 \times 256 или 4000×30004000 \times 3000).
  • Трансляционная эквивариантность (translation equivariance): если объект на входном изображении сместится на 10 пикселей вправо, активация на карте признаков сместится ровно на те же 10 позиций, не изменив своей формы. Сети не нужно заново учиться распознавать трещину или контур детали в каждом отдельном углу кадра.

Геометрия свертки: отступы (Padding) и шаг (Stride)

Размер выходной карты признаков (Hout,Wout)(H_{out}, W_{out}) определяется не только размером ядра KK, но и двумя ключевыми гиперпараметрами слоя: отступами (padding) и шагом (stride).

Отступы (Padding)

Когда ядро размера K×KK \times K (K>1K > 1) скользит по изображению без выхода за границы, крайние пиксели участвуют в вычислениях гораздо реже, чем центральные. Кроме того, пространственный размер матрицы с каждым слоем уменьшается: для ядра 3×33 \times 3 мы теряем по 1 пикселю с каждой стороны (Hout=H2H_{out} = H - 2).

Чтобы предотвратить неконтролируемое сжатие карт признаков и сохранить информацию на границах, по периметру входного тензора добавляют фиктивные рамки из нулей (zero padding):

  • Valid Padding (P=0P = 0): дополнение отсутствует. Ядро скользит только внутри исходного тензора, размер выхода уменьшается.
  • Same Padding (P=(K1)/2P = (K - 1) / 2 при нечетном KK и шаге S=1S = 1): вход дополняется нулями так, чтобы пространственный размер выхода в точности совпадал с входом (Hout=HH_{out} = H, Wout=WW_{out} = W).

Шаг свертки (Stride)

Шаг свертки SS определяет величину дискретного смещения скользящего окна на каждом шаге.

  • При S=1S = 1 фильтр сдвигается на 1 пиксель, сканируя вход непрерывно.
  • При S=2S = 2 фильтр перескакивает через один пиксель как по вертикали, так и по горизонтали. Это приводит к уменьшению пространственного разрешения карты признаков приблизительно в 2 раза, заменяя операцию пулинга.

Hout=Hin+2PKS+1H_{out} = \left\lfloor \frac{H_{in} + 2P - K}{S} \right\rfloor + 1

Поясним компоненты формулы:

  • HinH_{in} — исходная высота входного тензора в пикселях;
  • PP — размер отступа (padding) с каждой из сторон (если padding=1, к высоте добавляется 2×1=22 \times 1 = 2 пикселя);
  • KK — пространственный размер ядра (kernel size);
  • SS — величина шага смещения фильтра (stride);
  • \lfloor \dots \rfloor — операция округления вниз (целочисленное деление), гарантирующая, что неполный шаг ядра за пределами границы отбрасывается.

Разберем практический пример: пусть входной тензор имеет высоту Hin=32H_{in} = 32, размер ядра K=3K = 3, отступ P=1P = 1 и шаг S=2S = 2. Подставляем значения в формулу:

Hout=32+2132+1=312+1=15+1=16H_{out} = \left\lfloor \frac{32 + 2 \cdot 1 - 3}{2} \right\rfloor + 1 = \left\lfloor \frac{31}{2} \right\rfloor + 1 = 15 + 1 = 16

Выходное пространственное разрешение уменьшилось ровно в 2 раза — с 32×3232 \times 32 до 16×1616 \times 16.


Сверточный слой против полносвязного: анатомия параметров

Сравним архитектурные затраты полносвязного и сверточного слоев.

Критерий Полносвязный слой (nn.Linear) Сверточный слой (nn.Conv2d)
Входной формат 1D-вектор признаков (N,Din)(N, D_{in}) 4D-тензор (N,Cin,H,W)(N, C_{in}, H, W)
Связи нейронов Каждый выход соединен со всеми входами Локальные связи в пределах окна K×KK \times K
Разделение весов Отсутствует (у каждой связи свой вес) Есть (один 3D-фильтр на всю карту)
Формула параметров Dout(Din+1)D_{out} \cdot (D_{in} + 1) Cout(CinKhKw+1)C_{out} \cdot (C_{in} \cdot K_h \cdot K_w + 1)
Зависимость весов от H,WH, W Напрямую зависит от разрешения кадра Не зависит от пространственного размера
Пространственная топология Разрушается операцией Flatten Сохраняется в виде карты признаков

Формула расчета обучаемых параметров сверточного слоя наглядно показывает его компактность:

Params=Cout×(Cin×Kh×Kw+1)\text{Params} = C_{out} \times (C_{in} \times K_h \times K_w + 1)

Здесь:

  • CoutC_{out} — количество выходных фильтров (каждый формирует свой канал признаков);
  • Cin×Kh×KwC_{in} \times K_h \times K_w — количество весовых коэффициентов в теле одного 3D-фильтра;
  • +1+ 1 — одно обучаемое смещение (bias) на каждый фильтр (если параметр bias=True).

Пример: слой nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, bias=True) содержит:

Params=128×(64×3×3+1)=128×(576+1)=73,856 параметров\text{Params} = 128 \times (64 \times 3 \times 3 + 1) = 128 \times (576 + 1) = 73{,}856 \text{ параметров}

Этот слой может обрабатывать изображение как 64×6464 \times 64, так и 1024×10241024 \times 1024 пикселей — число весов останется строго 73 856.


Реализация сверточного слоя в PyTorch

В PyTorch сверточный слой для двумерных изображений реализован в классе torch.nn.Conv2d. На вход он ожидает 4D-тензор стандартного формата (Batch_size, Channels, Height, Width) — NCHW.

import torch
import torch.nn as nn

# Создаем синтетический батч: 4 RGB-изображения размером 128x128
input_tensor = torch.randn(4, 3, 128, 128)

# Сверточный слой: 3 входных канала -> 16 выходных карт признаков
# Ядро 3x3, шаг 1, отступ 1 (Same padding)
conv_layer = nn.Conv2d(
    in_channels=3,
    out_channels=16,
    kernel_size=3,
    stride=1,
    padding=1,
    bias=True
)

# Прямой проход
output_tensor = conv_layer(input_tensor)

print(f"Форма входа:  {input_tensor.shape}")   # torch.Size([4, 3, 128, 128])
print(f"Форма выхода: {output_tensor.shape}")  # torch.Size([4, 16, 128, 128])

# Проверяем количество параметров: 16 * (3 * 3 * 3 + 1) = 16 * 28 = 448
total_params = sum(p.numel() for p in conv_layer.parameters() if p.requires_grad)
print(f"Обучаемых параметров: {total_params}") # 448

Тензор весов conv_layer.weight имеет форму (out_channels, in_channels, kernel_size, kernel_size), то есть (16, 3, 3, 3). Смещение conv_layer.bias имеет форму (16,) — ровно по одному числу на каждую выходную карту признаков.

Сверточный слой преобразует локальные пиксельные паттерны в абстрактные карты признаков. Однако при последовательном наложении нескольких слоев возникает вопрос: какую область исходного кадра охватывает отдельный нейрон на глубоких уровнях сети и как сжать карты признаков перед финальной классификацией? Эту задачу решают операции пулинга и концепция рецептивного поля, которые мы разберем в следующей главе.

Операции пулинга, рецептивное поле и переход от карт признаков к классификации

Операции пулинга, рецептивное поле и переход от карт признаков к классификации

Если сверточный фильтр размером 3×33 \times 3 видит всего 9 соседних пикселей, как нейросеть умудряется распознать объект размером во весь кадр — например, деталь на конвейере или дорожный знак в разрешении 1920×10801920 \times 1080? Одиночная свертка локальна и «близорука». Чтобы связать микроструктуры (границы, перепады яркости) в высокоуровневые концепции (форму отверстия, текстуру брака, контур изделия), сети необходим механизм постепенного расширения пространственного контекста при контролируемом объеме вычислений.

Эту задачу решают две взаимосвязанные концепции: операции субдискретизации (пулинг) и накопление рецептивного поля (Receptive Field). В этой главе мы разберем, как сверточная сеть сжимает пространственные координаты, сохраняя смысловые сигналы, как локальные ядра объединяются в «макролинзы» и почему современное компьютерное зрение почти полностью отказалось от классического выпрямления карт признаков через Flatten в пользу адаптивного глобального пулинга.


Пространственная редукция: Max-Pooling и Average-Pooling

При обработке глубоких сетей сохранение исходного пространственного разрешения (например, 224×224224 \times 224 пикселя) на протяжении десятков слоев привело бы к вычислительному коллапсу. С каждым новым сверточным слоем число каналов CC растет, а объем памяти под карты активаций увеличивается пропорционально C×H×WC \times H \times W.

Пулинг (Pooling, или операция объединения) решает три инженерные задачи:

  1. Снижение вычислительной сложности: уменьшение высоты HH и ширины WW снижает количество операций с плавающей точкой (FLOPs) на последующих слоях.
  2. Трансляционная инвариантность: небольшие пространственные сдвиги объекта внутри локального окна не меняют выходное значение агрегации.
  3. Ускорение роста рецептивного поля: последующие фильтры охватывают все более масштабные фрагменты исходного изображения.

В отличие от свертки, пулинг не содержит обучаемых параметров (весов и смещений). Он выполняет фиксированную математическую операцию агрегации над каждым каналом независимо.

Max-Pooling: сохранение доминирующих сигналов

Слой nn.MaxPool2d делит карту признаков на непересекающиеся или частично перекрывающиеся окна и выбирает из каждого окна максимальное числовое значение.

Max-Pooling (выбор максимума) — нелинейная операция пространственной агрегации, которая сохраняет наиболее сильный отклик детектора признаков в локальном окне и отбрасывает фоновый шум.

Математически для окна размером Kh×KwK_h \times K_w операция в позиции (i,j)(i, j) на канале cc выражается формулой:

yc,i,j=maxm[0,Kh1],n[0,Kw1]xc,iSh+m,jSw+ny_{c, i, j} = \max_{m \in [0, K_h-1], \, n \in [0, K_w-1]} x_{c, \, i \cdot S_h + m, \, j \cdot S_w + n}

Здесь:

  • xx — входной тензор карты признаков;
  • yy — результирующий сжатый тензор;
  • Sh,SwS_h, S_w — шаг (stride) смещения окна по вертикали и горизонтали;
  • m,nm, n — локальные координаты внутри окна агрегации.

Практический смысл: если сверточный фильтр настроен на поиск трещины, то после функции активации ReLU пиксели с дефектом получат высокие положительные активации (например, 8.4), а ровный металл — нули. Max-Pooling 2×22 \times 2 с шагом 2 выберет значение 8.4, зафиксировав факт наличия дефекта в данном квадрате 2×22 \times 2, даже если точные координаты внутри окна слегка сместятся.

Average-Pooling: усреднение контекста

Слой nn.AvgPool2d вычисляет среднее арифметическое элементов в скользящем окне:

yc,i,j=1KhKwm=0Kh1n=0Kw1xc,iSh+m,jSw+ny_{c, i, j} = \frac{1}{K_h \cdot K_w} \sum_{m=0}^{K_h-1} \sum_{n=0}^{K_w-1} x_{c, \, i \cdot S_h + m, \, j \cdot S_w + n}

Среднее значение сглаживает активации, сохраняя общую фоновую информацию о присутствии признака, но размывает резкие пиковые сигналы.

Характеристика Max-Pooling (MaxPool2d) Average-Pooling (AvgPool2d)
Математическая суть Экстремум (max\max) Среднее арифметическое (1N\frac{1}{N}\sum)
Обучаемые параметры 0 0
Поведение признаков Выделяет контрастные границы, ребра, ключевые детали Сглаживает карту, передает интегральный фон
Основное применение Промежуточные блоки классификаторов и детекторов Финальная агрегация признаков (Global Pooling)

Геометрия сжатия карты признаков

Размерность выходной карты признаков после операции пулинга вычисляется по формуле:

Hout=Hin+2PKS+1H_{out} = \left\lfloor \frac{H_{in} + 2P - K}{S} \right\rfloor + 1

Где:

  • HinH_{in} — входная высота;
  • PP — размер отступа (padding);
  • KK — размер ядра пулинга (kernel size);
  • SS — шаг сканирования (stride);
  • \lfloor \dots \rfloor — операция округления вниз (floor).

В подавляющем большинстве стандартных архитектур используется конфигурация K=2,S=2,P=0K = 2, S = 2, P = 0. Она ровно в 2 раза уменьшает пространственные размеры по каждой оси (Hout=Hin/2H_{out} = H_{in} / 2, Wout=Win/2W_{out} = W_{in} / 2) и сокращает площадь карты активаций в 4 раза.


Рецептивное поле (Receptive Field)

Рецептивное поле (Receptive Field, RF) — область входного пространства (исходного изображения), которая непосредственно влияет на числовое значение конкретного элемента в карте признаков на данном слое сети.

Если нейрон первого сверточного слоя с ядром 3×33 \times 3 видит квадрат 3×33 \times 3 пикселя исходного изображения, то нейрон следующего слоя видит область 3×33 \times 3 уже в карте признаков первого слоя. А эта область, в свою очередь, собирает информацию из более широкого участка оригинала.

Эквивалентность каскадов сверток

Рассмотрим фундаментальный архитектурный принцип, заложенный в сетях VGG и развитый во всех последующих архитектурах: последовательность двух сверток 3×33 \times 3 имеет то же рецептивное поле, что и одна свертка 5×55 \times 5, а три свертки 3×33 \times 3 охватывают зону 7×77 \times 7.

Докажем это математически. Пусть шаг свертки S=1S = 1, а отступы подобраны так, чтобы сохранять размерность:

  1. Выходной элемент y(1)y^{(1)} первого слоя 3×33 \times 3 зависит от 3 точек входа по каждой оси: [1,0,1][-1, 0, 1].
  2. Выходной элемент y(2)y^{(2)} второго слоя 3×33 \times 3 берет окно из трех элементов карты y(1)y^{(1)}: [1,0,1][-1, 0, 1].
  3. Левый край окна (y1(1)y_{-1}^{(1)}) видит входные точки от 2-2 до 00. Правый край (y+1(1)y_{+1}^{(1)}) видит точки от 00 до +2+2.
  4. Суммарный охват на входе составляет диапазон [2,1,0,1,2][-2, -1, 0, 1, 2] — ровно 5 пикселей.

Почему инженеры предпочитают каскад из двух слоев 3×33 \times 3 вместо одного слоя 5×55 \times 5?

  1. Снижение числа параметров:
    • Один слой 5×55 \times 5 с CC входными и CC выходными каналами содержит 25C225 \cdot C^2 весов.
    • Два последовательных слоя 3×33 \times 3 содержат 2(9C2)=18C22 \cdot (9 \cdot C^2) = 18 \cdot C^2 весов.
    • Экономия памяти и вычислений составляет:

25C218C225C2=28%\frac{25 \cdot C^2 - 18 \cdot C^2}{25 \cdot C^2} = 28\%

  1. Больше нелинейности: между двумя слоями 3×33 \times 3 устанавливается дополнительная функция активации (например, ReLU). Это позволяет сети моделировать значительно более сложные границы разделения классов.

Влияние шага (Stride) и пулинга на рецептивное поле

Когда в сети применяется слой с шагом S>1S > 1 (или пулинг 2×22 \times 2), каждый шаг по карте признаков на следующем слое начинает соответствовать скачку на SS пикселей на входе.

Рекуррентная формула расчета рецептивного поля RFlRF_l для слоя ll:

RFl=RFl1+(Kl1)Jl1RF_l = RF_{l-1} + (K_l - 1) \cdot J_{l-1}

Jl=Jl1SlJ_l = J_{l-1} \cdot S_l

Где:

  • RFlRF_l — рецептивное поле текущего слоя ll (для входа RF0=1RF_0 = 1);
  • KlK_l — размер ядра текущего слоя;
  • SlS_l — шаг (stride) текущего слоя;
  • JlJ_l — суммарный шаг («прыжок», jump) фильтра относительно исходного изображения (J0=1J_0 = 1).

Пример расчета:

  1. Conv1 (K1=3,S1=1K_1 = 3, S_1 = 1): RF1=1+(31)1=3RF_1 = 1 + (3 - 1) \cdot 1 = 3, J1=11=1J_1 = 1 \cdot 1 = 1.
  2. MaxPool1 (K2=2,S2=2K_2 = 2, S_2 = 2): RF2=3+(21)1=4RF_2 = 3 + (2 - 1) \cdot 1 = 4, J2=12=2J_2 = 1 \cdot 2 = 2.
  3. Conv2 (K3=3,S3=1K_3 = 3, S_3 = 1): RF3=4+(31)2=8RF_3 = 4 + (3 - 1) \cdot 2 = 8, J3=21=2J_3 = 2 \cdot 1 = 2.
  4. MaxPool2 (K4=2,S4=2K_4 = 2, S_4 = 2): RF4=8+(21)2=10RF_4 = 8 + (2 - 1) \cdot 2 = 10, J4=22=4J_4 = 2 \cdot 2 = 4.
  5. Conv3 (K5=3,S5=1K_5 = 3, S_5 = 1): RF5=10+(31)4=18RF_5 = 10 + (3 - 1) \cdot 4 = 18, J5=41=4J_5 = 4 \cdot 1 = 4.

Пулинг и шаг свертки выступают мультипликаторами: благодаря им рецептивное поле на глубоких слоях растет не линейно, а экспоненциально.

Теоретическое vs Эффективное рецептивное поле (ERF) Теоретическое поле рассчитывает крайние границы пикселей, которые чисто математически связаны с выходом через граф вычислений. Однако вклад центральных пикселей в результат многократно выше вклада граничных — распределение чувствительности нейрона имеет форму двумерного распределения Гаусса. Эффективное рецептивное поле (Effective Receptive Field) обычно занимает лишь часть теоретического окна.


Переход от карт признаков к классификации: Flatten против Global Pooling

После прохождения через каскады сверток и пулингов (так называемый Backbone, или костяк признаков) 4D-тензор батча имеет форму (N,C,H,W)(N, C, H, W), где HH и WW малы (например, 7×77 \times 7), а число каналов CC велико (например, 512 или 2048). Каждый канал представляет собой карту наличия определенного абстрактного признака.

Каким образом преобразовать этот тензор в вектор логитов размерности (N,num_classes)(N, \text{num\_classes})?

Классический подход (Flatten + MLP) и его проблемы

В ранних сетях (AlexNet, VGG) выход сверточной части выпрямлялся в 1D-вектор:

(N,C,H,W)Flatten(N,CHW)(N, C, H, W) \xrightarrow{\text{Flatten}} (N, C \cdot H \cdot W)

Затем следовал массивный блок полносвязных слоев (nn.Linear).

Рассчитаем число параметров для классификационной «головы» сети VGG-16, где выход сверточной части имеет размер (N,512,7,7)(N, 512, 7, 7), а первый полносвязный слой проецирует признаки в 4096 нейронов:

Параметры=Dout(Din+1)=4096(51277+1)=409625089=102764544\text{Параметры} = D_{out} \cdot (D_{in} + 1) = 4096 \cdot (512 \cdot 7 \cdot 7 + 1) = 4096 \cdot 25\,089 = 102\,764\,544

Один лишь первый линейный слой содержит более 102 миллионов параметров, что требует свыше 400 МБ памяти только для хранения весов (в float32).

Недостатки подхода Flatten:

  • Катастрофическое переобучение: подавляющая часть весов всей нейросети сосредоточена в полносвязном блоке, что требует агрессивного Dropout и регуляризации.
  • Жесткая привязка к входному разрешению: если обучить сеть на кадрах 224×224224 \times 224, размер карты на выходе сверток будет 7×77 \times 7. Если подать изображение 448×448448 \times 448, выход станет 14×1414 \times 14, размер вектора после Flatten вырастет в 4 раза, и nn.Linear выбросит исключение несоответствия размерностей (RuntimeError: shape mismatch).

Современный стандарт: Global Average Pooling (GAP)

В архитектуре Network in Network (Lin et al., 2013), а затем в ResNet и Inception был внедрен механизм глобального усреднения — Global Average Pooling (GAP).

Global Average Pooling (GAP) — операция пространственного сжатия, которая вычисляет единственное среднее значение для каждой двумерной карты признаков размером H×WH \times W, схлопывая пространственные размерности до 1×11 \times 1.

Трансформация формы тензора:

(N,C,H,W)GAP(N,C,1,1)Squeeze / Flatten(N,C)(N, C, H, W) \xrightarrow{\text{GAP}} (N, C, 1, 1) \xrightarrow{\text{Squeeze / Flatten}} (N, C)

Математически для каждого канала cc:

yc=1HWi=1Hj=1Wxc,i,jy_c = \frac{1}{H \cdot W} \sum_{i=1}^{H} \sum_{j=1}^{W} x_{c, i, j}

После слоя GAP вектор признаков размерности (N,C)(N, C) сразу подается на единственный линейный классификатор nn.Linear(C, num_classes).

Сверточный бэкбон:       [N, 512, 7, 7]
       │
       ▼
AdaptiveAvgPool2d((1, 1)): [N, 512, 1, 1]
       │
       ▼
Flatten (start_dim=1):    [N, 512]
       │
       ▼
Linear(512, 10):          [N, 10] (Логиты классов)

Сравнение подходов

Критерий Flatten + Полносвязные слои Global Average Pooling (GAP)
Число обучаемых параметров классификатора Огромное (десятки–сотни млн) Минимальное (CclassesC \cdot \text{classes})
Склонность к переобучению Очень высокая Низкая (GAP действует как структурный регуляризатор)
Чувствительность к входному разрешению Строго фиксированный размер кадра Поддержка произвольного размера кадра
Интерпретируемость Низкая (веса размазаны по координатам) Высокая (канал cc напрямую связан с весом класса)

Сборка сверточной модели на PyTorch

Объединим изученные компоненты: блоки свертки, слои Max-Pooling для редукции разрешения, адаптивный пулинг nn.AdaptiveAvgPool2d и компактную линейную классификационную голову.

В PyTorch слой nn.AdaptiveAvgPool2d(output_size) вычисляет размер ядра и шаг автоматически на основе входной размерности, гарантируя, что на выходе получится тензор с заданным разрешением (output_size, output_size).

import torch
import torch.nn as nn

class IndustrialDefectCNN(nn.Module):
    """
    Сверточная сеть для классификации дефектов на производстве.
    Принимает RGB-изображение произвольного размера (>= 32x32),
    возвращает сырые логиты для num_classes классов.
    """
    def __init__(self, num_classes: int = 4):
        super().__init__()

        # Сверточный блок 1: 3 -> 32 канала, сжатие 224x224 -> 112x112
        self.block1 = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )

        # Сверточный блок 2: 32 -> 64 канала, сжатие 112x112 -> 56x56
        self.block2 = nn.Sequential(
            nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )

        # Сверточный блок 3: 64 -> 128 каналов, каскад двух Conv 3x3 (RF 5x5)
        # Сжатие 56x56 -> 28x28
        self.block3 = nn.Sequential(
            nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(in_channels=128, out_channels=128, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )

        # Глобальный адаптивный пулинг: сжатие любого (H, W) -> (1, 1)
        self.global_pool = nn.AdaptiveAvgPool2d(output_size=(1, 1))

        # Классификационная голова
        self.classifier = nn.Sequential(
            nn.Flatten(start_dim=1),
            nn.Dropout(p=0.3),
            nn.Linear(in_features=128, out_features=num_classes)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Извлечение признаков (Feature Extraction)
        x = self.block1(x)
        x = self.block2(x)
        x = self.block3(x)

        # Глобальное усреднение
        x = self.global_pool(x)

        # Классификация
        logits = self.classifier(x)
        return logits

if __name__ == "__main__":
    # Проверка работы модели на батче изображений
    model = IndustrialDefectCNN(num_classes=4)

    # Имитация мини-батча из 8 кадров 224x224x3
    dummy_input = torch.randn(8, 3, 224, 224)
    output_logits = model(dummy_input)

    print(f"Форма входного тензора:  {dummy_input.shape}")
    print(f"Форма выходных логитов:  {output_logits.shape}")

    # Проверка независимости от разрешения (подаем кадр 320x320)
    variable_input = torch.randn(2, 3, 320, 320)
    variable_output = model(variable_input)
    print(f"Выход для кадра 320x320: {variable_output.shape}")

Проследим трансформацию формы тензора на каждом этапе выполнения forward:

  1. Вход: (8, 3, 224, 224)
  2. После block1: (8, 32, 112, 112)
  3. После block2: (8, 64, 56, 56)
  4. После block3: (8, 128, 28, 28)
  5. После global_pool: (8, 128, 1, 1)
  6. После Flatten: (8, 128)
  7. Выход classifier: (8, 4)

Благодаря использованию AdaptiveAvgPool2d((1, 1)) классификатор содержит всего 128×4+4=516128 \times 4 + 4 = 516 обучаемых параметров, а сеть способна обрабатывать изображения любого входного разрешения без падений по ошибке несоответствия форм матриц.

Пакетная нормализация BatchNorm2d и стабилизация обучения глубоких сверточных сетей

Пакетная нормализация BatchNorm2d и стабилизация обучения глубоких сверточных сетей

Попробуйте увеличить скорость обучения (learning rate) в глубокой сверточной сети без нормализации всего в 5 раз — и оптимизация мгновенно разрушится: градиенты либо обратятся в ноль, либо взорвутся до значений NaN. До 2015 года обучение сетей глубже 15–20 слоев напоминало хождение по канату: требовались ювелирная инициализация весов и крошечные шаги оптимизатора.

Причина этой нестабильности кроется в постоянном изменении распределения входных сигналов на каждом слое по мере того, как корректируются веса всех предыдущих слоев. Решением стала концепция пакетной нормализации (Batch Normalization), которая не просто ускорила сходимость сетей в десятки раз, но и изменила фундаментальные правила конструирования сверточных блоков.


Проблема дрейфа активаций в глубоких сетях

Когда входное изображение проходит через последовательность сверточных слоев, каждый слой преобразует тензор признаков. Во время обратного распространения ошибки градиентный спуск обновляет веса всех слоев одновременно.

Это создает эффект взаимосвязанного сдвига: даже небольшое изменение параметров на первом слое приводит к заметному изменению распределения его выходных значений. Для второго слоя это означает, что его входные данные постоянно меняют свое среднее значение и дисперсию. К десятому или двадцатому слою эти флуктуации накапливаются лавинообразно.

Дрейф внутренних распределений (Internal Covariate Shift) — явление в глубоких нейросетях, при котором распределение входных активаций промежуточных слоев непрерывно изменяется в процессе оптимизации весов предшествующих слоев.

Если распределение выходов слоя смещается в область насыщения функций активации, производные затухают, блокируя градиентный поток к начальным слоям сети. Чтобы предотвратить этот сдвиг, сеть вынуждена учиться крайне медленно, делая микроскопические шаги оптимизации.


Математический аппарат BatchNorm2d: нормализация 4D-тензоров

В сверточных сетях признаковые данные представлены четырехмерным тензором формата NCHWNCHW: размер пакета (NN), число каналов (CC), высота (HH) и ширина (WW).

Ключевая особенность двумерной пакетной нормализации (BatchNorm2d) заключается в том, что статистика вычисляется независимо для каждого отдельного канала, но объединяет в себе все сэмплы мини-батча и все пространственные координаты.

Для каждого канала c[1,C]c \in [1, C] рассматривается совокупность из m=N×H×Wm = N \times H \times W скалярных значений. Математический алгоритм прямого прохода состоит из четырех шагов:

  1. Вычисление выборочного среднего значения по батчу и пространству:

μc=1NHWi=1Nh=1Hw=1Wxi,c,h,w\mu_c = \frac{1}{N \cdot H \cdot W} \sum_{i=1}^{N} \sum_{h=1}^{H} \sum_{w=1}^{W} x_{i, c, h, w}

где:

  • NN — число изображений в мини-батче;
  • H,WH, W — высота и ширина карты признаков;
  • xi,c,h,wx_{i, c, h, w} — значение активации конкретного пикселя ii-го изображения в канале cc.

Пример: Если в батче 16 изображений с картами признаков 8×88 \times 8, среднее μc\mu_c вычисляется по 16×8×8=102416 \times 8 \times 8 = 1024 точкам этого канала.

  1. Вычисление выборочной дисперсии канала:

σc2=1NHWi=1Nh=1Hw=1W(xi,c,h,wμc)2\sigma_c^2 = \frac{1}{N \cdot H \cdot W} \sum_{i=1}^{N} \sum_{h=1}^{H} \sum_{w=1}^{W} (x_{i, c, h, w} - \mu_c)^2

где σc2\sigma_c^2 показывает разброс активаций канала cc относительно вычисленного среднего μc\mu_c.

  1. Стандартизация (Z-нормализация):

x^i,c,h,w=xi,c,h,wμcσc2+ϵ\hat{x}_{i, c, h, w} = \frac{x_{i, c, h, w} - \mu_c}{\sqrt{\sigma_c^2 + \epsilon}}

где ϵ\epsilon (обычно 10510^{-5}) — малая константа численной стабильности, предотвращающая деление на ноль при нулевой дисперсии. После этого шага распределение значений канала имеет строго нулевое среднее (E[x^]=0\mathbb{E}[\hat{x}] = 0) и единичную дисперсию (Var(x^)=1\text{Var}(\hat{x}) = 1).

  1. Масштабирование и сдвиг (Scale and Shift):

yi,c,h,w=γcx^i,c,h,w+βcy_{i, c, h, w} = \gamma_c \hat{x}_{i, c, h, w} + \beta_c

где:

  • γc\gamma_c (гамма) — обучаемый параметр масштаба для канала cc;
  • βc\beta_c (бета) — обучаемый параметр сдвига для канала cc.
Зачем нужны параметры γ\gamma и β\beta?

Если бы слой ограничивался только стандартизацией x^\hat{x}, все активации принудительно загонялись бы в окрестность нуля с единичным разбросом. Для функции активации вроде Sigmoid или Tanh это означало бы попадание строго в линейный участок около нуля, что лишило бы сеть возможности моделировать нелинейные зависимости.

Обучаемые параметры γc\gamma_c и βc\beta_c возвращают сети репрезентативную емкость. Сеть сама определяет оптимальное распределение активаций: если градиентному спуску потребуется полностью отменить нормализацию для некоторого канала, он может выставить γc=σc2+ϵ\gamma_c = \sqrt{\sigma_c^2 + \epsilon} и βc=μc\beta_c = \mu_c, вернув исходный тензор xx.

По умолчанию в PyTorch при инициализации слоя nn.BatchNorm2d(C) вектор γ\gamma заполняется единицами, а вектор β\beta — нулями.


Архитектурное правило: почему сверточный слой теряет bias

В полносвязных и сверточных слоях без нормализации к результату матричного перемножения всегда добавляется обучаемый вектор смещения: z=Wx+bz = Wx + b. Однако при размещении BatchNorm2d сразу после свертки параметр bb становится математически бесполезным.

Проследим, что происходит со смещением bcb_c канала при вычислении нормализации:

μc=1m((Wx)i,c,h,w+bc)=(1m(Wx)i,c,h,w)+bc\mu_c = \frac{1}{m} \sum ( (Wx)_{i, c, h, w} + b_c ) = \left( \frac{1}{m} \sum (Wx)_{i, c, h, w} \right) + b_c

Теперь подставим это в формулу центрирования числителя:

xi,c,h,wμc=((Wx)i,c,h,w+bc)(1m(Wx)i,c,h,w+bc)=(Wx)i,c,h,wμWxx_{i, c, h, w} - \mu_c = \left( (Wx)_{i, c, h, w} + b_c \right) - \left( \frac{1}{m} \sum (Wx)_{i, c, h, w} + b_c \right) = (Wx)_{i, c, h, w} - \mu_{Wx}

Константа bcb_c полностью вычитается сама из себя и исчезает из градиентного графа. Ее роль целиком берет на себя параметр βc\beta_c из слоя нормализации.

Правило проектирования: если за сверточным слоем nn.Conv2d следует слой nn.BatchNorm2d, в свертке всегда необходимо отключать смещение: nn.Conv2d(..., bias=False). Это экономит память GPU и устраняет неиспользуемые градиентные вычисления.


Поведение слоя: режим обучения (Train) против оценки (Eval)

Работа BatchNorm2d кардинально различается в зависимости от текущего режима модели.

Характеристика Режим обучения (model.train()) Режим инференса (model.eval())
Источник среднего и дисперсии Текущий мини-батч (μB,σB2\mu_B, \sigma_B^2) Накопленные скользящие статистики (μ^run,σ^run2\hat{\mu}_{\text{run}}, \hat{\sigma}_{\text{run}}^2)
Зависимость между сэмплами Сэмплы взаимно влияют друг на друга внутри батча Полная детерминированность: сэмпл обрабатывается изолированно
Обучаемые веса (γ,β\gamma, \beta) Обновляются через loss.backward() и шаг оптимизатора Заморожены, используются как фиксированные константы
Обновление буферов Экспоненциальное сглаживание скользящих средних Буферы не изменяются
Механизм накопления статистик (Running Statistics)

Во время инференса на вход модели может поступить одно-единственное изображение (N=1N=1). Вычислить дисперсию по одному сэмплу невозможно (или она будет искажена), поэтому во время обучения BatchNorm2d параллельно ведет учет глобальных статистик датасета с помощью экспоненциального скользящего среднего (EMA):

μ^run(1α)μ^run+αμB\hat{\mu}_{\text{run}} \leftarrow (1 - \alpha) \cdot \hat{\mu}_{\text{run}} + \alpha \cdot \mu_B

σ^run2(1α)σ^run2+ασB2\hat{\sigma}_{\text{run}}^2 \leftarrow (1 - \alpha) \cdot \hat{\sigma}_{\text{run}}^2 + \alpha \cdot \sigma_B^2

Здесь α\alpha — коэффициент сглаживания (в PyTorch задается параметром momentum=0.1 по умолчанию). Обратите внимание: этот momentum относится к обновлению статистик нормализации и не имеет отношения к параметру моментов в оптимизаторах SGD.

В режиме model.eval() операция стандартизации превращается в строго детерминированное линейное преобразование:

y=γσ^run2+ϵx+(βγμ^runσ^run2+ϵ)y = \frac{\gamma}{\sqrt{\hat{\sigma}_{\text{run}}^2 + \epsilon}} \cdot x + \left( \beta - \frac{\gamma \cdot \hat{\mu}_{\text{run}}}{\sqrt{\hat{\sigma}_{\text{run}}^2 + \epsilon}} \right)


Влияние размера мини-батча и регуляризующий эффект

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

Этот шум действует как мягкий регуляризатор, похожий на Dropout: он мешает отдельным нейронам сонастраиваться под конкретные примеры.

Однако здесь кроется и главное ограничение BatchNorm2d: критическая зависимость от размера пакета.

  • Большие батчи (N32N \geq 32): оценки μB\mu_B и σB2\sigma_B^2 точны и стабильны, обучение проходит с высокой скоростью.
  • Малые батчи (N4N \leq 4): выборочные средние и дисперсии становятся чрезмерно зашумленными. Накопленные скользящие статистики расходятся с реальным распределением данных, что приводит к резкой деградации точности на валидации.

Сборка сверточного блока в PyTorch

Канонический сверточный блок современных архитектур строится по цепочке Conv \to BatchNorm \to Activation. Нормализация помещается строго между линейной пространственной операцией и нелинейной функцией активации.

import torch
import torch.nn as nn

class ConvBNReLUBlock(nn.Module):
    """
    Базовый сверточный блок с пакетной нормализацией.
    Демонстрирует корректную изоляцию параметров: bias=False в свертке,
    обучаемые scale/shift в BatchNorm2d.
    """
    def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3, stride: int = 1):
        super().__init__()

        # Шаг 1: Свертка без смещения (bias=False)
        self.conv = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=kernel_size,
            stride=stride,
            padding=kernel_size // 2,
            bias=False
        )

        # Шаг 2: Нормализация с 2 * out_channels обучаемыми параметрами (gamma, beta)
        self.bn = nn.BatchNorm2d(num_features=out_channels, eps=1e-5, momentum=0.1)

        # Шаг 3: Нелинейность
        self.act = nn.ReLU(inplace=True)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.act(self.bn(self.conv(x)))

# Проверка размерностей и параметров
block = ConvBNReLUBlock(in_channels=64, out_channels=128)
dummy_input = torch.randn(8, 64, 32, 32) # Пакет из 8 карт признаков 32x32

output = block(dummy_input)
print(f"Форма входа: {dummy_input.shape}")
print(f"Форма выхода: {output.shape}")

# Подсчет параметров:
# Conv: 128 * (64 * 3 * 3) = 73 728 весов (без bias)
# BN: 128 (gamma) + 128 (beta) = 256 обучаемых параметров
print(f"Обучаемых параметров в Conv: {sum(p.numel() for p in block.conv.parameters())}")
print(f"Обучаемых параметров в BN:   {sum(p.numel() for p in block.bn.parameters())}")

Благодаря слою BatchNorm2d ландшафт функции потерь становится значительно более гладким (уменьшается константа Липшица для градиентов). Это позволяет использовать темпы обучения в 10–30 раз выше базовых, ускоряя сходимость нейросетей от нескольких недель до нескольких часов и делая глубокие архитектуры устойчивыми к инициализации.

Архитектурная революция: остаточные связи в ResNet и эффективные блоки современных CNN

Архитектурная революция: остаточные связи в ResNet и эффективные блоки современных CNN

В 2015 году исследователи из Microsoft Research столкнулись с феноменом, который поставил под сомнение общепринятый принцип «глубже сеть — выше точность». Сеть глубиной в 56 слоев показала ошибку на обучающей выборке заметно выше, чем сеть из 20 слоев. Это не было переобучением (при переобучении ошибка на тренировочных данных падает почти до нуля) и не являлось классическим затуханием градиента (в обеих сетях уже использовалась пакетная нормализация BatchNorm2d, стабилизирующая дисперсию активаций). Проблема заключалась в принципиальной трудности оптимизации: добавление новых слоев в прямолинейный стек приводило к тому, что сеть деградировала, теряя способность выучивать даже простое тождественное преобразование.

Решением стал переход от аппроксимации целевого отображения напрямую к обучению остаточных функций (Residual Learning). Архитектура ResNet не просто решила проблему деградации, но и открыла дорогу сверхглубоким сетям из сотен слоев, заложив фундамент для современных эффективных бэкбонов вроде MobileNet и EfficientNet.


Математическая природа остаточного обучения

В классических прямоточных сетях (таких как VGG) стек из нескольких сверточных слоев пытается напрямую аппроксимировать некоторую целевую функцию отображения H(x)H(x), где xx — входной тензор блока.

Если оптимальным решением для добавленных глубоких слоев является простое сохранение уже извлеченных признаков (тождественное отображение H(x)=xH(x) = x), стек параметрических слоев со случайной инициализацией и нелинейностями ReLU тратит огромные вычислительные усилия на подбор матриц весов WW, которые приближали бы единичную матрицу. На практике градиентный спуск часто застревает в локальных субоптимальных минимумах, ухудшая итоговое качество.

Ключевой инсайт остаточного обучения (ResNet):

Вместо того чтобы заставлять стек слоев аппроксимировать исходную функцию H(x)H(x), мы репараметризуем задачу: заставляем слои аппроксимировать остаточную функцию (residual) F(x)=H(x)xF(x) = H(x) - x. В таком случае исходное преобразование восстанавливается тривиальным сложением:

H(x)=F(x)+xH(x) = F(x) + x

Здесь xx — входной тензор признаков, F(x)F(x) — отображение, реализуемое последовательностью сверток, нормализаций и нелинейностей внутри блока, а H(x)H(x) — результирующий выходной тензор.

Если для задачи оптимально тождественное отображение, оптимизатору достаточно свести веса сверточных слоев внутри F(x)F(x) к нулю — функция ReLU и регуляризация весов естественным образом подталкивают параметры к нулевым значениям. В результате блок автоматически превращается в тождественный оператор H(x)=0+x=xH(x) = 0 + x = x.

Градиентная магистраль: почему ResNet обучается на глубине 152+ слоев

Главное следствие остаточной связи проявляется на этапе обратного распространения ошибки. Применим правило дифференцирования сложной функции (Chain Rule) для вычисления градиента функции потерь L\mathcal{L} по входному тензору xx:

Lx=LHHx=LH(F(x)x+1)=LHF(x)x+LH\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial H} \cdot \frac{\partial H}{\partial x} = \frac{\partial \mathcal{L}}{\partial H} \cdot \left(\frac{\partial F(x)}{\partial x} + 1\right) = \frac{\partial \mathcal{L}}{\partial H} \cdot \frac{\partial F(x)}{\partial x} + \frac{\partial \mathcal{L}}{\partial H}

В этой формуле:

  • Lx\frac{\partial \mathcal{L}}{\partial x} — градиент функции потерь по входу блока, передаваемый на предыдущие слои;
  • LH\frac{\partial \mathcal{L}}{\partial H} — градиент ошибки, пришедший с верхних (более глубоких) слоев;
  • F(x)x\frac{\partial F(x)}{\partial x} — матрица частных производных параметрической ветки сверток;
  • Слагаемое +1+1 (или единичная матрица в тензорном виде) — производная тождественного пути xx.

Слагаемое LH\frac{\partial \mathcal{L}}{\partial H} в правой части уравнения передается назад напрямую, без умножения на матрицы весов или производные функций активации. Даже если градиенты параметрического пути F(x)x\frac{\partial F(x)}{\partial x} обратятся в ноль или станут крайне малыми, сигнал ошибки без затухания распространяется от классификационной головы до самых первых слоев через аддитивную «градиентную магистраль».


Архитектурные блоки: BasicBlock и Bottleneck

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

       BasicBlock (ResNet-18, 34)             Bottleneck Block (ResNet-50, 101, 152)

              Вход x [C]                                    Вход x [C]
               │      ╲                                      │      ╲
       [Conv 3x3, C]   │                             [Conv 1x1, C/4] │
       [BatchNorm2d]   │                             [BatchNorm2d]   │
       [    ReLU   ]   │                             [    ReLU   ]   │
               │       │                                     │       │
       [Conv 3x3, C]   │                             [Conv 3x3, C/4] │ (Skip Connection)
       [BatchNorm2d]   │ (Identity)                  [BatchNorm2d]   │
               │       │                             [    ReLU   ]   │
               │       │                                     │       │
               │       │                             [Conv 1x1, C]   │
               │       │                             [BatchNorm2d]   │
               ▼       ▼                                     ▼       ▼
             Сложение (+)                                  Сложение (+)
                   │                                             │
               [ ReLU ]                                      [ ReLU ]
                   │                                             │
              Выход [C]                                     Выход [C]

1. BasicBlock (для неглубоких сетей ResNet-18 и ResNet-34)

Состоит из двух последовательных сверток 3×33 \times 3 с одинаковым числом каналов CC. Каждая свертка сопровождается слоем BatchNorm2d, а нелинейность ReLU применяется после первой нормализации и после сложения с пропущенным входом:

  • Свертка 3×33 \times 3 (вход CC, выход CC) \to BatchNorm \to ReLU
  • Свертка 3×33 \times 3 (вход CC, выход CC) \to BatchNorm
  • Поэлементное сложение с xx \to итоговый ReLU

2. Bottleneck Block (для глубоких сетей ResNet-50, ResNet-101, ResNet-152)

С увеличением глубины прямое использование сверток 3×33 \times 3 с большим числом каналов приводит к квадратичному росту параметров и операций умножения-сложения (FLOPs). Блок Bottleneck решает это за счет трехэтапной трансформации «сжатие — обработка — восстановление»:

  1. Сжатие (Squeeze): свертка 1×11 \times 1 сокращает число каналов в 4 раза (с CC до C/4C/4).
  2. Пространственная обработка: свертка 3×33 \times 3 работает в сжатом пространстве признаков (C/4C/4 каналов).
  3. Восстановление (Expand): свертка 1×11 \times 1 восстанавливает исходную размерность каналов (с C/4C/4 обратно до CC).

Если входной тензор имеет C=256C = 256 каналов, то вместо двух сверток 3×33 \times 3 (число умножений пропорционально 2×3×3×256×2561.18×1062 \times 3 \times 3 \times 256 \times 256 \approx 1.18 \times 10^6), блок Bottleneck выполняет:

  • Свертка 1×11 \times 1: 1×1×256×64=163841 \times 1 \times 256 \times 64 = 16\,384
  • Свертка 3×33 \times 3: 3×3×64×64=368643 \times 3 \times 64 \times 64 = 36\,864
  • Свертка 1×11 \times 1: 1×1×64×256=163841 \times 1 \times 64 \times 256 = 16\,384
  • Суммарно: 6963269\,632 операции — в 17 раз меньше вычислений при сопоставимой выразительной способности.

Согласование размерностей при даунсэмплинге (Downsampling)

Когда блок ResNet уменьшает пространственное разрешение карты признаков в 2 раза (с помощью свертки со stride=2) или увеличивает число каналов, прямая сумма F(x)+xF(x) + x невозможна из-за несовпадения форм тензоров: F(x)F(x) имеет форму (N,Cout,H/2,W/2)(N, C_{out}, H/2, W/2), а xx(N,Cin,H,W)(N, C_{in}, H, W).

В таких точках на пути skip connection устанавливается проекционный слой (downsample shortcut), состоящий из свертки 1×11 \times 1 со stride=2 и слоя BatchNorm2d:

H(x)=F(x)+WsxH(x) = F(x) + W_s x

где WsW_s — обучаемая проекция 1×11 \times 1, приводящая xx к пространственному разрешению и числу каналов выхода F(x)F(x).


Реализация остаточного блока на PyTorch

Построим законченную реализацию блока Bottleneck, поддерживающую как сохранение размерностей (тождественный путь), так и пространственный даунсэмплинг.

import torch
import torch.nn as nn

class BottleneckBlock(nn.Module):
    expansion: int = 4

    def __init__(
        self,
        in_channels: int,
        base_channels: int,
        stride: int = 1,
    ) -> None:
        super().__init__()
        out_channels = base_channels * self.expansion

        # 1. Сжатие каналов: 1x1
        self.conv1 = nn.Conv2d(
            in_channels, base_channels, kernel_size=1, bias=False
        )
        self.bn1 = nn.BatchNorm2d(base_channels)

        # 2. Пространственная свертка: 3x3
        self.conv2 = nn.Conv2d(
            base_channels,
            base_channels,
            kernel_size=3,
            stride=stride,
            padding=1,
            bias=False,
        )
        self.bn2 = nn.BatchNorm2d(base_channels)

        # 3. Восстановление каналов: 1x1
        self.conv3 = nn.Conv2d(
            base_channels, out_channels, kernel_size=1, bias=False
        )
        self.bn3 = nn.BatchNorm2d(out_channels)

        self.relu = nn.ReLU(inplace=True)

        # Ветка Skip Connection
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(
                    in_channels,
                    out_channels,
                    kernel_size=1,
                    stride=stride,
                    bias=False,
                ),
                nn.BatchNorm2d(out_channels),
            )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        identity = self.shortcut(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
        out = self.relu(out)
        return out

Эволюция эффективности: от ResNet к MobileNet и EfficientNet

Остаточные связи открыли возможность масштабирования сетей в глубину, однако классическая двумерная свертка остается вычислительно «тяжелой» операцией. Она одновременно выполняет два преобразования: фильтрует пространственные паттерны (K×KK \times K) и комбинирует информацию между каналами (CinCoutC_{in} \to C_{out}).

1. Глубинно-разделимая свертка (Depthwise Separable Convolution)

В архитектуре MobileNet стандартная 3D-свертка факторизуется на две независимые операции:

  1. Depthwise Convolution (поканальная): к каждому входному каналу применяется отдельный пространственный 2D-фильтр K×KK \times K. Число каналов не меняется, межполевое взаимодействие отсутствует (в PyTorch: nn.Conv2d(C, C, kernel_size=K, groups=C)).
  2. Pointwise Convolution (точечная): обычная свертка 1×11 \times 1, которая линейно комбинирует выходы всех каналов в новые CoutC_{out} признаков.

Сравним вычислительную сложность для карты признаков размера H×WH \times W:

  • Стандартная свертка: вычисления = H×W×Cin×Cout×K2H \times W \times C_{in} \times C_{out} \times K^2
  • Depthwise + Pointwise: вычисления = H×W×Cin×K2+H×W×Cin×Cout×12H \times W \times C_{in} \times K^2 + H \times W \times C_{in} \times C_{out} \times 1^2

Отношение вычислительной сложности:

ВычисленияDSВычисленияStandard=HWCinK2+HWCinCoutHWCinCoutK2=1Cout+1K2\frac{\text{Вычисления}_{\text{DS}}}{\text{Вычисления}_{\text{Standard}}} = \frac{H \cdot W \cdot C_{in} \cdot K^2 + H \cdot W \cdot C_{in} \cdot C_{out}}{H \cdot W \cdot C_{in} \cdot C_{out} \cdot K^2} = \frac{1}{C_{out}} + \frac{1}{K^2}

Для стандартного ядра 3×33 \times 3 (K=3K=3) при достаточно большом CoutC_{out} (например, 128 или 256) отношение стремится к 19\frac{1}{9}. Глубинно-разделимая свертка сокращает число операций и параметров в 8–9 раз, теряя менее 1% точности.

2. Инвертированные остаточные блоки (Inverted Residuals / MBConv)

Архитектуры MobileNetV2 и EfficientNet развили эту идею, объединив остаточные связи и разделимые свертки в блок MBConv:

Параметр ResNet Bottleneck MBConv (MobileNetV2 / EfficientNet)
Схема каналов Широкий \to Узкий \to Широкий (сжатие) Узкий \to Широкий \to Узкий (расширение)
Пространственная свертка Стандартная 3×33 \times 3 в узком слое Depthwise 3×33 \times 3 или 5×55 \times 5 в расширенном слое
Skip Connection Соединяет широкие представления Соединяет узкие «бутылочные горлышки»
Финальная активация ReLU Linear (без активации, во избежание потери информации)
                       MBConv Block (Inverted Residual)

                                 Вход x [C_in]
                                  │         ╲
                       [Conv 1x1, Expansion] │
                       [BatchNorm2d + SiLU ] │
                                  │          │
                       [DW Conv 3x3/5x5    ] │
                       [BatchNorm2d + SiLU ] │ (Skip Connection,
                                  │          │  если stride=1
                       [  Squeeze-and-     ] │  и C_in == C_out)
                       [  Excitation (SE)  ] │
                                  │          │
                       [Conv 1x1, C_out    ] │
                       [BatchNorm2d        ] │ (Linear Bottleneck — БЕЗ активации!)
                                  ▼          ▼
                                Сложение (+)
                                     │
                                 Выход [C_out]

В блоке MBConv признаковое пространство сначала расширяется (обычно с коэффициентом expansion=6\text{expansion} = 6) с помощью свертки 1×11 \times 1, затем обрабатывается Depthwise-фильтром высокой выразительности, после чего проецируется обратно в компактный вектор признаков без нелинейности (Linear Bottleneck). Skip-connection прокладывается между узкими представлениями, что кардинально экономит оперативную память при хранении промежуточных градиентов.

3. Механизм внимания Squeeze-and-Excitation (SE) и масштабирование EfficientNet

В блоках EfficientNet поверх MBConv добавлен легковесный механизм канального внимания (SE-блок):

  1. Squeeze: Global Average Pooling сжимает карту признаков (C,H,W)(C, H, W) в вектор размера (C,1,1)(C, 1, 1).
  2. Excitation: двухслойная полносвязная мини-сеть (CC/rCC \to C/r \to C) с функцией Sigmoid генерирует весовые коэффициенты важности каждого канала в диапазоне [0,1][0, 1].
  3. Scale: исходные карты признаков поканально домножаются на полученные коэффициенты, усиливая информативные каналы и подавляя шумные.

EfficientNet объединил эти компоненты со стратегией составного масштабирования (Compound Scaling): одновременным пропорциональным увеличением глубины сети (числа слоев), ширины (числа каналов) и разрешения входного изображения с помощью фиксированного коэффициента ϕ\phi, достигнув рекордной точности при минимальном числе параметров.


Сравнительный обзор современных классификационных бэкбонов

При выборе архитектуры для прикладной системы компьютерного зрения инженер ориентируется на баланс точности, объема параметров и аппаратной задержки (Latency):

Архитектура Число параметров (Top-1 ImageNet) Особенности применения Оптимальный сценарий
ResNet-18 / 34 11.7M / 21.8M (~70–73%) Простая структура, высокая скорость на GPU благодаря стандартным 3×33 \times 3 сверткам Быстрый baseline, задачи с жесткими требованиями к простоте архитектуры
ResNet-50 25.6M (~76.1%) Индустриальный стандарт надежности, отличная поддержка TensorRT/ONNX Серверный инференс общего назначения, детекция и сегментация
MobileNetV3-Large 5.4M (~75.2%) Depthwise-свертки, оптимизирована под мобильные CPU/NPU Edge-устройства, микроконтроллеры, мобильные приложения
EfficientNet-B0 / B4 5.3M / 19.3M (~77.1% / ~83.0%) Максимальная точность на единицу параметров за счет MBConv + SE Высокоточная классификация при ограниченном вычислительном бюджете

Понимание устройства этих архитектур подводит нас к следующему шагу: на практике современные сверточные сети крайне редко обучают со случайной инициализацией с нуля. Вместо этого используют предобученные веса (на миллионах изображений ImageNet) и адаптируют их под целевую производственную задачу — метод, известный как Transfer Learning.

Методология переноса обучения (Transfer Learning): от извлечения признаков к тонкой настройке (Fine-Tuning)

Методология переноса обучения (Transfer Learning): от извлечения признаков к тонкой настройке (Fine-Tuning)

Обучение глубокой сверточной сети уровня ResNet-50 или EfficientNet «с нуля» требует порядка 1.3×1061.3 \times 10^6 тщательно размеченных изображений (объем ImageNet-1K), сотен часов вычислений на кластерах GPU и миллионов итераций градиентного спуска. Однако в прикладных задачах машинного зрения — будь то дефектоскопия сварных швов, сортировка деталей на конвейере или анализ медицинских снимков — инженер редко располагает выборкой более чем в 200–2000 примеров. Попытка обучить 25 миллионов параметров на такой выборке неизбежно приводит к катастрофическому переобучению: сеть моментально запоминает обучающие кадры, теряя способность к обобщению.

Решением этого фундаментального противоречия является перенос обучения (Transfer Learning) — методология, позволяющая использовать представления, сформированные моделью на масштабном базовом датасете, для решения новой целевой задачи с минимальными затратами данных и вычислительных ресурсов.

Иерархия визуальных признаков: почему глубокие сети универсальны

Успех переноса обучения опирается на фундаментальное свойство сверточных архитектур: пространственно-иерархическую организацию извлекаемых признаков.

Сверточные фильтры не изолированы — каждый последующий слой агрегирует отклики предыдущих через расширение рецептивного поля:

  1. Начальные слои (Low-level features): фильтры первых слоев (3×33 \times 3 или 7×77 \times 7) настраиваются на базовые геометрические примитивы: перепады яркости, направленные границы (Gabor-подобные фильтры), углы, цветовые пятна и градиентные переходы. Эти визуальные кирпичики инвариантны к предметной области: граница металлической пластины математически устроена так же, как контур листа дерева или край кости на рентгене.
  2. Промежуточные слои (Mid-level features): комбинируют простые ребра в повторяющиеся паттерны, сетки, окружности, текстурные решетки и локальные фрагменты форм.
  3. Глубокие слои (High-level / Task-specific features): собирают фрагменты в целостные семантические объекты или их характерные части (колеса, фары, специфические топологии дефектов, морфологические структуры клеток).

Поскольку низкоуровневые и среднеуровневые фильтры универсальны для всего визуального мира, обучать их заново бессмысленно. Достаточно перенести готовую систему извлечения признаков (бэкбон) и адаптировать ее под конкретный класс объектов.

Формализация переноса: домен-источник и целевой домен

Математически перенос обучения оперирует понятиями предметной области (домена) и задачи.

Домен D={X,P(X)}\mathcal{D} = \{\mathcal{X}, P(X)\} состоит из пространства признаков X\mathcal{X} (для изображений — тензоры RC×H×W\mathbb{R}^{C \times H \times W}) и маргинального распределения вероятностей P(X)P(X), где X={x1,,xn}XX = \{x_1, \dots, x_n\} \subset \mathcal{X}.

Задача T={Y,f()}\mathcal{T} = \{\mathcal{Y}, f(\cdot)\} в рамках домена D\mathcal{D} задается пространством меток Y\mathcal{Y} и целевой предиктивной функцией f:XYf: \mathcal{X} \to \mathcal{Y}, оцениваемой через условную вероятность P(YX)P(Y \mid X).

При переносе обучения с ImageNet на прикладную классификацию:

  • Исходный домен и задача (Source): Ds\mathcal{D}_s (бытовые фотографии высокого разрешения) и Ts\mathcal{T}_s (классификация на 1000 классов ImageNet: животные, транспорт, предметы быта).
  • Целевой домен и задача (Target): Dt\mathcal{D}_t (например, полутоновые или RGB-кадры деталей с промышленной камеры) и Tt\mathcal{T}_t (классификация на 3 класса: норма, трещина, пора).

Цель инженера — оптимизировать целевую функцию ft(x)f_t(x) для домена Dt\mathcal{D}_t, минимизируя эмпирический риск на выборке малого размера за счет априорного распределения весов Θ\Theta, сформированного в ходе решения задачи Ts\mathcal{T}_s.

Две базовые стратегии: Feature Extraction и Fine-Tuning

В зависимости от объема целевых данных и степени их схожести с исходным доменом применяются две принципиально разные стратегии адаптации.

Исходная модель (Pretrained on ImageNet):
[ Conv Block 1 ] -> [ Conv Block 2 ] -> ... -> [ Conv Block N ] -> [ GAP ] -> [ Linear(1000) ]

1. Стратегия Feature Extraction:
[ Conv Block 1 ] -> [ Conv Block 2 ] -> ... -> [ Conv Block N ] -> [ GAP ] -> [ Linear(K_target) ]
|<-------------- ЗАМОРОЖЕНО (requires_grad = False) ------------>|            |<- ОБУЧАЕТСЯ ->|

2. Стратегия Fine-Tuning (частичная или полная):
[ Conv Block 1 ] -> [ Conv Block 2 ] -> ... -> [ Conv Block N ] -> [ GAP ] -> [ Linear(K_target) ]
|<--- ЗАМОРОЖЕНО (или LR = 1e-5) ---->| |<-- РАЗМОРОЖЕНО (LR = 1e-4) ->|            |<- LR = 1e-3 ->|

1. Извлечение признаков (Feature Extraction / Linear Probe)

Бэкбон предобученной сети рассматривается как фиксированный нелинейный дескриптор данных:

  1. Выходной полносвязный слой модели (рассчитанный на 1000 классов) отсекается.
  2. Все веса сверточного бэкбона «замораживаются» — для них отключается расчет градиентов (requires_grad = False).
  3. Поверх слоя глобального усредняющего пулинга (GAP) монтируется новая классификационная «голова» (обычно nn.Linear(D_in, K_target)), веса которой инициализируются случайно.
  4. В процессе обучения градиентный спуск обновляет исключительно веса нового линейного слоя.

Преимущества: невозможно переобучить бэкбон; вычислительная сложность минимальна (прямой проход через бэкбон можно выполнить один раз и закешировать векторы признаков в память); гарантированная выпуклость задачи оптимизации при использовании линейного классификатора.

2. Тонкая настройка (Fine-Tuning)

При тонкой настройке размораживается часть сверточных слоев (или весь бэкбон целиком), и их веса продолжают корректироваться градиентным спуском под специфику целевого распределения Dt\mathcal{D}_t.

Опасность градиентного удара (Gradient Shock)

Если разморозить всю сеть сразу и запустить обучение со случайной инициализацией новой классификационной головы, возникнет фатальная деградация весов.

Поскольку голова инициализирована случайно, значение функции потерь LL на первых итерациях будет огромным. Градиенты ΘL\nabla_{\Theta} L, распространяясь по градиентной магистрали через autograd к начальным слоям, мгновенно разрушат тонко настроенные предобученные веса бэкбона — произойдет так называемое катастрофическое забывание (catastrophic forgetting).

Двухэтапный протокол дообучения (Two-Stage Fine-Tuning):

Чтобы исключить градиентный шок, обучение разбивают на два последовательных этапа:

  1. Этап прогрева (Warmup / Head training): Бэкбон замораживается (requires_grad = False). Обучается только новая голова в течение 5–10 эпох со стандартным темпом обучения (например, η=103\eta = 10^{-3}), пока ее веса не сойдутся к адекватному распределению, а лосс не стабилизируется.
  2. Этап совместного дообучения (Fine-tuning): Размораживаются верхние сверточные блоки (или вся сеть), и обучение продолжается на значительно сниженном темпе обучения (η=104105\eta = 10^{-4} \dots 10^{-5}).

Дифференциальный темп обучения (Discriminative Learning Rates)

Применять единый темп обучения (learning rate) ко всей размороженной сети неоптимально: начальные слои содержат универсальные фильтры, требующие минимальной микроподстройки, в то время как глубокие слои должны сильнее перестраиваться под специфические паттерны целевого домена.

Техника дифференциального (послойного) темпа обучения задает экспоненциально убывающий шаг градиентного спуска от классификатора к входу сети:

ηl=ηheadγLl\eta_l = \eta_{head} \cdot \gamma^{L - l}

Пояснение элементов формулы:

  • ηl\eta_l — темп обучения для слоя с индексом ll (где l=1l=1 — первый слой сети, l=Ll=L — последний сверточный слой бэкбона).
  • ηhead\eta_{head} — базовый темп обучения новой классификационной головы (например, 10310^{-3}).
  • γ\gamma — коэффициент затухания темпа обучения (обычно γ[0.2,0.8]\gamma \in [0.2, 0.8]).
  • LlL - l — расстояние от текущего слоя до головы классификатора.

Практический пример: если для головы задан ηhead=103\eta_{head} = 10^{-3}, а коэффициент затухания γ=0.5\gamma = 0.5, то последний сверточный блок будет обучаться с шагом 5×1045 \times 10^{-4}, средний — с 2.5×1042.5 \times 10^{-4}, а входной — с 1.25×1041.25 \times 10^{-4}.

Матрица выбора стратегии переноса обучения

Выбор между Feature Extraction, частичным и полным Fine-Tuning определяется двумя независимыми осями: размером целевого датасета и степенью его визуального сходства с базовым доменом (ImageNet).

Высокое сходство с Source (Natural images) Низкое сходство с Source (X-Ray, микроскопия, радары)
Малый датасет (N<103N < 10^3) Feature Extraction (Linear Probe)<br>Заморозка всего бэкбона. Обучение только финального классификатора. Fine-tuning приведет к неминуемому переобучению. Глубокий Feature Extraction / Partial Fine-Tuning<br>Линейный классификатор поверх последних слоев может не сработать. Снимают признаки со средних слоев либо размораживают только 1–2 верхних блока с сильной регуляризацией (Dropout, Weight Decay).
Большой датасет (N>104N > 10^4) Полный Fine-Tuning с низким LR<br>Разморозка всей сети. Инициализация весами ImageNet обеспечивает быструю сходимость и лучшую финальную точность, чем обучение с нуля. Полный Fine-Tuning с нуля / прогревом<br>Обучение всех слоев. Предобученные веса используются лишь как удачная статистическая инициализация (вместо Xavier/Kaiming), ускоряющая выход из седловых точек.

Тонкие инженерные нюансы: BatchNorm и адаптация слоев в PyTorch

Перенос обучения требует строгого контроля за состоянием слоев нормализации и структурой вычислительного графа.

Дилемма BatchNorm при заморозке бэкбона

Слой nn.BatchNorm2d содержит две категории параметров:

  1. Обучаемые аффинные веса (γ,β\gamma, \beta), участвующие в градиентном спуске.
  2. Необучаемые буферы скользящих статистик (running_mean, running_var), обновляющиеся на каждом прямом проходе в режиме model.train().

Если заморозить параметры слоя (requires_grad = False), но оставить модель в режиме model.train(), слой продолжит пересчитывать скользящие статистики под батчи целевого датасета. Если размер батча мал (например, 4–8 изображений из-за нехватки видеопамяти), выборочные средние и дисперсии будут крайне зашумленными. Это разрушит калибровку признаков предобученной сети.

Правило продакшн-переноса: при заморозке бэкбона слои BatchNorm2d необходимо принудительно переводить в режим детерминированного инференса (eval()), фиксируя накопленные статистики ImageNet.

# Фиксация слоев BatchNorm в режиме eval даже во время обучения
def freeze_backbone_with_bn(model):
    for param in model.parameters():
        param.requires_grad = False

    # Принудительно оставляем все BatchNorm в eval
    for module in model.modules():
        if isinstance(module, torch.nn.BatchNorm2d):
            module.eval()

Замена классификационной головы и сборка групп параметров в PyTorch

Разные семейства предобученных архитектур в torchvision.models имеют разные имена атрибутов классификатора:

  • ResNet: model.fc = nn.Linear(in_features, num_classes)
  • EfficientNet / MobileNet: model.classifier[1] = nn.Linear(in_features, num_classes)
  • VGG / DenseNet: model.classifier[6] = nn.Linear(in_features, num_classes)

Для реализации дифференциального темпа обучения параметры модели распределяются по независимым группам в словаре конфигурации оптимизатора:

import torch
import torch.nn as nn
from torchvision.models import resnet50, ResNet50_Weights

# 1. Загрузка предобученной модели с актуальным синтаксисом весов
weights = ResNet50_Weights.DEFAULT
model = resnet50(weights=weights)

# 2. Заморозка всех базовых слоев бэкбона
for param in model.parameters():
    param.requires_grad = False

# 3. Замена финального слоя (in_features у ResNet-50 = 2048)
num_classes = 3  # Например: Normal, Defect_A, Defect_B
model.fc = nn.Linear(model.fc.in_features, num_classes)

# 4. Разморозка последнего остаточного блока (layer4) для тонкой настройки
for param in model.layer4.parameters():
    param.requires_grad = True

# 5. Конфигурация оптимизатора с дифференциальным темпом обучения
optimizer = torch.optim.AdamW([
    {'params': model.layer4.parameters(), 'lr': 1e-4},       # Медленно для бэкбона
    {'params': model.fc.parameters(),     'lr': 1e-3}        # Быстро для новой головы
], weight_decay=1e-2)

В таком пайплайне градиенты будут вычисляться только для model.layer4 и model.fc. Слои layer1, layer2, layer3 и начальная свертка conv1 остаются абсолютно неподвижными, выступая в роли быстрого и неизменного экстрактора базовых дескрипторов.

Благодаря методологии Transfer Learning и дифференциальной настройке весов современное машинное зрение перешло от этапа накопления гигантских обучающих выборок к точечной адаптации мощных фундаментальных представлений, решая прикладные задачи классификации за считанные минуты.

Практикум: адаптация предобученной модели из torchvision под задачу классификации изображений

Практикум: адаптация предобученной модели из torchvision под задачу классификации изображений

Попытка обучить глубокую сверточную сеть вроде ResNet-50 с нуля на специализированном датасете из 1500–2000 промышленных снимков почти неизбежно приводит к фиаско: сеть с 25 миллионами параметров моментально переобучается, застревая на точности около 60–65%. В то же время перенос предобученных представлений с датасета ImageNet позволяет получить точность выше 98% всего за 15–20 эпох обучения на скромном GPU.

Однако на практике перенос обучения часто ломается об инженерные детали: некорректно подобранные параметры нормализации, случайное обнуление скользящих статистик BatchNorm или разрушение предобученных сверточных фильтров градиентным ударом от инициализированной случайным шумом головы. Превратим концепцию переноса обучения в строгий, отказоустойчивый производственный код на базе библиотеки torchvision.

Современный API моделей и весов в torchvision

Исторически загрузка предобученных моделей в PyTorch осуществлялась передачей булевого флага pretrained=True (например, models.resnet50(pretrained=True)). Начиная с версии torchvision 0.13, этот подход признан устаревшим (deprecated). На смену ему пришла система перечислений (Enums) Weights.

Новый API решает фундаментальную проблему воспроизводимости: для одной и той же архитектуры со временем могут появляться разные версии весов (обученные с улучшенными аугментациями, другими оптимизаторами или при разном разрешении). Перечисление весов явно фиксирует конкретный набор параметров и автоматически предоставляет ассоциированные с ним преобразования входных данных:

import torchvision.models as models

# Получение весов по умолчанию (наиболее точных на текущий момент)
weights = models.ResNet50_Weights.DEFAULT

# Инициализация архитектуры с загрузкой предобученных тензоров
model = models.resnet50(weights=weights)

# Извлечение встроенного пайплайна предобработки
preprocess = weights.transforms()
print(preprocess)

Использование weights.transforms() гарантирует, что изображение пройдет именно те этапы масштабирования, кадрирования и канальной стандартизации (mean/std), на которых модель обучалась исходно.

Анатомия моделей: замена классификационной головы

Чтобы адаптировать модель под целевое количество классов KK (например, K=3K = 3 для категорий дефектов: Pass, Crack, Pore), необходимо заменить последний полносвязный слой. Архитектуры в torchvision структурированы по-разному, поэтому универсального атрибута для классификатора не существует.

Сравним две наиболее востребованные в production архитектуры: классическую остаточную сеть ResNet-50 и масштабируемую EfficientNet-B0.

Характеристика ResNet-50 EfficientNet-B0
Слой пулинга перед головой nn.AdaptiveAvgPool2d((1, 1)) nn.AdaptiveAvgPool2d(1)
Имя атрибута классификатора model.fc model.classifier
Исходная структура головы Одиночный nn.Linear(2048, 1000) Последовательность: nn.Dropout(p=0.2), nn.Linear(1280, 1000)
Число входных признаков (DinD_{in}) 2048 1280
Способ адаптации под KK классов model.fc = nn.Linear(2048, K) model.classifier[1] = nn.Linear(1280, K)

Пошаговая замена головы и заморозка бэкбона

Реализуем функцию-конструктор, которая загружает модель, полностью замораживает сверточный бэкбон и подставляет новую случайно инициализированную голову:

import torch
import torch.nn as nn
from torchvision.models import resnet50, ResNet50_Weights, efficientnet_b0, EfficientNet_B0_Weights

def build_transfer_model(architecture: str, num_classes: int, freeze_backbone: bool = True) -> nn.Module:
    if architecture == "resnet50":
        weights = ResNet50_Weights.DEFAULT
        model = resnet50(weights=weights)

        if freeze_backbone:
            for param in model.parameters():
                param.requires_grad = False

        in_features = model.fc.in_features  # 2048
        model.fc = nn.Linear(in_features, num_classes)
        # Параметры model.fc по умолчанию имеют requires_grad=True

    elif architecture == "efficientnet_b0":
        weights = EfficientNet_B0_Weights.DEFAULT
        model = efficientnet_b0(weights=weights)

        if freeze_backbone:
            for param in model.parameters():
                param.requires_grad = False

        in_features = model.classifier[1].in_features  # 1280
        dropout_p = model.classifier[0].p
        model.classifier = nn.Sequential(
            nn.Dropout(p=dropout_p, inplace=True),
            nn.Linear(in_features, num_classes)
        )
    else:
        raise ValueError(f"Неподдерживаемая архитектура: {architecture}")

    return model

Проверим распределение параметров для верификации заморозки:

model = build_transfer_model("resnet50", num_classes=3, freeze_backbone=True)

trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())

print(f"Всего параметров: {total_params:,}")
print(f"Обучаемых параметров: {trainable_params:,}")
# Для ResNet-50 с K=3: 2048 * 3 + 3 = 6 147 обучаемых параметров из 23.5 млн!

Двухэтапный протокол обучения на практике

Для достижения наивысшей обобщающей способности используем двухэтапную стратегию:

  1. Фаза 1: Feature Extraction (Линейный зонд). Обучаем только новую голову на протяжении 5–10 эпох с относительно высоким шагом градиентного спуска (η=103\eta = 10^{-3}). Бэкбон заморожен, что исключает разрушение весов.
  2. Фаза 2: Fine-Tuning (Тонкая настройка). Размораживаем верхние сверточные блоки (например, layer4 в ResNet-50 или блоки 6–7 в EfficientNet) и продолжаем обучение с дифференциальным темпом: малый шаг для сверток (ηbackbone=105\eta_{backbone} = 10^{-5}) и умеренный для классификатора (ηhead=104\eta_{head} = 10^{-4}).

Конфигурация параметрических групп оптимизатора

При переходе к фазе тонкой настройки критически важно передать в оптимизатор параметры с раздельной скоростью обучения:

def configure_fine_tuning_optimizer(model: nn.Module, architecture: str) -> torch.optim.Optimizer:
    if architecture == "resnet50":
        # Размораживаем последний сверточный блок layer4
        for param in model.layer4.parameters():
            param.requires_grad = True

        param_groups = [
            {"params": model.layer4.parameters(), "lr": 1e-5, "weight_decay": 1e-4},
            {"params": model.fc.parameters(), "lr": 1e-4, "weight_decay": 1e-4}
        ]
    elif architecture == "efficientnet_b0":
        # Размораживаем верхние блоки признаков (features[6] и features[7])
        for param in model.features[6].parameters():
            param.requires_grad = True
        for param in model.features[7].parameters():
            param.requires_grad = True

        param_groups = [
            {"params": model.features[6].parameters(), "lr": 1e-5, "weight_decay": 1e-4},
            {"params": model.features[7].parameters(), "lr": 1e-5, "weight_decay": 1e-4},
            {"params": model.classifier.parameters(), "lr": 1e-4, "weight_decay": 1e-4}
        ]

    return torch.optim.AdamW(param_groups)

Сквозной скрипт: обучение, валидация и сохранение чекпоинта

Объединим подготовку модели, двухфазный цикл обучения и сохранение лучшей контрольной точки в единый промышленный модуль.

import copy
from typing import Dict, Tuple
import torch
import torch.nn as nn
from torch.utils.data import DataLoader

class TransferTrainer:
    def __init__(
        self,
        model: nn.Module,
        criterion: nn.Module,
        device: torch.device
    ):
        self.model = model.to(device)
        self.criterion = criterion
        self.device = device

    def train_epoch(self, dataloader: DataLoader, optimizer: torch.optim.Optimizer) -> Tuple[float, float]:
        self.model.train()
        running_loss = 0.0
        correct_predictions = 0
        total_samples = 0

        for inputs, labels in dataloader:
            inputs = inputs.to(self.device, non_blocking=True)
            labels = labels.to(self.device, non_blocking=True)

            optimizer.zero_grad(set_to_none=True)

            with torch.amp.autocast(device_type=self.device.type, enabled=(self.device.type == "cuda")):
                outputs = self.model(inputs)
                loss = self.criterion(outputs, labels)

            loss.backward()
            optimizer.step()

            batch_size = inputs.size(0)
            running_loss += loss.item() * batch_size
            _, preds = torch.max(outputs, dim=1)
            correct_predictions += torch.sum(preds == labels).item()
            total_samples += batch_size

        epoch_loss = running_loss / total_samples
        epoch_acc = correct_predictions / total_samples
        return epoch_loss, epoch_acc

    @torch.inference_mode()
    def evaluate(self, dataloader: DataLoader) -> Tuple[float, float]:
        self.model.eval()
        running_loss = 0.0
        correct_predictions = 0
        total_samples = 0

        for inputs, labels in dataloader:
            inputs = inputs.to(self.device, non_blocking=True)
            labels = labels.to(self.device, non_blocking=True)

            outputs = self.model(inputs)
            loss = self.criterion(outputs, labels)

            batch_size = inputs.size(0)
            running_loss += loss.item() * batch_size
            _, preds = torch.max(outputs, dim=1)
            correct_predictions += torch.sum(preds == labels).item()
            total_samples += batch_size

        epoch_loss = running_loss / total_samples
        epoch_acc = correct_predictions / total_samples
        return epoch_loss, epoch_acc

    def fit(
        self,
        train_loader: DataLoader,
        val_loader: DataLoader,
        optimizer: torch.optim.Optimizer,
        epochs: int
    ) -> Dict[str, float]:
        best_val_loss = float("inf")
        best_weights = copy.deepcopy(self.model.state_dict())
        best_metrics = {}

        for epoch in range(epochs):
            train_loss, train_acc = self.train_epoch(train_loader, optimizer)
            val_loss, val_acc = self.evaluate(val_loader)

            print(
                f"Эпоха [{epoch+1:02d}/{epochs:02d}] | "
                f"Train Loss: {train_loss:.4f}, Acc: {train_acc:.4f} | "
                f"Val Loss: {val_loss:.4f}, Acc: {val_acc:.4f}"
            )

            if val_loss < best_val_loss:
                best_val_loss = val_loss
                best_weights = copy.deepcopy(self.model.state_dict())
                best_metrics = {
                    "val_loss": val_loss,
                    "val_acc": val_acc,
                    "best_epoch": epoch + 1
                }

        self.model.load_state_dict(best_weights)
        return best_metrics

Двухфазный запуск и сохранение результатов

# Определение устройства
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 1. Построение модели с замороженным бэкбоном
model = build_transfer_model("resnet50", num_classes=3, freeze_backbone=True)
criterion = nn.CrossEntropyLoss()
trainer = TransferTrainer(model, criterion, device)

# Предполагается, что train_loader и val_loader инициализированы
# 2. Фаза 1: Обучение только головы (Feature Extraction)
print("--- Фаза 1: Feature Extraction ---")
optimizer_phase1 = torch.optim.AdamW(model.fc.parameters(), lr=1e-3, weight_decay=1e-4)
trainer.fit(train_loader, val_loader, optimizer_phase1, epochs=5)

# 3. Фаза 2: Тонкая настройка верхних слоев (Fine-Tuning)
print("--- Фаза 2: Fine-Tuning ---")
optimizer_phase2 = configure_fine_tuning_optimizer(model, "resnet50")
best_metrics = trainer.fit(train_loader, val_loader, optimizer_phase2, epochs=15)

# 4. Сохранение итоговой модели
checkpoint = {
    "architecture": "resnet50",
    "num_classes": 3,
    "state_dict": model.state_dict(),
    "metrics": best_metrics
}
torch.save(checkpoint, "best_defect_classifier_resnet50.pth")
print(f"Чекпоинт сохранен. Лучшая точность: {best_metrics['val_acc']:.4f}")

Инференс на одиночном изображении

Для интеграции обученной модели в систему видеоаналитики или инспекционную станцию напишем класс Predictor, использующий OpenCV для чтения кадров и предобработку torchvision:

import cv2
import numpy as np
import torch
import torch.nn.functional as F
from torchvision import transforms

class DefectPredictor:
    def __init__(self, checkpoint_path: str, device: torch.device):
        self.device = device

        # Загрузка метаданных чекпоинта
        checkpoint = torch.load(checkpoint_path, map_location=device)
        self.model = build_transfer_model(
            architecture=checkpoint["architecture"],
            num_classes=checkpoint["num_classes"],
            freeze_backbone=False
        )
        self.model.load_state_dict(checkpoint["state_dict"])
        self.model.to(device)
        self.model.eval()

        # Канонический пайплайн трансформации ImageNet
        self.transform = transforms.Compose([
            transforms.ToPILImage(),
            transforms.Resize(256, interpolation=transforms.InterpolationMode.BILINEAR),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])
        self.class_names = ["Pass", "Crack", "Pore"]

    @torch.inference_mode()
    def predict(self, bgr_image: np.ndarray) -> Tuple[str, float, np.ndarray]:
        # Конвертация BGR (OpenCV) в RGB (PyTorch)
        rgb_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)

        # Подготовка 4D тензора (1, 3, 224, 224)
        tensor_input = self.transform(rgb_image).unsqueeze(0).to(self.device)

        # Прямой проход и вычисление вероятностей Softmax
        logits = self.model(tensor_input)
        probabilities = F.softmax(logits, dim=1).squeeze(0).cpu().numpy()

        top_idx = int(np.argmax(probabilities))
        predicted_class = self.class_names[top_idx]
        confidence = float(probabilities[top_idx])

        return predicted_class, confidence, probabilities

Применение классификатора к кадру с конвейера выполняется за три строки:

predictor = DefectPredictor("best_defect_classifier_resnet50.pth", device=device)
frame = cv2.imread("sample_pcb_defect.jpg")

if frame is not None:
    label, score, all_probs = predictor.predict(frame)
    print(f"Результат: {label} с уверенностью {score * 100:.2f}%")

Освоив перенос обучения и тонкую настройку бэкбонов, мы получили универсальный инструмент для классификации любых дефектов и объектов. Следующий критический шаг в построении промышленных CV-систем — обеспечить модель достаточным разнообразием данных с помощью специализированных пайплайнов аугментации.