YOLOv2: когда хорошее стало лучше, а потом превратилось в YOLO9000
YOLOv1 был как первая любовь - революционная, но с кучей проблем. Сетка 7x7, странная функция потерь, проблемы с маленькими объектами. Если вы читали мою статью про функцию потерь YOLOv1, то помните, как там все было сложно с координатами и доверием.
YOLOv2 - это инженерный ответ на все эти проблемы. Джозеф Редмон и его команда не изобрели ничего принципиально нового, но собрали пазл из существующих техник так, что получился один из самых влиятельных детекторов в истории.
Важно: YOLOv2 вышел в 2016 году, но его архитектурные решения до сих пор влияют на современные детекторы. Если вы работаете с YOLOv10 или другими современными версиями, вы все равно видите отголоски решений из v2.
Что реально изменилось в YOLOv2
Не буду тянуть с интригой. Вот что отличает YOLOv2 от предшественника:
- Batch Normalization после каждого сверточного слоя
- Anchor boxes вместо прямого предсказания координат
- Высокоуровневые фичи из классификации (Darknet-19)
- Многомасштабное обучение
- Прямые предсказания вместо пространственных ограничений
Звучит как список фич из документации? Согласен. Но за каждой строчкой - часы экспериментов и инженерных решений.
Batch Normalization: почему это не просто еще один слой
В YOLOv1 не было нормализации. Сети приходилось бороться с внутренним ковариационным сдвигом - когда распределение активаций меняется от слоя к слою во время обучения. Это как пытаться попасть в движущуюся мишень.
Batch Normalization решает эту проблему радикально просто: нормализуем выход каждого слоя по мини-батчу. Формула проста:
# Вот как это работает внутри
import torch
import torch.nn as nn
class ConvBNLeaky(nn.Module):
"""Базовый блок YOLOv2: свертка + BatchNorm + LeakyReLU"""
def __init__(self, in_channels, out_channels, kernel_size, stride=1):
super().__init__()
padding = (kernel_size - 1) // 2
self.conv = nn.Conv2d(
in_channels, out_channels,
kernel_size, stride, padding, bias=False
)
self.bn = nn.BatchNorm2d(out_channels)
self.leaky = nn.LeakyReLU(0.1)
def forward(self, x):
return self.leaky(self.bn(self.conv(x)))
Зачем bias=False в свертке? Потому что BatchNorm уже содержит параметр сдвига (beta). Два смещения - это избыточно и может замедлить сходимость.
Предупреждение: BatchNorm ведет себя по-разному в режиме обучения и инференса. В обучении он использует статистику текущего батча, в инференсе - скользящее среднее за все обучение. Не забудьте model.eval() перед инференсом!
Anchor boxes: самая важная идея YOLOv2
В YOLOv1 сетка предсказывала координаты bounding box напрямую. Это было нестабильно, особенно для больших объектов. YOLOv2 заимствует идею anchor boxes из Faster R-CNN, но делает это по-своему.
Вот в чем суть: вместо того чтобы учить сеть предсказывать абсолютные координаты, учим ее предсказывать отклонения от заранее определенных "якорей".
Как выбрать хорошие anchor boxes? Авторы YOLOv2 использовали k-means кластеризацию на ground truth bounding boxes из датасета. Не на пикселях изображения, а на ширине и высоте боксов.
import numpy as np
from sklearn.cluster import KMeans
def get_anchors(bboxes, num_anchors=5):
"""Вычисляем anchor boxes с помощью k-means"""
# bboxes: массив [N, 2] с шириной и высотой
# Используем IoU как метрику расстояния для k-means
# Это нестандартно, но работает лучше евклидова расстояния
kmeans = KMeans(n_clusters=num_anchors, random_state=42)
kmeans.fit(bboxes)
anchors = kmeans.cluster_centers_
# Сортируем по площади для удобства
anchors = anchors[np.argsort(anchors[:, 0] * anchors[:, 1])]
return anchors
# Пример для COCO
# Типичные anchors для YOLOv2 на COCO:
# [[10, 13], [16, 30], [33, 23], [30, 61], [62, 45]]
Архитектура Darknet-19: когда меньше - больше
YOLOv1 использовал GoogLeNet-inspired архитектуру. YOLOv2 переходит на Darknet-19 - 19 слоевую сеть, специально разработанную для детекции.
| Слой | Тип | Размер фильтра / Страйд | Выход |
|---|---|---|---|
| 0 | Conv | 3x3 / 1 | 32 |
| 1 | MaxPool | 2x2 / 2 | 32 |
| 2-17 | Conv + MaxPool | чередование | 1024 |
| 18 | Conv | 1x1 / 1 | 1000 |
| 19 | Global AvgPool | - | 1000 |
Ключевая особенность: все свертки 3x3 с паддингом 1, чтобы сохранить размер. MaxPool с stride 2 уменьшает размер в 2 раза. В конце - глобальный average pooling вместо полносвязных слоев.
1 Реализуем Darknet-19 с нуля
Вот полная реализация Darknet-19 на PyTorch. Обратите внимание на последовательность ConvBNLeaky блоков:
import torch
import torch.nn as nn
class Darknet19(nn.Module):
"""Полная реализация Darknet-19"""
def __init__(self, num_classes=1000):
super().__init__()
# Первые слои
self.features = nn.Sequential(
ConvBNLeaky(3, 32, 3),
nn.MaxPool2d(2, 2),
ConvBNLeaky(32, 64, 3),
nn.MaxPool2d(2, 2),
ConvBNLeaky(64, 128, 3),
ConvBNLeaky(128, 64, 1),
ConvBNLeaky(64, 128, 3),
nn.MaxPool2d(2, 2),
ConvBNLeaky(128, 256, 3),
ConvBNLeaky(256, 128, 1),
ConvBNLeaky(128, 256, 3),
nn.MaxPool2d(2, 2),
ConvBNLeaky(256, 512, 3),
ConvBNLeaky(512, 256, 1),
ConvBNLeaky(256, 512, 3),
ConvBNLeaky(512, 256, 1),
ConvBNLeaky(256, 512, 3),
nn.MaxPool2d(2, 2),
ConvBNLeaky(512, 1024, 3),
ConvBNLeaky(1024, 512, 1),
ConvBNLeaky(512, 1024, 3),
ConvBNLeaky(1024, 512, 1),
ConvBNLeaky(512, 1024, 3),
)
# Классификационная головка
self.classifier = nn.Sequential(
nn.Conv2d(1024, num_classes, 1),
nn.AdaptiveAvgPool2d(1)
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x.flatten(1)
От Darknet-19 к детектору: добавляем detection head
Darknet-19 - это backbone для классификации. Для детекции нужно добавить detection head. В YOLOv2 это делается так:
- Убираем последний сверточный слой (1000 классов ImageNet)
- Добавляем три сверточных слоя 3x3 с 1024 фильтрами
- Добавляем финальный слой 1x1, который предсказывает bounding boxes
Размер выхода detection head: S x S x (B * (5 + C))
- S: размер сетки (обычно 13 для 416x416 входов)
- B: количество anchor boxes на ячейку (обычно 5)
- 5: tx, ty, tw, th, confidence
- C: количество классов
2 Собираем YOLOv2 detection head
class YOLOv2Head(nn.Module):
"""Detection head для YOLOv2"""
def __init__(self, num_anchors=5, num_classes=20):
super().__init__()
self.num_anchors = num_anchors
self.num_classes = num_classes
# Дополнительные слои для детекции
self.detection_layers = nn.Sequential(
ConvBNLeaky(1024, 1024, 3),
ConvBNLeaky(1024, 1024, 3),
)
# Финальный слой предсказаний
# Каждый anchor предсказывает: 4 координаты + 1 confidence + C классов
self.predictions = nn.Conv2d(
1024, num_anchors * (5 + num_classes), 1
)
def forward(self, x):
# x: [batch, 1024, 13, 13] для 416x416 входа
x = self.detection_layers(x)
predictions = self.predictions(x)
# Решейпим для удобства
batch_size = x.size(0)
grid_size = x.size(2) # 13
# [batch, anchors*(5+C), 13, 13] -> [batch, anchors, 13, 13, 5+C]
predictions = predictions.view(
batch_size, self.num_anchors,
5 + self.num_classes, grid_size, grid_size
).permute(0, 1, 3, 4, 2).contiguous()
return predictions
Преобразование предсказаний в bounding boxes
Вот где начинается магия. Сеть предсказывает не координаты напрямую, а смещения относительно anchor boxes. Формулы преобразования:
def decode_predictions(predictions, anchors, grid_size=13):
"""
Преобразует raw predictions в bounding boxes
predictions: [batch, anchors, grid, grid, 5+C]
anchors: [num_anchors, 2] - ширины и высоты
"""
batch_size = predictions.size(0)
# Создаем координатную сетку
grid_y, grid_x = torch.meshgrid(
torch.arange(grid_size),
torch.arange(grid_size),
indexing='ij'
)
# Добавляем batch и anchor измерения
grid_x = grid_x.view(1, 1, grid_size, grid_size).to(predictions.device)
grid_y = grid_y.view(1, 1, grid_size, grid_size).to(predictions.device)
# Извлекаем предсказания
tx = predictions[..., 0] # Смещение по X
ty = predictions[..., 1] # Смещение по Y
tw = predictions[..., 2] # Логарифм отношения ширины
th = predictions[..., 3] # Логарифм отношения высоты
# Преобразуем в абсолютные координаты
bx = torch.sigmoid(tx) + grid_x # Центр по X
by = torch.sigmoid(ty) + grid_y # Центр по Y
bw = anchors[:, 0].view(1, -1, 1, 1) * torch.exp(tw) # Ширина
bh = anchors[:, 1].view(1, -1, 1, 1) * torch.exp(th) # Высота
# Нормализуем к [0, 1] относительно размера изображения
bx = bx / grid_size
by = by / grid_size
bw = bw / grid_size
bh = bh / grid_size
# Конвертируем в формат [x1, y1, x2, y2]
x1 = bx - bw / 2
y1 = by - bh / 2
x2 = bx + bw / 2
y2 = by + bh / 2
# Извлекаем confidence и классы
confidence = torch.sigmoid(predictions[..., 4])
class_probs = torch.softmax(predictions[..., 5:], dim=-1)
return {
'boxes': torch.stack([x1, y1, x2, y2], dim=-1),
'confidence': confidence,
'class_probs': class_probs
}
Функция потерь YOLOv2: что изменилось
По сравнению с YOLOv1, функция потерь стала проще и стабильнее. Основные компоненты:
- Координатная потеря (MSE для центров, MSE для ширины/высоты)
- Confidence потеря (binary cross-entropy)
- Классовая потеря (softmax cross-entropy)
class YOLOv2Loss(nn.Module):
"""Функция потерь для YOLOv2"""
def __init__(self, num_anchors=5, num_classes=20, lambda_coord=5, lambda_noobj=0.5):
super().__init__()
self.num_anchors = num_anchors
self.num_classes = num_classes
self.lambda_coord = lambda_coord
self.lambda_noobj = lambda_noobj
self.mse = nn.MSELoss(reduction='sum')
self.bce = nn.BCELoss(reduction='sum')
self.ce = nn.CrossEntropyLoss(reduction='sum')
def forward(self, predictions, targets):
"""
predictions: [batch, anchors, grid, grid, 5+C]
targets: словарь с ground truth
"""
device = predictions.device
batch_size = predictions.size(0)
# Маска объектов и не-объектов
obj_mask = targets['obj_mask'] # [batch, anchors, grid, grid]
noobj_mask = targets['noobj_mask']
# Координатная потеря
pred_boxes = predictions[..., :4]
target_boxes = targets['boxes']
coord_loss = self.mse(
pred_boxes[obj_mask],
target_boxes[obj_mask]
)
# Confidence потеря
pred_conf = torch.sigmoid(predictions[..., 4])
target_conf = targets['confidence']
obj_conf_loss = self.bce(
pred_conf[obj_mask],
target_conf[obj_mask]
)
noobj_conf_loss = self.bce(
pred_conf[noobj_mask],
target_conf[noobj_mask]
)
# Классовая потеря
pred_cls = predictions[..., 5:]
target_cls = targets['class_labels']
cls_loss = self.ce(
pred_cls[obj_mask].view(-1, self.num_classes),
target_cls[obj_mask].view(-1)
)
# Суммируем с коэффициентами
total_loss = (
self.lambda_coord * coord_loss +
obj_conf_loss +
self.lambda_noobj * noobj_conf_loss +
cls_loss
) / batch_size
return total_loss
YOLO9000: когда 9000 классов - это нормально
YOLO9000 - это YOLOv2, обученный на комбинированном датасете из COCO и ImageNet. Но здесь есть хитрость: не все классы имеют bounding boxes.
Решение: иерархическая классификация. Создаем дерево WordNet, где узлы - это синонимы. Если в датасете нет bounding box для "сиамской кошки", но есть для "кошки", модель может использовать иерархическую информацию.
| Особенность | YOLOv2 | YOLO9000 |
|---|---|---|
| Классы | 20 (PASCAL VOC) | 9418 |
| Данные | Только детекция | Детекция + классификация |
| Обучение | Joint training | Hierarchical training |
| mAP на VOC | 76.8% | 78.6% |
Многомасштабное обучение: один размер для всех
YOLOv2 вводит трюк с изменением размера входного изображения каждые 10 батчей. Сеть учится работать с разными разрешениями - от 320x320 до 608x608.
Почему это работает? Потому что сверточные сети по своей природе масштабно-инвариантны. Но полносвязные слои (которых в YOLOv2 нет) требовали фиксированного размера входа.
class MultiScaleTraining:
"""Многомасштабное обучение для YOLOv2"""
def __init__(self, min_size=320, max_size=608, step=32):
self.min_size = min_size
self.max_size = max_size
self.step = step
self.current_epoch = 0
def get_random_size(self):
"""Возвращает случайный размер из сетки"""
sizes = list(range(self.min_size, self.max_size + 1, self.step))
return np.random.choice(sizes)
def resize_batch(self, images, bboxes, new_size):
"""Изменяет размер батча и обновляет bounding boxes"""
batch_size, _, h_orig, w_orig = images.shape
# Ресайз изображений
resized_images = F.interpolate(
images, size=(new_size, new_size),
mode='bilinear', align_corners=False
)
# Масштабируем bounding boxes
scale_x = new_size / w_orig
scale_y = new_size / h_orig
bboxes[..., [0, 2]] *= scale_x # x координаты
bboxes[..., [1, 3]] *= scale_y # y координаты
return resized_images, bboxes
Почему YOLOv2 все еще актуален в 2026 году
Казалось бы, зачем разбирать архитектуру 2016 года, когда есть YOLOv10 и другие современные модели? Вот несколько причин:
- Понятность: YOLOv2 достаточно прост, чтобы его можно было реализовать с нуля за день
- Фундаментальные идеи: Anchor boxes, BatchNorm, многомасштабное обучение - все это до сих пор используется
- Образовательная ценность: Если понимаешь YOLOv2, современные архитектуры кажутся эволюцией, а не магией
- Ресурсоэффективность: Darknet-19 до сих пор работает на слабом железе
Если вы работаете с промышленными задачами компьютерного зрения, рекомендую почитать мою статью про выжимание максимума из YOLO для промышленного CV. Там много практических советов, которые применимы и к YOLOv2.
Распространенные ошибки при реализации
За 8 лет работы с YOLO я видел одни и те же ошибки снова и снова:
Ошибка 1: Неправильная инициализация anchor boxes. Если взять случайные anchors, модель может никогда не сойтись.
Ошибка 2: Забыть про model.eval() при инференсе. BatchNorm в режиме обучения использует статистику батча, а не скользящее среднее.
Ошибка 3: Неправильное масштабирование координат. Все координаты должны быть в диапазоне [0, 1] относительно размера изображения.
Ошибка 4: Слишком высокий learning rate. YOLO чувствителен к learning rate. Начинайте с 1e-3, используйте cosine annealing.
Собираем все вместе: полный пайплайн обучения
Вот минимальный рабочий пример обучения YOLOv2 на кастомном датасете:
def train_yolov2(config):
"""Полный пайплайн обучения YOLOv2"""
# 1. Загрузка данных
train_dataset = YourDataset(config.train_path)
val_dataset = YourDataset(config.val_path)
train_loader = DataLoader(train_dataset, batch_size=config.batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=config.batch_size)
# 2. Инициализация модели
anchors = get_anchors_from_dataset(train_dataset)
model = YOLOv2(
num_anchors=config.num_anchors,
num_classes=config.num_classes,
anchors=anchors
).to(config.device)
# 3. Функция потерь и оптимизатор
criterion = YOLOv2Loss(
num_anchors=config.num_anchors,
num_classes=config.num_classes
)
optimizer = torch.optim.Adam(
model.parameters(),
lr=config.lr,
weight_decay=config.weight_decay
)
# 4. Многомасштабное обучение
multi_scale = MultiScaleTraining()
# 5. Цикл обучения
for epoch in range(config.epochs):
model.train()
# Изменяем размер каждые 10 батчей
if epoch % 10 == 0:
new_size = multi_scale.get_random_size()
print(f"Changing input size to {new_size}x{new_size}")
for batch_idx, (images, targets) in enumerate(train_loader):
images = images.to(config.device)
# Подготовка targets для YOLO
yolo_targets = prepare_yolo_targets(
targets, anchors,
grid_size=model.grid_size
)
# Forward pass
predictions = model(images)
loss = criterion(predictions, yolo_targets)
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Логирование
if batch_idx % 100 == 0:
print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")
# Валидация
if epoch % 5 == 0:
validate(model, val_loader, config.device)
return model
Что дальше после YOLOv2?
YOLOv2 задал направление, но не был конечной точкой. В YOLOv3 появились:
- Трехуровневая пирамида признаков (FPN)
- Residual connections
- Binary cross-entropy для классификации вместо softmax
Современные YOLO (v8, v10) ушли еще дальше, но core идеи остались. Если вы хотите глубоко понять современные детекторы, начинайте с YOLOv2. Это как учить алгебру перед матанализом.
Для тех, кто хочет копнуть еще глубже в настройку гиперпараметров, рекомендую мою статью про YOLO-эксперименты без иллюзий. Там разобраны все тонкости обучения современных детекторов.
Главный урок YOLOv2: иногда лучшие решения - это не новые изобретения, а грамотная комбинация существующих техник. BatchNorm взяли у Ioffe & Szegedy, anchor boxes у Faster R-CNN, Darknet - собственная разработка. Смешали, оптимизировали, получили результат.
Попробуйте реализовать YOLOv2 с нуля. Не копируйте готовый код, напишите свой. Когда вы сами пройдете через все грабли (неправильные anchors, забытый model.eval(), путаницу с координатами), вы поймете детекцию на уровне, недоступном при использовании готовых библиотек.