YOLOv2 и YOLO9000: архитектура, anchor boxes, реализация на PyTorch | AiManual
AiManual Logo Ai / Manual.
08 Фев 2026 Гайд

YOLOv2/YOLO9000: полный разбор архитектуры и реализация с нуля на PyTorch (Batch Norm, Anchor Boxes)

Полный разбор YOLOv2 и YOLO9000: от Batch Normalization до anchor boxes. Реализация с нуля на PyTorch с пошаговым кодом и объяснением всех архитектурных решений

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 - это заранее определенные bounding boxes разных размеров и соотношений сторон. Сеть учится не "изобретать" размеры с нуля, а корректировать готовые шаблоны.

Как выбрать хорошие 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 это делается так:

  1. Убираем последний сверточный слой (1000 классов ImageNet)
  2. Добавляем три сверточных слоя 3x3 с 1024 фильтрами
  3. Добавляем финальный слой 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
    }
💡
Сигмоида на tx и ty гарантирует, что центр бокса остается в пределах ячейки сетки. Это стабилизирует обучение на ранних этапах.

Функция потерь YOLOv2: что изменилось

По сравнению с YOLOv1, функция потерь стала проще и стабильнее. Основные компоненты:

  1. Координатная потеря (MSE для центров, MSE для ширины/высоты)
  2. Confidence потеря (binary cross-entropy)
  3. Классовая потеря (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 и другие современные модели? Вот несколько причин:

  1. Понятность: YOLOv2 достаточно прост, чтобы его можно было реализовать с нуля за день
  2. Фундаментальные идеи: Anchor boxes, BatchNorm, многомасштабное обучение - все это до сих пор используется
  3. Образовательная ценность: Если понимаешь YOLOv2, современные архитектуры кажутся эволюцией, а не магией
  4. Ресурсоэффективность: 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(), путаницу с координатами), вы поймете детекцию на уровне, недоступном при использовании готовых библиотек.