Введение в глубокое обучение и PyTorch для компьютерного зрения

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

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

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

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

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


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

В предыдущих модулях мы работали с изображениями как с двумерными и трехмерными массивами чисел: матрицами полутоновых градаций или тензорами цветовых каналов H×W×CH \times W \times C. Для компьютера изображение — это исключительно сетка числовых значений яркости от 0 до 255.

Человек, глядя на изображение, мгновенно распознает высокоуровневые концепции: объекты, границы, материалы, контекст сцены. Компьютер же видит только низкоуровневые значения в ячейках памяти. Это фундаментальное несоответствие между сырыми значениями пикселей и человеческой интерпретацией сцены называется семантическим разрывом (semantic gap) [1].

Семантический разрыв (Semantic Gap) — фундаментальное расхождение между низкоуровневыми числовыми данными растрового изображения (значениями яркости пикселей в буфере памяти) и высокоуровневым смысловым содержанием сцены, воспринимаемым человеком [1].

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

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

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


Классический подход: ручное проектирование признаков

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

Классический пайплайн состоит из жесткой цепочки операций:

  1. Предобработка: нормализация контраста, фильтрация шума, цветовая сегментация.
  2. Ручное выделение признаков (Handcrafted Features): инженер сам придумывает математические дескрипторы — гистограммы направленных градиентов (HOG), дескрипторы угловых точек (SIFT, ORB), геометрические форм-факторы контуров (Solidity, Aspect Ratio, моменты инерции).
  3. Классификатор: полученный вектор признаков фиксированной длины подается в классификатор (метод опорных векторов SVM, случайный лес Random Forest или дерево решений).

Главная проблема этой схемы — разрыв оптимизации. Инженер настраивает экстрактор признаков отдельно, опираясь на собственную интуицию, а классификатор обучается отдельно на том векторе, который ему предоставили. Если выбранный набор признаков отбросил важную для распознавания информацию (например, микротекстуру), классификатор не сможет восстановить ее ни при каких условиях.


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

Глубокое обучение отказывается от ручного конструирования признаков в пользу сквозного обучения (End-to-End Learning). Вся система — от сырых пикселей на входе до финального предсказания класса — представляет собой единую дифференцируемую параметрическую функцию.

Фундаментальная парадигма, лежащая в основе большинства систем компьютерного зрения, — обучение с учителем (Supervised Learning).

Обучение с учителем (Supervised Learning) — методология машинного обучения, при которой алгоритм настраивает параметры математической модели на основе обучающей выборки, состоящей из пар входных объектов и соответствующих им истинных меток (разметки).

Формальная постановка задачи

Пусть у нас есть обучающий набор данных (датасет) D\mathcal{D}, состоящий из NN пар:

D={(x(1),y(1)),(x(2),y(2)),,(x(N),y(N))}\mathcal{D} = \{(x^{(1)}, y^{(1)}), (x^{(2)}, y^{(2)}), \dots, (x^{(N)}, y^{(N)})\}

Разберем структуру этих элементов:

  • x(i)x^{(i)} — входной объект. В задачах зрения это матрица или тензор изображения (например, полутоновое изображение размера 28×2828 \times 28 или RGB-кадр 3×224×2243 \times 224 \times 224).
  • y(i)y^{(i)} — целевая истинная метка (Ground Truth target). Для задачи бинарной классификации (брак / норма) это скаляр y{0,1}y \in \{0, 1\}; для задачи многоклассовой классификации (например, определение 10 типов деталей) — целое число от 0 до 9 или унитарный вектор (One-Hot vector).
  • NN — общее количество размеченных примеров в обучающей выборке.

Нейронная сеть выступает в роли параметрического оператора ff:

y^=f(x;θ)\hat{y} = f(x; \theta)

Где:

  • xx — тензор входного изображения.
  • θ\theta — вектор (или множество матриц) настраиваемых параметров модели (весов и смещений).
  • y^\hat{y} — предиктивный выход модели (предсказанная вероятность принадлежности к классу или вектор логитов).

Пример: если на вход подается полутоновое изображение дефекта xx размером 28×2828 \times 28 пикселей, а истинная метка y=1y = 1 (трещина), модель вычисляет y^=f(x;θ)=0.87\hat{y} = f(x; \theta) = 0.87, что интерпретируется как 87% уверенность в наличии дефекта.


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

В отличие от эвристических алгоритмов OpenCV, параметры нейросети θ\theta не задаются программистом вручную. Они инициализируются случайными значениями и корректируются в процессе итеративной оптимизации.

Весь процесс опирается на три ключевых механизма:

  1. Прямой проход (Forward Pass): Входное изображение xx проходит через слои нейросети, преобразуясь из низкоуровневых матриц пикселей во все более абстрактные внутренние представления, вплоть до получения предсказания y^\hat{y}.
  2. Функция потерь (Loss Function): Скалярная функция L(y^,y)\mathcal{L}(\hat{y}, y), которая количественно оценивает штраф за расхождение между предсказанием модели y^\hat{y} и истинной меткой yy. Чем хуже предсказание, тем больше значение ошибки.
  3. Обратное распространение ошибки и градиентный спуск (Backward Pass & Optimization): Вычисляются частные производные функции потерь по всем параметрам модели Lθ\frac{\partial \mathcal{L}}{\partial \theta}. На основе этих градиентов веса θ\theta обновляются в сторону уменьшения ошибки:

θθηLθ\theta \leftarrow \theta - \eta \cdot \frac{\partial \mathcal{L}}{\partial \theta}

Где:

  • θ\theta — вектор оптимизируемых параметров (весов).
  • η\eta — скорость обучения (learning rate), определяющая величину шага оптимизатора вдоль направления антиградиента.
  • Lθ\frac{\partial \mathcal{L}}{\partial \theta} — градиент функции потерь по параметрам модели, указывающий направление наискорейшего роста ошибки.

Пример: если модель предсказала y^=0.2\hat{y} = 0.2 для объекта класса y=1y = 1, функция потерь зафиксирует высокую ошибку. Градиентный шаг скорректирует веса θ\theta так, чтобы при повторной подаче этого же изображения предсказание сместилось ближе к 1.0.

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


Сравнение парадигм: классическое зрение против глубокого обучения

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

Критерий Классическое машинное зрение Глубокое обучение (Deep Learning)
Формирование признаков Ручное конструирование инженером (HOG, контуры, моменты) Автоматическое иерархическое извлечение из сырых данных
Устойчивость к вариациям Низкая (требует жестко контролируемого освещения и позы) Высокая (обобщает сложные внутриклассовые вариации)
Требования к объему данных Работает без обучающих выборок (детерминированные правила) Требует сотен и тысяч размеченных обучающих примеров (X,y)(X, y)
Вычислительные требования Низкие (выполняется на стандартных CPU) Высокие при обучении и инференсе (требует GPU/NPU)
Интерпретируемость Полная (каждый шаг прозрачен: порог, угол, площадь) Ограниченная («черный ящик» с миллионами весов)
Область применения Прецизионная калибровка, метрология, проверка допусков Семантическая классификация, сложная детекция, сегментация

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


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

В компьютерном зрении переход от концептуальной модели f(x;θ)f(x; \theta) к программной реализации требует перехода от массивов NumPy к специализированным тензорным структурам данных.

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

В следующей главе мы детально изучим устройство тензоров PyTorch, их размещение в памяти, различия в форматах каналов (NCHW vs NHWC) и перевод вычислений на ускорители CUDA.

Тензоры PyTorch: операции над многомерными массивами, форматы каналов и аппаратное ускорение

Тензоры PyTorch: операции над многомерными массивами, форматы каналов и аппаратное ускорение

Сквозная дифференцируемая модель обрабатывает растровые изображения как массивы чисел, непрерывно выполняя миллиарды операций умножения матриц на каждом шаге градиентного спуска. Массивы ndarray библиотеки NumPy идеально подходят для манипуляций на центральном процессоре (CPU), однако они не поддерживают прямое исполнение на графических ускорителях (GPU) и не сохраняют историю операций для автоматического дифференцирования. Переход от классической обработки к глубокому обучению требует фундаментальной вычислительной структуры — тензора.

Тензор (torch.Tensor) — это многомерный числовой массив с единым типом данных, поддерживающий автоматическое вычисление градиентов и оптимизированный для параллельных матричных вычислений на специализированных аппаратных ускорителях (GPU, TPU, NPU).

От NumPy ndarray к torch.Tensor: единая память и преобразование типов

С точки зрения низкоуровневой организации оперативной памяти тензор PyTorch устроен идентично массиву NumPy: это непрерывный одномерный буфер данных в памяти, поверх которого заданы метаданные — форма (shape), шаги смещения (strides) и тип данных (dtype).

Благодаря этому соответствию PyTorch реализует механизм создания тензоров из массивов NumPy с нулевыми затратами на копирование памяти (zero-copy memory sharing):

import numpy as np
import torch

# Создаем полутоновое изображение 480x640 в формате NumPy
numpy_image = np.zeros((480, 640), dtype=np.float32)

# Создание тензора без копирования буфера данных
tensor_from_np = torch.from_numpy(numpy_image)

# Обратная конвертация тензора в массив NumPy (также делит общий буфер)
np_from_tensor = tensor_from_np.numpy()

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

Однако глубокое обучение предъявляет жесткие требования к типам данных. Классические графические файлы декодируются в формат uint8 с диапазоном от 00 до 255255. В нейросетевых моделях градиентная оптимизация требует непрерывного пространства вещественных чисел. Стандартом для весов и промежуточных активаций в компьютерном зрении является 32-битное число с плавающей точкой (torch.float32), а значения яркости пикселей нормализуются в вещественный отрезок [0.0,1.0][0.0, 1.0].

Математическое масштабирование яркости пикселя xx в нормализованное значение xnormx_{\text{norm}} выражается формулой:

xnorm=x255.0x_{\text{norm}} = \frac{x}{255.0}

где x{0,1,,255}x \in \{0, 1, \dots, 255\} — исходное дискретное значение яркости в формате uint8, а 255.0255.0 — нормировочный делитель. На практике пиксель с максимальной яркостью 255255 превращается в вещественную единицу 1.01.0, а нейтральный серый уровень 128128 переходит в значение 0.50196\approx 0.50196.

# Загруженная матрица OpenCV (uint8)
raw_uint8_frame = np.random.randint(0, 256, size=(224, 224, 3), dtype=np.uint8)

# Корректное преобразование в тензор для глубокого обучения:
# 1. torch.from_numpy создает тензор типа torch.uint8
# 2. .float() приводит тип к torch.float32
# 3. Деление на 255.0 масштабирует значения в диапазон [0.0, 1.0]
tensor_frame = torch.from_numpy(raw_uint8_frame).float() / 255.0

Анатомия размерностей: HWC против CHW и батчинг (NCHW)

В библиотеках OpenCV и NumPy трехканальные цветные изображения хранятся в порядке H×W×CH \times W \times C (высота, ширина, каналы). В памяти такого массива пиксели следуют чередующимися триплетами: [R0, G0, B0, R1, G1, B1, ...].

В глубоком обучении стандартным является другой порядок осей — C×H×WC \times H \times W (каналы, высота, ширина). В формате CHWCHW цветовые каналы представляют собой изолированные двумерные плоскости: сначала в памяти последовательно расположен весь красный канал изображения, затем весь зеленый, и затем весь синий.

Для одновременной параллельной обработки нескольких изображений на видеокарте тензоры объединяются вдоль нулевой оси в пакеты (батчи), образуя четырехмерную структуру N×C×H×WN \times C \times H \times W:

  • NN (Batch Size) — количество изображений в одном пакете;
  • CC (Channels) — количество каналов (1 для grayscale, 3 для RGB);
  • HH (Height) — высота кадров в пикселях;
  • WW (Width) — ширина кадров в пикселях.

Формат NCHWNCHW принят в качестве индустриального стандарта в PyTorch, поскольку библиотеки параллельных вычислений NVIDIA CUDA (в частности cuDNN) оптимизированы под коалесцентный (coalesced) доступ к памяти: графические ядра считывают непрерывные пространственные срезы H×WH \times W одного канала в кэш с максимальной пропускной способностью шины.

Трансформация осей: почему нельзя путать view и permute

Преобразование входного кадра OpenCV из формата (H,W,C)(H, W, C) в формат PyTorch (C,H,W)(C, H, W) — одна из самых частых точек возникновения критических ошибок в коде инженеров машинного зрения.

В PyTorch существуют две принципиально разные операции изменения формы:

  1. view() и reshape() — изменяют способ интерпретации формы тензора без перестановки элементов в физической памяти. Чтение буфера идет строго подряд по исходным адресам.
  2. permute() — изменяет логический порядок координатных осей и пересчитывает шаги смещения (strides), сохраняя корректную пространственную структуру данных.

Если к тензору формы (H,W,C)(H, W, C) применить операцию .view(C, H, W), данные физической памяти не будут сгруппированы по каналам. Вместо этого в первый канал попадут перемешанные значения RR, GG и BB из первой трети исходного буфера, что полностью разрушит пространственную структуру и семантику изображения.

Правильный перевод изображения OpenCV в батч PyTorch требует явной перестановки осей через permute и добавления размерности пакета через unsqueeze:

# Исходный кадр OpenCV: (H=480, W=640, C=3)
img_hwc = torch.from_numpy(raw_uint8_frame).float() / 255.0

# 1. Перестановка осей: (H, W, C) -> (C, H, W)
# 0-я ось (H) становится 1-й, 1-я (W) становится 2-й, 2-я (C) становится 0-й
img_chw = img_hwc.permute(2, 0, 1)  # Форма: (3, 480, 640)

# 2. Добавление размерности батча N вдоль 0-й позиции:
batch_nchw = img_chw.unsqueeze(0)   # Форма: (1, 3, 480, 640)

Операция permute делает тензор фрагментированным по смещениям в памяти — такой тензор называется непрерывным не по порядку (non-contiguous). Если после permute требуется применить операцию view(), необходимо сначала восстановить последовательный порядок байтов методом .contiguous():

# Восстановление линейного порядка в памяти перед выравниванием в вектор
flat_features = batch_nchw.permute(0, 2, 3, 1).contiguous().view(1, -1)

Аппаратные устройства: CPU, CUDA и параллельное исполнение

Тензоры PyTorch содержат атрибут device, определяющий, на каком вычислительном узле физически выделена память под данный массив:

  • torch.device('cpu') — системная оперативная память (RAM), вычисления на центральном процессоре;
  • torch.device('cuda:0') — выделенная видеопамять (VRAM) графического процессора с индексом 0.

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

# Определение доступности аппаратного ускорителя
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Создание батча на CPU
x_cpu = torch.randn(32, 3, 224, 224, dtype=torch.float32)

# Явный перенос тензора в память графического ускорителя
x_gpu = x_cpu.to(device)

Вызовы ядер CUDA на GPU выполняются асинхронно: центральный процессор отправляет команду на вычисление в командную очередь видеокарты и немедленно возвращает управление в Python, не дожидаясь завершения физического расчета на кристалле GPU.

Если требуется измерить точное время выполнения нейросетевых операций на GPU или вернуть результат обработки обратно в память CPU для визуализации через OpenCV, необходимо учитывать асинхронность и использовать явную синхронизацию:

# Пример безопасного возврата тензора предсказания в OpenCV
prediction_gpu = model_forward(x_gpu)  # Результат лежит в VRAM

# Корректный пайплайн возврата в NumPy/OpenCV:
# 1. .detach() — отвязать тензор от вычислительного графа autograd
# 2. .cpu() — скопировать данные из VRAM в системную память RAM
# 3. .numpy() — обернуть буфер RAM в массив NumPy
result_frame = prediction_gpu.squeeze(0).permute(1, 2, 0).detach().cpu().numpy()

Понимание низкоуровневой структуры тензоров, строгого разделения памяти между CPU/GPU и специфики координатного формата NCHWNCHW формирует фундамент для построения математических слоев нейронных сетей.

Математика полносвязных слоев: веса, смещения, выпрямление изображений и нелинейные функции активации

Математика полносвязных слоев: веса, смещения, выпрямление изображений и нелинейные функции активации

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

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

Подготовка визуальных данных: операция Flatten

Тензор изображения после загрузки и нормализации находится в четырехмерном формате N×C×H×WN \times C \times H \times W (размер пакета, каналы, высота, ширина). Однако классический полносвязный слой ожидает на входе двумерную матрицу, где каждая строка представляет собой плоский одномерный вектор признаков одного конкретного объекта выборки.

Процесс перевода многомерного тензора в плоский вектор называется выпрямлением (Flattening).

При выпрямлении нулевая ось (размер батча NN) должна оставаться неизменной, чтобы не перемешать данные разных кадров между собой. Пространственные оси CC, HH и WW схлопываются в единый вектор размерности Din=C×H×WD_{in} = C \times H \times W.

В PyTorch для этой операции используется модуль torch.nn.Flatten или функциональный вызов torch.flatten:

import torch
import torch.nn as nn

# Пакет из 16 полутоновых фрагментов дефектов размером 28x28
batch_images = torch.randn(16, 1, 28, 28)

# Способ 1: через модуль nn.Flatten (удобен внутри nn.Sequential)
flatten_layer = nn.Flatten(start_dim=1)
flat_tensor = flatten_layer(batch_images)
print(flat_tensor.shape)  # Выведет: torch.Size([16, 784])

# Способ 2: явный вызов через метод view
flat_tensor_view = batch_images.view(batch_images.size(0), -1)
print(flat_tensor_view.shape)  # Выведет: torch.Size([16, 784])

Каждый пиксель растра 28×2828 \times 28 становится независимой координатой в 784784-мерном пространстве признаков. Важно понимать компромисс: операция Flatten полностью разрушает локальную двумерную топологию (соседство пикселей по вертикали и горизонтали), превращая изображение в плоский массив чисел. Именно этот вектор и принимает первый вычислительный слой нейросети.

Математика полносвязного слоя: веса и смещения

Полносвязный слой (в литературе также называемый Dense layer, Linear layer или Multi-Layer Perceptron / MLP layer) связывает каждый входной признак с каждым выходным нейроном.

Математически преобразование для одного входного вектора-строки xx размерности 1×Din1 \times D_{in} выражается формулой:

y=xWT+by = x W^T + b

Где:

  • xx — входной вектор признаков размерности 1×Din1 \times D_{in}.
  • WW — матрица весовых коэффициентов размерности Dout×DinD_{out} \times D_{in}. Транспонирование WTW^T (размерность Din×DoutD_{in} \times D_{out}) необходимо для согласования матричного умножения вектор-строки xx на веса.
  • bb — вектор смещения (bias) размерности 1×Dout1 \times D_{out}.
  • yy — результирующий вектор активаций слоя размерности 1×Dout1 \times D_{out}.

Если на вход подается мини-батч из NN объектов (матрица XX размера N×DinN \times D_{in}), операция масштабируется без изменения логики:

Y=XWT+1NbY = X W^T + \mathbf{1}_N b

Здесь вектор смещения bb автоматически транслируется (broadcasting) на все NN строк матрицы.

# Создание линейного слоя: 784 входа -> 128 выходов
fc = nn.Linear(in_features=784, out_features=128, bias=True)

print("Форма матрицы весов (W):", fc.weight.shape)  # torch.Size([128, 784])
print("Форма вектора смещений (b):", fc.bias.shape)   # torch.Size([128])

# Прямой проход пакета данных
output = fc(flat_tensor)
print("Форма выхода слоя (Y):", output.shape)        # torch.Size([16, 128])

Подсчет обучаемых параметров

Количество настраиваемых параметров линейного слоя определяется суммой элементов весовой матрицы и вектора смещений:

Params=(Din×Dout)+Dout=Dout×(Din+1)\text{Params} = (D_{in} \times D_{out}) + D_{out} = D_{out} \times (D_{in} + 1)

Например, для слоя nn.Linear(784, 128):

  • Количество весов: 784×128=100352784 \times 128 = 100\,352.
  • Количество смещений: 128128.
  • Итого параметров: 100352+128=100480100\,352 + 128 = 100\,480.

Каждый вес wijw_{ij} определяет степень влияния jj-го пикселя входного изображения на активацию ii-го скрытого нейрона, а смещение bib_i сдвигает порог активации независимо от входных значений.

Парадокс многослойности: почему линейные слои схлопываются

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

z1=xW1T+b1z_1 = x W_1^T + b_1

z2=z1W2T+b2z_2 = z_1 W_2^T + b_2

Подставим выражение для первого слоя во второй:

z2=(xW1T+b1)W2T+b2=x(W1TW2T)+(b1W2T+b2)z_2 = (x W_1^T + b_1) W_2^T + b_2 = x (W_1^T W_2^T) + (b_1 W_2^T + b_2)

Используя свойство транспонирования произведения матриц W1TW2T=(W2W1)TW_1^T W_2^T = (W_2 W_1)^T, перепишем уравнение:

Wcomb=W2W1,bcomb=b1W2T+b2W_{comb} = W_2 W_1, \quad b_{comb} = b_1 W_2^T + b_2

z2=xWcombT+bcombz_2 = x W_{comb}^T + b_{comb}

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

Сколько бы линейных слоев мы ни объединили в цепочку (хоть 2, хоть 100), полученная архитектура не сможет построить нелинейную разделяющую поверхность. Она останется обычной гиперплоскостью, неспособной решить даже базовую задачу логического исключающего ИЛИ (XOR), не говоря уже о распознавании объектов на реальных снимках.

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

Нелинейные функции активации

Функция активации σ(z)\sigma(z) применяется поэлементно к каждому скаляру вектора z=xWT+bz = x W^T + b. Она выполняет роль селективного фильтра, искривляющего пространство признаков.

1. Сигмоида (Sigmoid)

Исторически первая функция активации, вдохновленная поведением биологических нейронов:

σ(z)=11+ez\sigma(z) = \frac{1}{1 + e^{-z}}

  • Область значений: (0,1)(0, 1). Удобна для вероятностной интерпретации на выходе бинарных классификаторов.
  • Производная: σ(z)=σ(z)(1σ(z))\sigma'(z) = \sigma(z)(1 - \sigma(z)). Максимальное значение производной достигается при z=0z = 0 и равно всего 0.250.25.
  • Фундаментальный недостаток: проблема затухания градиента (Vanishing Gradient). При больших положительных или отрицательных значениях аргумента (z>4|z| > 4) кривая становится почти горизонтальной, а производная стремится к нулю. При обратном распространении ошибки через глубокую сеть перемножение малых производных приводит к экспоненциальному затуханию сигнала ошибки: веса начальных слоев перестают обновляться.

2. ReLU (Rectified Linear Unit)

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

ReLU(z)=max(0,z)\text{ReLU}(z) = \max(0, z)

  • Область значений: [0,+)[0, +\infty).
  • Производная:

    ReLU(z)={1,z>00,z<0\text{ReLU}'(z) = \begin{cases} 1, & z > 0 \\ 0, & z < 0 \end{cases}

    (в точке z=0z=0 производная формально не определена, программно принимается равной 0).
  • Преимущества:
    1. Вычислительная простота: операция сводится к базовому аппаратному сравнению с нулем (без вычисления тяжелых экспонент).
    2. Отсутствие насыщения в положительной области: градиент равен 1.01.0 при любом сколь угодно большом zz, что полностью решает проблему затухания градиента при прямом потоке сигнала.
    3. Разреженность активаций (Sparsity): отрицательные сигналы строго обнуляются, снижая взаимную зависимость признаков.

3. LeakyReLU и проблема Dying ReLU

Если входной сигнал нейрона с функцией ReLU устойчиво попадает в отрицательную область (z<0z < 0), градиент через него становится равным нулю. Если в процессе оптимизации веса сместились так, что нейрон выдает отрицательные значения на всем обучающем датасете, он больше никогда не обновит свои веса — наступает «смерть нейрона» (Dying ReLU).

Для решения этой проблемы предложена модификация LeakyReLU, пропускающая слабый линейный сигнал при z<0z < 0:

LeakyReLU(z)=max(αz,z),где α0.01\text{LeakyReLU}(z) = \max(\alpha z, z), \quad \text{где } \alpha \approx 0.01

  • Производная в отрицательной области равна α>0\alpha > 0, что гарантирует протекание хотя бы минимального градиента и возможность «оживления» весов.

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

Функция Формула σ(z)\sigma(z) Диапазон значений Диапазон производной σ(z)\sigma'(z) Основное назначение
Sigmoid 11+ez\frac{1}{1 + e^{-z}} (0,1)(0, 1) (0,0.25](0, 0.25] Выходной слой бинарной классификации
Tanh ezezez+ez\frac{e^z - e^{-z}}{e^z + e^{-z}} (1,1)(-1, 1) (0,1.0](0, 1.0] Рекуррентные блоки, нормализованные скрытые слои
ReLU max(0,z)\max(0, z) [0,+)[0, +\infty) {0,1}\{0, 1\} Скрытые слои классификаторов и CV-моделей
LeakyReLU max(αz,z)\max(\alpha z, z) (,+)(-\infty, +\infty) {α,1}\{\alpha, 1\} Скрытые слои при риске вырождения (Dying ReLU)

Проектирование полносвязной модели в PyTorch

Объединим рассмотренные компоненты в законченную архитектуру многослойного перцептрона (MLP) для задачи классификации трех типов производственных дефектов (царапина, скол, трещина) по кадрам 28×2828 \times 28.

В PyTorch пользовательские модели принято наследовать от базового класса nn.Module.

import torch
import torch.nn as nn

class DefectClassifierMLP(nn.Module):
    def __init__(self, input_dim: int = 28 * 28, num_classes: int = 3):
        super().__init__()

        # Сборка последовательного вычислительного графа
        self.network = nn.Sequential(
            # 1. Выпрямление входного тензора (B, 1, 28, 28) -> (B, 784)
            nn.Flatten(),

            # 2. Первый скрытый слой с нелинейностью
            nn.Linear(in_features=input_dim, out_features=256),
            nn.ReLU(),

            # 3. Второй скрытый слой с нелинейностью
            nn.Linear(in_features=256, out_features=64),
            nn.ReLU(),

            # 4. Выходной логит-слой (без активации, выдает сырые оценки)
            nn.Linear(in_features=64, out_features=num_classes)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Прямой проход данных через слои
        return self.network(x)

# Инициализация модели
model = DefectClassifierMLP()
print(model)

Проследим трансформацию размерностей тензора на каждом этапе прямого прохода (forward pass):

# Создаем тестовый мини-батч: 8 изображений 28x28 (1 канал)
input_batch = torch.randn(8, 1, 28, 28)

# Прямой проход
logits = model(input_batch)

print("Входной размер:", input_batch.shape)  # torch.Size([8, 1, 28, 28])
print("Выходной размер:", logits.shape)       # torch.Size([8, 3])
  1. Вход: тензор [8, 1, 28, 28].
  2. Flatten: преобразование в матрицу [8, 784].
  3. Linear(784, 256) + ReLU: умножение на веса W1R256×784W_1 \in \mathbb{R}^{256 \times 784}, добавление смещения b1R256b_1 \in \mathbb{R}^{256}, обнуление отрицательных значений \to тензор [8, 256].
  4. Linear(256, 64) + ReLU: умножение на W2R64×256W_2 \in \mathbb{R}^{64 \times 256}, сдвиг b2R64b_2 \in \mathbb{R}^{64}, применение нелинейности \to тензор [8, 64].
  5. Linear(64, 3): финальное линейное проецирование в пространство классов \to тензор логитов [8, 3].

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

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

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

В архитектуре полносвязной нейросети даже со скромным скрытым слоем на 128 нейронов число обучаемых параметров легко превышает 100000100\,000. Чтобы обновить каждый весовой коэффициент в сторону уменьшения ошибки, классический математический анализ требует вычислить частную производную функции потерь по каждому параметру: Lwi\frac{\partial L}{\partial w_i}. Если бы инженер выписывал эти производные вручную по формуле производной сложной функции, малейшее изменение архитектуры сети требовало бы полного аналитического перерасчёта всей цепочки формул.

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


Динамический вычислительный граф

Математические операции над тензорами в PyTorch можно представить в виде ориентированного ациклического графа (Directed Acyclic Graph, DAG). В этом графе:

  • Листовые узлы (leaf nodes) — входные тензоры данных и обучаемые параметры (матрицы весов WW и векторы смещений bb).
  • Внутренние и выходные узлы — промежуточные результаты и итоговая функция потерь.
  • Рёбра / операторы — математические преобразования (матричное умножение, сложение, функции активации).

PyTorch реализует парадигму Define-by-Run: вычислительный граф создаётся динамически прямо во время выполнения кода на Python. Каждый раз, когда над тензором выполняется операция, PyTorch не просто сохраняет результирующие числа, но и создаёт служебный объект градиентной функции — grad_fn.

Этот граф хранит полную топологию вычислений. Свойство тензора is_leaf указывает, создан ли тензор пользователем вручную (листовой узел) или получен в результате операции:

import torch

# Листовой тензор параметров
w = torch.tensor([2.0, -3.0], requires_grad=True)
b = torch.tensor(1.5, requires_grad=True)

# Входные данные (листовой тензор без градиентов)
x = torch.tensor([1.0, 4.0])

# Промежуточные узлы графа
z = torch.dot(w, x) + b  # grad_fn=<AddBackward0>
loss = torch.relu(z)     # grad_fn=<ReluBackward0>

print(f"w is leaf: {w.is_leaf}")       # True
print(f"loss is leaf: {loss.is_leaf}") # False
print(f"loss grad_fn: {loss.grad_fn}") # <ReluBackward0 object>

Механизм Autograd и флаг requires_grad

Движок автодифференцирования torch.autograd отслеживает операции только над теми тензорами, у которых атрибут requires_grad установлен в True. Для входных данных (тензоров изображений) градиенты обычно не нужны, поэтому по умолчанию requires_grad=False. Для весов и смещений в слоях nn.Linear этот флаг включён автоматически.

Векторно-якобиево произведение

Пусть функция потерь LL зависит от выхода слоя y=f(x)y = f(x), где yRmy \in \mathbb{R}^m, а xRnx \in \mathbb{R}^n. Матрица всех частных производных выхода по входу называется матрицей Якоби JRm×nJ \in \mathbb{R}^{m \times n}:

J=[y1x1y1xnymx1ymxn]J = \begin{bmatrix} \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} \end{bmatrix}

  • mm — размерность выходного вектора yy,
  • nn — размерность входного вектора xx,
  • элемент Jij=yixjJ_{ij} = \frac{\partial y_i}{\partial x_j} показывает чувствительность ii-й координаты выхода к изменению jj-й координаты входа.

Пример: если на вход слоя подаётся вектор признаков из 784 элементов (n=784n=784), а на выходе формируется 128 нейронов (m=128m=128), матрица Якоби содержит 128×784=100352128 \times 784 = 100\,352 частные производные.

Вычисление и хранение полной матрицы Якоби для каждого слоя потребовало бы гигантского объёма памяти. Однако целевая функция потерь LL всегда скалярна (LRL \in \mathbb{R}). Согласно цепному правилу (Chain Rule), для проброса ошибки назад через слой требуется не сама матрица JJ, а её произведение на вектор градиента вышестоящего уровня v=Lyv = \frac{\partial L}{\partial y}:

Lx=vTJ=[Ly1,,Lym][y1x1y1xnymx1ymxn]\frac{\partial L}{\partial x} = v^T J = \left[ \frac{\partial L}{\partial y_1}, \ldots, \frac{\partial L}{\partial y_m} \right] \begin{bmatrix} \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} \end{bmatrix}

  • v=Lyv = \frac{\partial L}{\partial y} — вектор-строка размерности 1×m1 \times m, содержащий производные скалярного лосса по выходам слоя,
  • JJ — матрица Якоби слоя размерности m×nm \times n,
  • Lx\frac{\partial L}{\partial x} — итоговый вектор производных лосса по входам слоя размерности 1×n1 \times n.

autograd никогда не материализует матрицу Якоби целиком: для каждой базовой операции (сложение, умножение, матричное произведение) в PyTorch заранее зашит специализированный алгоритм прямого вычисления векторно-якобиева произведения vTJv^T J.


Накопление градиентов и управление буфером .grad

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

Ключевая архитектурная особенность PyTorch: градиенты не перезаписываются, а накапливаются (суммируются) в тензоре .grad.

param.gradparam.grad+Lparam\text{param.grad} \leftarrow \text{param.grad} + \frac{\partial L}{\partial \text{param}}

x = torch.tensor([2.0], requires_grad=True)

# Первый шаг вычислений
y1 = x ** 2  # dy1/dx = 2 * x = 4.0
y1.backward()
print(f"Градиент после 1-го backward: {x.grad.item()}") # 4.0

# Второй шаг вычислений БЕЗ сброса буфера
y2 = x ** 3  # dy2/dx = 3 * x^2 = 12.0
y2.backward()
print(f"Градиент после 2-го backward: {x.grad.item()}") # 4.0 + 12.0 = 16.0

Если не очищать .grad перед каждым новым батчем, градиенты от текущего шага сложатся с градиентами от предыдущего. В результате шаг оптимизации будет выполнен в неверном направлении, и обучение разойдётся. Для очистки градиентов вызывают метод optimizer.zero_grad().

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


Градиентная оптимизация: SGD, Momentum и Adam

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

1. Стохастический градиентный спуск (SGD)

Базовое правило обновления параметров:

θt+1=θtηgt\theta_{t+1} = \theta_t - \eta \cdot g_t

  • θt\theta_t — вектор параметров сети на шаге tt,
  • η\eta — скорость обучения (learning rate, η>0\eta > 0),
  • gt=θL(θt)g_t = \nabla_\theta L(\theta_t) — вектор градиентов потерь по параметрам на текущем батче.

Пример: если вес w=1.5w = 1.5, скорость обучения η=0.01\eta = 0.01, а вычисленный градиент g=3.0g = 3.0, новое значение составит wnew=1.50.01×3.0=1.47w_{\text{new}} = 1.5 - 0.01 \times 3.0 = 1.47.

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

2. SGD с моментом (Momentum)

Метод симулирует физику движения тяжёлого шара: накопленная «инерция» гасит поперечные колебания и ускоряет движение вдоль устойчивого градиентного направления:

vt+1=γvt+ηgtv_{t+1} = \gamma v_t + \eta \cdot g_t

θt+1=θtvt+1\theta_{t+1} = \theta_t - v_{t+1}

  • vtv_t — вектор накопленной скорости (буфер инерции),
  • γ\gamma — коэффициент затухания момента (типично γ=0.9\gamma = 0.9).

3. Адаптивный оптимизатор Adam (Adaptive Moment Estimation)

Adam комбинирует идею момента (первый момент — экспоненциальное скользящее среднее градиентов mtm_t) и масштабирование шага по дисперсии градиентов (второй момент — vtv_t):

mt=β1mt1+(1β1)gtm_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t

vt=β2vt1+(1β2)gt2v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2

m^t=mt1β1t,v^t=vt1β2t\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}

θt+1=θtηv^t+ϵm^t\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t

  • mtm_t и vtv_t — оценки первого (среднее) и второго (нецентрированная дисперсия) моментов градиента,
  • β1,β2\beta_1, \beta_2 — гиперпараметры сглаживания (по умолчанию β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999),
  • m^t,v^t\hat{m}_t, \hat{v}_t — скорректированные на смещение к нулю оценки моментов,
  • ϵ\epsilon — малая константа для исключения деления на ноль (ϵ=108\epsilon = 10^{-8}).
Критерий SGD SGD + Momentum Adam
Адаптивный шаг под каждый вес Нет Нет Да (через v^t\sqrt{\hat{v}_t})
Устойчивость к шуму батчей Низкая Средняя Высокая
Память на весовой параметр 0 байт 4 байта (буфер vv) 8 байт (буферы mm и vv)
Чувствительность к learning rate Высокая Средняя Низкая

Управление вычислительным графом: no_grad и inference_mode

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

PyTorch предоставляет контекстные менеджеры для полного отключения трекинга операций:

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(784, 128),
    nn.ReLU(),
    nn.Linear(128, 10)
)

dummy_input = torch.randn(32, 784)

# 1. Режим с построением графа (по умолчанию)
out_train = model(dummy_input)
print(f"Обучение: requires_grad={out_train.requires_grad}") # True

# 2. Контекст torch.no_grad()
with torch.no_grad():
    out_val = model(dummy_input)
    print(f"Валидация (no_grad): requires_grad={out_val.requires_grad}") # False

# 3. Высокопроизводительный режим torch.inference_mode()
with torch.inference_mode():
    out_test = model(dummy_input)
    print(f"Инференс (inference_mode): requires_grad={out_test.requires_grad}") # False
  • torch.no_grad() отключает вычисление grad_fn и экономит память под промежуточные тензоры.
  • torch.inference_mode() — более жёсткая и быстрая версия no_grad(), появившаяся в PyTorch 1.9. Она отключает не только автодифференцирование, но и трекинг версий тензоров, обеспечивая максимальную скорость инференса в боевых CV-сервисах.

Пайплайн подачи данных в PyTorch: абстракции Dataset, DataLoader, батчинг и нормализация тензоров

Пайплайн подачи данных в PyTorch: абстракции Dataset, DataLoader, батчинг и нормализация тензоров

Если запустить обучение нейросети на современном графическом процессоре и взглянуть на утилизацию GPU через утилиту nvidia-smi, можно обнаружить парадокс: видеокарта за несколько тысяч долларов простаивает с загрузкой 5–10%, пока вычисления заблокированы ожиданием данных. В компьютерном зрении узким местом почти всегда становится не обратный проход по вычислительному графу, а дисковый ввод-вывод, декодирование сжатых JPEG-файлов и преобразование пикселей в тензоры на CPU.

Чтобы видеокарта непрерывно выполняла матричные перемножения, архитектура подачи данных в PyTorch разделена на два изолированных уровня: абстракцию хранения единичного сэмпла (Dataset) и механизм параллельной сборки пакетов (DataLoader).


Абстракция Dataset: протокол доступа к данным

Базовый класс torch.utils.data.Dataset реализует паттерн «коллекция с произвольным доступом» (Map-style dataset). Его задача — скрыть за простым интерфейсом физическое расположение файлов, формат аннотаций и логику декодирования единичного сэмпла.

Чтобы создать собственный источник данных, достаточно унаследовать класс от torch.utils.data.Dataset и переопределить три специальных метода:

import os
import cv2
import torch
from torch.utils.data import Dataset

class DefectDataset(Dataset):
    def __init__(self, file_paths: list[str], labels: list[int], transform=None):
        self.file_paths = file_paths
        self.labels = labels
        self.transform = transform

    def __len__(self) -> int:
        return len(self.file_paths)

    def __getitem__(self, idx: int) -> tuple[torch.Tensor, int]:
        image_path = self.file_paths[idx]
        # Чтение изображения в формате BGR через OpenCV
        image = cv2.imread(image_path)
        if image is None:
            raise FileNotFoundError(f"Не удалось прочитать файл: {image_path}")

        # Конвертация в RGB
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

        label = self.labels[idx]

        if self.transform is not None:
            image = self.transform(image)

        return image, label

Контракт методов предельно строг:

  1. __init__ — сохраняет метаданные: пути к файлам, метки классов, конфигурацию предобработки. В конструкторе запрещено загружать сами растровые матрицы в память, иначе датасет из миллиона изображений мгновенно вызовет аварийную остановку из-за нехватки RAM.
  2. __len__ — возвращает общее количество доступных объектов выборки (целое число).
  3. __getitem__(idx) — принимает целочисленный индекс и возвращает ровно один готовый образец (кортеж из предобработанного тензора изображения и его числовой метки). Загрузка с диска происходит «лениво» — только в момент запроса конкретного индекса.

Преобразования и поканальная нормализация тензоров

Приведение пикселей из формата uint8 диапазона [0,255][0, 255] в вещественные числа [0.0,1.0][0.0, 1.0] делением на 255.0255.0 — обязательный, но недостаточный шаг. Для быстрой и устойчивой сходимости градиентного спуска входные данные модели должны иметь нулевое среднее и единичную дисперсию вдоль каждого цветового канала. Это называется стандартной нормализацией (Z-score):

z=xμσz = \frac{x - \mu}{\sigma}

Здесь xx — нормализованное в диапазон [0.0,1.0][0.0, 1.0] значение пикселя, μ\mu — математическое ожидание яркости по обучающей выборке для конкретного канала, а σ\sigma — стандартное отклонение яркости для этого же канала.

Практический пример: Пусть в красном канале RR пиксель имеет значение x=0.600x = 0.600, среднее по выборке μ=0.485\mu = 0.485, а стандартное отклонение σ=0.229\sigma = 0.229.

Тогда нормализованное значение составит:

z=0.6000.4850.2290.502z = \frac{0.600 - 0.485}{0.229} \approx 0.502

Если распределение входных признаков смещено (например, все пиксели строго положительны в диапазоне [0.0,1.0][0.0, 1.0]), то градиенты по весам полносвязного слоя на первом шаге будут сонаправлены (все строго положительны или строго отрицательны). Вектор весов вынужден двигаться «зигзагами», что замедляет обучение в разы. Поканальная стандартизация центрирует гиперплоскость оптимизации вокруг начала координат.

В экосистеме PyTorch цепочки таких преобразований собираются через библиотеку torchvision.transforms.v2:

import torchvision.transforms.v2 as transforms

# Конвейер предобработки для обучающей выборки
train_transforms = transforms.Compose([
    transforms.ToImage(),                                           # Преобразование массива в Image-тензор
    transforms.ToDtype(torch.float32, scale=True),                  # Масштабирование [0, 255] -> [0.0, 1.0]
    transforms.Normalize(mean=[0.485, 0.456, 0.406],                # Вычитание поканального среднего
                         std=[0.229, 0.224, 0.225])                 # Деление на стандартное отклонение
])

Статистики mean=[0.485, 0.456, 0.406] и std=[0.229, 0.224, 0.225] — это стандартные значения, рассчитанные по миллионам изображений датасета ImageNet. Они подходят для большинства прикладных задач технического зрения, использующих RGB-камеры естественного освещения.


DataLoader: упаковка сэмплов и механизм collate_fn

Класс Dataset оперирует исключительно одиночными элементами. Оптимизаторы же (SGD, Adam) обновляют веса по мини-батчам. Задачу формирования мини-батчей, перемешивания индексов перед каждой эпохой и распараллеливания чтения решает torch.utils.data.DataLoader.

Центральным механизмом DataLoader является функция агрегации collate_fn. По умолчанию default_collate принимает список из NN кортежей, возвращенных методом __getitem__:

batch_list=[(tensor0,y0),(tensor1,y1),,(tensorN1,yN1)]\text{batch\_list} = [(\text{tensor}_0, y_0), (\text{tensor}_1, y_1), \dots, (\text{tensor}_{N-1}, y_{N-1})]

Затем collate_fn объединяет их вдоль новой нулевой оси с помощью вызова torch.stack:

  • Индивидуальные 3D-тензоры формы (C,H,W)(C, H, W) упаковываются в единый 4D-тензор батча формы (N,C,H,W)(N, C, H, W).
  • Скалярные метки классов yiy_i упаковываются в 1D-тензор формы (N)(N).
from torch.utils.data import DataLoader

loader = DataLoader(
    dataset=DefectDataset(file_paths, labels, transform=train_transforms),
    batch_size=32,
    shuffle=True,        # Перемешивание индексов в начале каждой эпохи
    drop_last=True       # Отбрасывание неполного последнего батча
)

# Проверка размерностей одного батча
batch_images, batch_labels = next(iter(loader))
print(f"Форма пакета изображений: {batch_images.shape}")   # torch.Size([32, 3, 224, 224])
print(f"Форма пакета меток:       {batch_labels.shape}")   # torch.Size([32])

Параметр drop_last=True критически важен при обучении: если размер датасета не делится нацело на batch_size, последний батч может содержать, например, всего 2 сэмпла. Градиенты от такого микробатча обладают высокой дисперсией и могут разрушить стабилизированные веса оптимизатора.


Многопроцессорная загрузка и pinned memory

Интерпретатор CPython ограничен глобальной блокировкой потоков (GIL, Global Interpreter Lock), из-за чего многопоточная загрузка на Python-потоках не дает прироста скорости при декодировании изображений. Поэтому DataLoader реализует многопроцессорность на базе модуля multiprocessing.

Параметр Назначение Рекомендация для продакшена
num_workers Количество дочерних процессов CPU для параллельного выполнения __getitem__ 2×(число CPU-ядер)2 \times (\text{число CPU-ядер}), но не более 881616 на один GPU
pin_memory Выделение тензоров в постранично фиксированной (page-locked/pinned memory) памяти, защищенной от выгрузки в swap True при обучении на GPU
persistent_workers Сохранение процессов-воркеров активными между эпохами True, чтобы исключить накладные расходы на пересоздание процессов
prefetch_factor Количество батчей, заранее загружаемых каждым воркером в очередь 2244 батча

Механизм Pinned Memory выделяет специальный сегмент оперативной памяти хоста, который физически блокируется от выгрузки в файл подкачки (swap). Это позволяет контроллеру Direct Memory Access (DMA) копировать тензор из оперативной памяти в видеопамять GPU напрямую через шину PCIe, полностью минуя центральный процессор.

Благодаря этому перенос батча на видеокарту через метод .to(device, non_blocking=True) выполняется асинхронно параллельно с вычислением прямого прохода предыдущего батча:

# Инициализация высокопроизводительного загрузчика
train_loader = DataLoader(
    dataset=DefectDataset(file_paths, labels, transform=train_transforms),
    batch_size=64,
    shuffle=True,
    num_workers=4,
    pin_memory=True,
    persistent_workers=True,
    prefetch_factor=2
)

Предостережение при работе с OpenCV в воркерах: Библиотека OpenCV по умолчанию запускает внутренние пулы потоков для каждого вызова cv2.imread или cv2.cvtColor. Когда DataLoader порождает num_workers=8 процессов, операционная система оказывается перегружена сотнями конкурирующих потоков CPU. Это приводит к просадке производительности (thrashing).

Перед созданием DataLoader всегда отключайте многопоточность OpenCV в основном скрипте:

cv2.setNumThreads(0)
cv2.ocl.setUseOpenCL(False)

Сквозной пример: сборка производственного конвейера данных

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

import os
import cv2
import torch
from torch.utils.data import Dataset, DataLoader
import torchvision.transforms.v2 as transforms

# Отключение внутренних потоков OpenCV во избежание конфликта с multiprocessing
cv2.setNumThreads(0)
cv2.ocl.setUseOpenCL(False)

class IndustrialPCBDataset(Dataset):
    """Датасет инспекции печатных плат: чтение с диска, конвертация и маппинг меток."""
    def __init__(self, samples: list[tuple[str, int]], transform=None):
        self.samples = samples
        self.transform = transform

    def __len__(self) -> int:
        return len(self.samples)

    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
        img_path, class_id = self.samples[idx]

        image = cv2.imread(img_path)
        if image is None:
            raise RuntimeError(f"Сбой чтения кадра: {img_path}")

        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

        if self.transform is not None:
            image = self.transform(image)

        # Возвращаем тензор изображения и целочисленную метку в формате int64 (torch.long)
        target = torch.tensor(class_id, dtype=torch.long)
        return image, target

def build_data_pipeline(data_list: list[tuple[str, int]], batch_size: int = 32) -> DataLoader:
    # 1. Построение цепочки трансформаций
    pipeline_transforms = transforms.Compose([
        transforms.ToImage(),
        transforms.Resize((224, 224), antialias=True),
        transforms.ToDtype(torch.float32, scale=True),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ])

    # 2. Инициализация датасета
    dataset = IndustrialPCBDataset(samples=data_list, transform=pipeline_transforms)

    # 3. Конфигурация параллельного загрузчика
    loader = DataLoader(
        dataset=dataset,
        batch_size=batch_size,
        shuffle=True,
        num_workers=4,
        pin_memory=torch.cuda.is_available(),
        persistent_workers=True,
        drop_last=True
    )
    return loader

Этот конвейер гарантирует: видеокарта непрерывно получает 4D-тензоры формата (N,C,H,W)(N, C, H, W), нормализованные по статистикам яркости, а декодирование и преобразования происходят в фоновых процессах CPU без задержки вычислительного цикла.

Целевые функции и метрики: измерение ошибки классификации и оценка предиктивной способности модели

Целевые функции и метрики: измерение ошибки классификации и оценка предиктивной способности модели

Представьте конвейерную линию поверхностного монтажа печатных плат: камера фиксирует 10 000 компонентов в час, из которых ровно 10 содержат критический дефект — микротрещину дорожки. Если спроектировать тривиальную модель, которая на любое изображение выдаёт константный ответ «плата исправна», её точность составит внушительные 99.9%. Однако с точки зрения контроля качества такая система абсолютно бесполезна: она пропустила 100% производственного брака.

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


От сырых логитов к распределению вероятностей

На выходе последнего полносвязного слоя полносвязной сети формируется вектор вещественных чисел размерности (N,C)(N, C), где NN — размер батча, а CC — количество целевых классов. Эти нескорректированные значения называются логитами (logits).

Логиты могут принимать любые вещественные значения в диапазоне от -\infty до ++\infty. Чтобы интерпретировать их как распределение вероятностей взаимоисключающих классов, вектор логитов zz необходимо нормализовать с помощью функции Softmax:

S(z)i=ezij=1CezjS(z)_i = \frac{e^{z_i}}{\sum_{j=1}^{C} e^{z_j}}

где ziz_i — логит ii-го класса, а знаменатель представляет собой сумму экспонент всех логитов вектора.

Функция Softmax обладает двумя ключевыми свойствами:

  1. Каждое выходное значение строго лежит в интервале (0,1)(0, 1).
  2. Сумма всех элементов вектора i=1CS(z)i\sum_{i=1}^C S(z)_i тождественно равна 1.01.0.

Рассмотрим практический пример. Предположим, для классификации дефекта на 3 класса (0 — норма, 1 — царапина, 2 — прогар) сеть сформировала логиты:

z=[2.0,1.0,0.1]z = [2.0, 1.0, 0.1]

Вычислим экспоненты: e2.07.389e^{2.0} \approx 7.389, e1.02.718e^{1.0} \approx 2.718, e0.11.105e^{0.1} \approx 1.105. Их сумма равна 7.389+2.718+1.105=11.2127.389 + 2.718 + 1.105 = 11.212.

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

p=[0.659,0.242,0.099]p = [0.659, 0.242, 0.099]

Проблема вычислительной нестабильности и трюк Log-Sum-Exp

Прямое вычисление экспонент от больших чисел в 32-битных числах с плавающей точкой (float32) приводит к арифметическому переполнению (overflow). Например, значение e89e^{89} превышает максимальное представимое число для float32 (~3.4×10383.4 \times 10^{38}) и возвращает inf.

Чтобы гарантировать численную стабильность, применяют математическое свойство инвариантности Softmax к сдвигу аргументов на константу MM:

ezijezj=eziMjezjM\frac{e^{z_i}}{\sum_j e^{z_j}} = \frac{e^{z_i - M}}{\sum_j e^{z_j - M}}

Если принять M=max(z)M = \max(z), то максимальный аргумент экспоненты станет равным 00, а все остальные — отрицательными. Это полностью исключает риск переполнения памяти, превращая вычисления в строго устойчивые.


Функция потерь перекрёстной энтропии (Cross-Entropy Loss)

После того как сеть сформировала вектор вероятностей pp, необходимо количественно оценить, насколько он далёк от истинного распределения меток yy. В задачах многоклассовой классификации истинная метка задаётся в виде one-hot вектора, где единица стоит на позиции правильного класса cc, а остальные элементы равны нулю.

Функция потерь перекрёстной энтропии (Cross-Entropy Loss) штрафует модель за расхождение между предсказанным и истинным распределением:

LCE=i=1Cyilog(pi)\mathcal{L}_{\mathrm{CE}} = -\sum_{i=1}^C y_i \log(p_i)

Поскольку в векторе yy все компоненты равны нулю, кроме истинного класса cc (yc=1y_c = 1), формула упрощается до логарифмического штрафа истинного класса:

LCE=log(pc)\mathcal{L}_{\mathrm{CE}} = -\log(p_c)

где pcp_c — предсказанная моделью вероятность правильного класса.

  • Если модель абсолютно уверена в правильном ответе (pc1.0p_c \to 1.0), штраф стремится к нулю: log(1.0)=0-\log(1.0) = 0.
  • Если модель ошибается и оценивает вероятность правильного класса близко к нулю (pc0p_c \to 0), штраф уходит в бесконечность: log(0.01)4.605-\log(0.01) \approx 4.605, log(0.0001)9.210-\log(0.0001) \approx 9.210.

Анатомия nn.CrossEntropyLoss в PyTorch

В библиотеке PyTorch модуль nn.CrossEntropyLoss математически эквивалентен последовательному применению слоя nn.LogSoftmax и критерия отрицательного логарифмического правдоподобия nn.NLLLoss (Negative Log-Likelihood Loss):

CrossEntropyLoss(z,c)=NLLLoss(LogSoftmax(z),c)\mathrm{CrossEntropyLoss}(z, c) = \mathrm{NLLLoss}(\mathrm{LogSoftmax}(z), c)

Важнейшее инженерное правило PyTorch: Модуль nn.CrossEntropyLoss принимает на вход сырые логиты (не нормализованные через Softmax). Внутри критерия вычисление Softmax и логарифма объединено в единое аналитическое выражение через трюк Log-Sum-Exp. Это обеспечивает максимальную вычислительную точность и защищает от деления на ноль.

import torch
import torch.nn as nn

# Логиты модели для батча из 2 сэмплов на 3 класса (форма: [2, 3])
logits = torch.tensor([[2.0, 1.0, 0.1],
                       [0.2, 3.5, 0.8]], dtype=torch.float32)

# Истинные целочисленные индексы классов (форма: [2])
targets = torch.tensor([0, 1], dtype=torch.long)

# Вычисление функции потерь
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, targets)

print(f"Скалярное значение Loss: {loss.item():.4f}")

Если данные несбалансированы (например, дефектный класс встречается в 10 раз реже нормы), в конструктор nn.CrossEntropyLoss передают аргумент weight — одномерный тензор весов классов:

# Класс 1 (редкий дефект) получает в 10 раз больший штраф при ошибке
class_weights = torch.tensor([1.0, 10.0, 2.0], dtype=torch.float32)
weighted_criterion = nn.CrossEntropyLoss(weight=class_weights)

Бинарная классификация против многоклассовой

В компьютерном зрении важно различать три типа задач классификации:

Тип задачи Число выходов сети (DoutD_{\mathrm{out}}) Финальное преобразование Функция потерь в PyTorch
Бинарная (дефект / норма) 11 (скалярный логит) Сигмоида σ(z)\sigma(z) nn.BCEWithLogitsLoss
Многоклассовая (один класс из CC) CC (вектор логитов) Softmax(z)\mathrm{Softmax}(z) nn.CrossEntropyLoss
Многометочная (несколько дефектов сразу) CC (независимые логиты) Поэлементная σ(zi)\sigma(z_i) nn.BCEWithLogitsLoss

Для бинарной задачи используется критерий бинарной перекрёстной энтропии (Binary Cross-Entropy, BCE). Как и в многоклассовом случае, класс nn.BCEWithLogitsLoss объединяет вычисление сигмоиды и логарифма потерь в одну численно устойчивую операцию:

LBCE=[ylog(σ(z))+(1y)log(1σ(z))]\mathcal{L}_{\mathrm{BCE}} = - [y \log(\sigma(z)) + (1 - y) \log(1 - \sigma(z))]


Оценка качества модели: матрица ошибок и эксплуатационные метрики

Значение функции потерь (Loss) необходимо для градиентного спуска, но само по себе оно не отвечает на прикладные вопросы: «Какова вероятность пропустить брак?» или «Как часто система бракует годную деталь?». Для ответа на эти вопросы предсказания модели сопоставляются с истинными метками через матрицу ошибок (Confusion Matrix).

В бинарной постановке пространство исходов делится на 4 категории:

  • True Positive (TP): истинно положительные — модель верно обнаружила дефект.
  • False Positive (FP): ложноположительные (ошибка I рода) — модель назвала годную деталь браком (ложная тревога).
  • True Negative (TN): истинно отрицательные — модель верно подтвердила исправность детали.
  • False Negative (FN): ложноотрицательные (ошибка II рода) — модель пропустила дефект в партию.

Базовые метрики

На основе элементов матрицы ошибок вычисляются специализированные показатели качества.

Accuracy (доля правильных ответов) — отношение числа всех верных предсказаний к общему размеру выборки:

Accuracy=TP+TNTP+TN+FP+FN\mathrm{Accuracy} = \frac{\mathrm{TP} + \mathrm{TN}}{\mathrm{TP} + \mathrm{TN} + \mathrm{FP} + \mathrm{FN}}

Ограничение: метрика неприменима при дисбалансе классов. Если выборка содержит 990 годных плат и 10 бракованных, тривиальный классификатор получает Accuracy=0.99\mathrm{Accuracy} = 0.99, скрывая факт пропуска всех 10 дефектов (FN=10\mathrm{FN} = 10).

Precision (точность / прогностическая ценность) — доля реальных дефектов среди всех объектов, которые модель пометила как дефект:

Precision=TPTP+FP\mathrm{Precision} = \frac{\mathrm{TP}}{\mathrm{TP} + \mathrm{FP}}

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

Recall (полнота / чувствительность) — доля обнаруженных дефектов относительно всех реально существовавших дефектов:

Recall=TPTP+FN\mathrm{Recall} = \frac{\mathrm{TP}}{\mathrm{TP} + \mathrm{FN}}

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

F1-Score (гармоническое среднее) — балансирующая метрика, объединяющая Precision и Recall:

F1=2PrecisionRecallPrecision+Recall=2TP2TP+FP+FN\mathrm{F1} = 2 \cdot \frac{\mathrm{Precision} \cdot \mathrm{Recall}}{\mathrm{Precision} + \mathrm{Recall}} = \frac{2\mathrm{TP}}{2\mathrm{TP} + \mathrm{FP} + \mathrm{FN}}

В отличие от арифметического среднего, гармоническое среднее стремится к меньшему значению: если одна из метрик обрушивается до нуля, F1-Score также падает до нуля.


Многоклассовая агрегация метрик: Macro, Micro, Weighted

Когда классов больше двух, матрица ошибок становится матрицей размера C×CC \times C, где строки соответствуют истинным классам, а столбцы — предсказанным. Для вычисления итоговой метрики по всему датасету применяют три стратегии агрегации:

  1. Macro-averaging (макроусреднение): метрика вычисляется независимо для каждого класса, после чего берется простое среднее арифметическое:

    F1macro=1Ci=1CF1i\mathrm{F1}_{\mathrm{macro}} = \frac{1}{C} \sum_{i=1}^C \mathrm{F1}_i

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

  2. Weighted-averaging (взвешенное усреднение): метрика каждого класса взвешивается на его долю (Support) в выборке:

    F1weighted=i=1CNiNtotalF1i\mathrm{F1}_{\mathrm{weighted}} = \sum_{i=1}^C \frac{N_i}{N_{\mathrm{total}}} \mathrm{F1}_i

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

  3. Micro-averaging (микроусреднение): сначала суммируются глобальные счетчики TP\mathrm{TP}, FP\mathrm{FP}, FN\mathrm{FN} по всем классам, а затем вычисляется единая метрика. В задаче одновариантной классификации Micro-F1\mathrm{Micro\text{-}F1} математически совпадает с Accuracy\mathrm{Accuracy}.


Вычисление метрик в PyTorch

В процессе валидации модели инференс выполняется в контексте torch.inference_mode(). Для каждого мини-батча извлекаются индексы наиболее вероятных классов через torch.argmax(logits, dim=1) и накапливаются для формирования итоговых метрик:

import torch

@torch.inference_mode()
def evaluate_predictions(logits: torch.Tensor, targets: torch.Tensor):
    """
    Вычисление базовых метрик классификации для батча.
    """
    # Получение индекса класса с максимальным логитом
    preds = torch.argmax(logits, dim=1)

    # Расчет бинарных счетчиков для конкретного целевого класса (например, класс 1)
    target_class = 1

    tp = torch.sum((preds == target_class) & (targets == target_class)).item()
    fp = torch.sum((preds == target_class) & (targets != target_class)).item()
    fn = torch.sum((preds != target_class) & (targets == target_class)).item()
    tn = torch.sum((preds != target_class) & (targets != target_class)).item()

    # Безопасное вычисление Precision и Recall с защитой от деления на 0
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0

    return {
        "TP": tp, "FP": fp, "FN": fn, "TN": tn,
        "Precision": precision, "Recall": recall, "F1": f1
    }

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

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

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

В вашем инженерном арсенале уже есть все компоненты глубокого обучения: многомерные тензоры в памяти GPU, параметрические полносвязные слои, механизм автоматического дифференцирования autograd, конвейеры пакетной загрузки данных DataLoader и функции потерь с метриками качества. Однако набор разрозненных узлов не составляет работающий механизм. Если просто соединить их случайным образом, модель может зависнуть из-за утечки видеопамяти на первой же сотне итераций, тихо переобучиться до полной непригодности или сохранить не те веса, потеряв результаты многочасовых вычислений.

Наша цель — собрать эти разрозненные модули в отказоустойчивый, масштабируемый конвейер. Мы построим сквозной цикл обучения (Training Loop) и валидации (Validation Loop), настроим корректное версионирование контрольных точек (Checkpoints) и реализуем модуль инференса, способный принимать кадр из промышленной камеры и возвращать классификационный вердикт с оценкой уверенности.


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

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

Шаг обучения (Training Step) — это неделимая атомарная операция, в рамках которой происходят пять строгих этапов:

  1. Перенос батча на целевое устройство: тензоры изображений и меток загружаются в память GPU через метод .to(device, non_blocking=True).
  2. Обнуление накопленных градиентов: метод optimizer.zero_grad(set_to_none=True) очищает буферы .grad у всех обучаемых параметров модели. Передача аргумента set_to_none=True предпочтительнее стандартного зануления нулями: сборщик мусора просто освобождает память, не тратя такты процессора на запись нулей в тензоры.
  3. Прямой проход (Forward Pass): вычисление логитов модели на текущем мини-батче.
  4. Вычисление функции потерь: передача логитов и истинных меток в критерий (например, nn.CrossEntropyLoss).
  5. Обратное распространение ошибки и шаг оптимизатора: вызов loss.backward() для расчета частных производных по графу autograd и последующий вызов optimizer.step() для обновления весов W\mathbf{W} и смещений b\mathbf{b}.
# Базовый каркас одного шага обучения
optimizer.zero_grad(set_to_none=True)

# Прямой проход
outputs = model(images)
loss = criterion(outputs, targets)

# Обратный проход и оптимизация
loss.backward()
optimizer.step()

Критическая ошибка аккумуляции потерь

Частая ошибка начинающих инженеров — подсчет суммарной ошибки эпохи простым сложением: running_loss += loss.

Переменная loss является узлом вычислительного графа PyTorch (тензором с прикрепленной историей операций grad_fn). Складывая сам тензор loss, вы удерживаете в оперативной или видеопамяти весь вычислительный граф текущего и всех предыдущих батчей. Граф не может быть уничтожен сборщиком мусора, что неизбежно приводит к ошибке CUDA out of memory (OOM).

Правильный подход — извлекать изолированное скалярное число с плавающей точкой через метод .item():

# Безопасный расчет суммарной ошибки с учетом реального размера батча
batch_size = images.size(0)
running_loss += loss.item() * batch_size

Режимы работы модели: train против eval

Полносвязные и сверточные сети содержат слои, чье математическое поведение кардинально различается на этапах подбора весов и практического применения (например, слои регуляризации nn.Dropout или нормализации nn.BatchNorm2d).

Для явного переключения поведения архитектуры класс nn.Module предоставляет два управляющих метода:

  • model.train() — активирует случайное отключение нейронов в Dropout (с масштабированием оставшихся сигналов на 1/(1p)1/(1-p) по схеме Inverted Dropout) и расчет скользящих статистик в BatchNorm. В этом режиме модель готова к оптимизации.
  • model.eval() — переводит все слои в детерминированное состояние (Dropout полностью деактивируется и пропускает входные данные как тождественное отображение без изменений; BatchNorm использует накопленные глобальные статистики среднего и дисперсии).

Метод model.eval() изменяет внутреннее состояние слоев модели, но не отключает автоматическое дифференцирование autograd. Для валидации и инференса вызов model.eval() всегда должен комбинироваться с контекстным менеджером torch.inference_mode().


Валидационный проход и мониторинг переобучения

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

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

def validate_one_epoch(model, dataloader, criterion, device):
    model.eval()
    running_loss = 0.0
    correct_predictions = 0
    total_samples = 0

    with torch.inference_mode():
        for images, targets in dataloader:
            images = images.to(device, non_blocking=True)
            targets = targets.to(device, non_blocking=True)

            logits = model(images)
            loss = criterion(logits, targets)

            batch_size = images.size(0)
            running_loss += loss.item() * batch_size

            # Вычисление предсказанных классов (индекс максимального логита)
            _, predicted = torch.max(logits, dim=1)
            correct_predictions += (predicted == targets).sum().item()
            total_samples += batch_size

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

Сохранение и загрузка контрольных точек (Checkpoints)

Обучение модели может занимать десятки часов. В производственной среде сохранять архитектуру модели целиком через torch.save(model, path) не рекомендуется: такой подход жестко привязывает файл к конкретной структуре каталогов и исходному коду проекта в Python через механизм pickle.

Индустриальный стандарт — сохранение словаря состояния (state dictionary / state_dict). state_dict представляет собой упорядоченный словарь Python, сопоставляющий имена каждого слоя с их текущими тензорами параметров (W\mathbf{W} и b\mathbf{b}).

Структура полного производственного чекпоинта

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

checkpoint = {
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'best_val_loss': best_val_loss,
    'class_to_idx': class_mapping
}
torch.save(checkpoint, 'best_checkpoint.pth')

Восстановление состояния

Для применения сохраненных весов сначала создается экземпляр класса модели с идентичной архитектурой, после чего в него загружаются веса методом load_state_dict:

# 1. Инициализация архитектуры
model = DefectClassifierMLP(input_dim=784, num_classes=3)

# 2. Чтение файла с диска на указанное устройство
checkpoint = torch.load('best_checkpoint.pth', map_location=device)

# 3. Применение весов
model.load_state_dict(checkpoint['model_state_dict'])
model.to(device)
model.eval()

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

Объединим все изученные абстракции в единую законченную программу. Решим задачу оптического контроля качества: классификацию полутоновых фрагментов деталей размером 28×2828 \times 28 пикселей на три категории:

  1. Class 0: Норма (Pass)
  2. Class 1: Трещина (Crack)
  3. Class 2: Поверхностная пора (Pore)

1. Архитектура нейросети

Создадим полносвязную архитектуру DefectClassifierMLP. На вход подается выпрямленный вектор 28×28=78428 \times 28 = 784 признака, скрытые слои используют активацию ReLU и регуляризацию Dropout, а выходной слой генерирует 3 логита:

import torch
import torch.nn as nn

class DefectClassifierMLP(nn.Module):
    def __init__(self, input_dim: int = 784, num_classes: int = 3):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Dropout(p=0.2),
            nn.Linear(256, 64),
            nn.ReLU(),
            nn.Linear(64, num_classes)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)

2. Сборка обучающего цикла (Training Engine)

Реализуем процедуру обучения с сохранением наилучшей модели по метрике минимальной ошибки на валидации (val_loss):

import copy
from typing import Dict, Tuple

def train_model(
    model: nn.Module,
    train_loader: torch.utils.data.DataLoader,
    val_loader: torch.utils.data.DataLoader,
    criterion: nn.Module,
    optimizer: torch.optim.Optimizer,
    num_epochs: int,
    device: torch.device,
    save_path: str = "best_model.pth"
) -> Dict[str, list]:

    model = model.to(device)
    history = {'train_loss': [], 'val_loss': [], 'val_acc': []}
    best_val_loss = float('inf')

    for epoch in range(1, num_epochs + 1):
        # ----------------- Фаза обучения -----------------
        model.train()
        running_train_loss = 0.0
        total_train_samples = 0

        for images, targets in train_loader:
            images = images.to(device, non_blocking=True)
            targets = targets.to(device, non_blocking=True)

            optimizer.zero_grad(set_to_none=True)
            logits = model(images)
            loss = criterion(logits, targets)

            loss.backward()
            optimizer.step()

            batch_size = images.size(0)
            running_train_loss += loss.item() * batch_size
            total_train_samples += batch_size

        epoch_train_loss = running_train_loss / total_train_samples

        # ----------------- Фаза валидации -----------------
        model.eval()
        running_val_loss = 0.0
        correct_val_preds = 0
        total_val_samples = 0

        with torch.inference_mode():
            for images, targets in val_loader:
                images = images.to(device, non_blocking=True)
                targets = targets.to(device, non_blocking=True)

                logits = model(images)
                loss = criterion(logits, targets)

                batch_size = images.size(0)
                running_val_loss += loss.item() * batch_size

                _, preds = torch.max(logits, dim=1)
                correct_val_preds += (preds == targets).sum().item()
                total_val_samples += batch_size

        epoch_val_loss = running_val_loss / total_val_samples
        epoch_val_acc = correct_val_preds / total_val_samples

        # Логирование телеметрии
        history['train_loss'].append(epoch_train_loss)
        history['val_loss'].append(epoch_val_loss)
        history['val_acc'].append(epoch_val_acc)

        print(f"Эпоха [{epoch:02d}/{num_epochs:02d}] | "
              f"Train Loss: {epoch_train_loss:.4f} | "
              f"Val Loss: {epoch_val_loss:.4f} | "
              f"Val Acc: {epoch_val_acc * 100:.2f}%")

        # Чекпоинтинг: сохранение лучшего состояния
        if epoch_val_loss < best_val_loss:
            best_val_loss = epoch_val_loss
            checkpoint = {
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'val_loss': best_val_loss,
                'val_acc': epoch_val_acc
            }
            torch.save(checkpoint, save_path)
            print(f"  >>> Чекпоинт сохранен: Val Loss улучшился до {best_val_loss:.4f}")

    return history

Промышленный модуль инференса: от кадра OpenCV к вердикту

На конвейере обученная модель получает кадры в формате numpy.ndarray из OpenCV. Для получения предсказания данные должны пройти цепочку детерминированных преобразований:

  1. Масштабирование до 28×2828 \times 28 и перевод в градации серого при необходимости.
  2. Конвертация типа из uint8 [0,255][0, 255] в float32 и нормализация диапазона значений к [0.0,1.0][0.0, 1.0].
  3. Добавление размерностей батча и канала (unsqueeze(0)) для формирования 4D-тензора формы (1,1,28,28)(1, 1, 28, 28).
  4. Прямой проход под torch.inference_mode().
  5. Расчет вероятностей через torch.softmax и выбор класса с максимальной уверенностью.
import cv2
import numpy as np

class DefectPredictor:
    def __init__(self, checkpoint_path: str, device: str = "cpu"):
        self.device = torch.device(device)
        self.classes = {0: "Норма (Pass)", 1: "Трещина (Crack)", 2: "Пора (Pore)"}

        # Восстановление архитектуры и весов
        self.model = DefectClassifierMLP(input_dim=784, num_classes=3)
        checkpoint = torch.load(checkpoint_path, map_location=self.device)
        self.model.load_state_dict(checkpoint['model_state_dict'])
        self.model.to(self.device)
        self.model.eval()

    def preprocess(self, image: np.ndarray) -> torch.Tensor:
        # Гарантируем полутоновый формат 28x28
        if len(image.shape) == 3:
            gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        else:
            gray = image

        resized = cv2.resize(gray, (28, 28), interpolation=cv2.INTER_AREA)

        # Нормализация [0, 255] -> [0.0, 1.0]
        tensor_img = torch.from_numpy(resized).float() / 255.0

        # Формирование размерности (1, 1, H, W) для модели
        tensor_img = tensor_img.unsqueeze(0).unsqueeze(0)
        return tensor_img

    def predict(self, image: np.ndarray) -> Tuple[str, float]:
        tensor = self.preprocess(image).to(self.device)

        with torch.inference_mode():
            logits = self.model(tensor)
            probabilities = torch.softmax(logits, dim=1)
            confidence, pred_idx = torch.max(probabilities, dim=1)

        class_id = pred_idx.item()
        score = confidence.item()
        return self.classes[class_id], score

Полносвязные сети успешно справляются с выпрямленными векторами фиксированного размера, однако при переходе к реальным изображениям высокого разрешения (1920×10801920 \times 1080, 4K4K) операция выпрямления приводит к взрывному росту числа обучаемых параметров и полному уничтожению двумерного пространственного контекста.

Для преодоления этих ограничений в следующем курсе мы перейдем к фундаменту современного машинного зрения — сверточным нейросетям (Convolutional Neural Networks, CNN).