Фаза 04 · урок 05

Перенос обучения и тонкая настройка

Цель урока: Обучение ResNet-50 на ImageNet требует примерно 2 000 GPU-часов. Очень немногие команды располагают таким бюджетом для каждой выпускаемой задачи. На практике почти все команды поставляют предобученный backbone с новой головой, обученной…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Компьютерное зрение
Чтение
14 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Извлечение признаков и тонкая настройка
  5. Почему замораживание вообще работает
  6. Дискриминационные скорости обучения
  7. Проблема BatchNorm
  8. Проектирование головы
  9. Послойное затухание LR
  10. Что оценивать
  11. Соберите сами
  12. Шаг 1: Загрузите предобученный backbone и изучите его
  13. Шаг 2: Извлечение признаков — заморозьте всё и замените голову
  14. Шаг 3: Дискриминационная тонкая настройка
  15. Шаг 4: Работа с BatchNorm
  16. Шаг 5: Минимальный сквозной цикл тонкой настройки
  17. Шаг 6: Постепенное размораживание
  18. Используйте
  19. Подготовьте к поставке
  20. Упражнения
  21. Ключевые термины
  22. Дополнительные материалы

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

Тип: Сборка Языки: Python Предварительные требования: Фаза 04, урок 03 (CNN), фаза 04, урок 04 (Классификация изображений) Время: ~75 минут

Цели обучения

  • Различать извлечение признаков и тонкую настройку (fine-tuning) и выбирать подходящий режим по размеру набора данных, расстоянию между доменами и вычислительному бюджету
  • Загрузить предобученный backbone, заменить его классификационную голову и обучить только голову до рабочего базового уровня менее чем за 20 строк
  • Постепенно размораживать слои, используя дискриминационные скорости обучения, чтобы ранние универсальные признаки получали меньшие обновления, чем поздние признаки, специфичные для задачи
  • Диагностировать три типичные проблемы: дрейф признаков из-за слишком высокой LR на размороженных блоках, разрушение статистик BN на малых наборах данных и катастрофическое забывание

Проблема

Обучение ResNet-50 на ImageNet требует примерно 2 000 GPU-часов. Очень немногие команды располагают таким бюджетом для каждой выпускаемой задачи. На практике почти все команды поставляют предобученный backbone с новой головой, обученной на нескольких сотнях или тысячах изображений, специфичных для задачи.

Это не срезание углов. Первый свёрточный блок любой CNN, обученной на ImageNet, изучает края и фильтры, подобные Габоровским. Следующие несколько блоков изучают текстуры и простые мотивы. Средние блоки изучают части объектов. Финальные блоки изучают их сочетания, начинающие напоминать 1 000 категорий ImageNet. Первые 90 % этой иерархии почти без изменений переносятся на медицинскую визуализацию, промышленную инспекцию, спутниковые данные и любую другую задачу компьютерного зрения — потому что у природы ограниченный словарь краёв и текстур. Последние 10 % вы и обучаете.

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

Концепция

Извлечение признаков и тонкая настройка

Есть два режима; выбор определяется тем, насколько вы доверяете предобученным признакам и каким объёмом данных располагаете.

Диаграмма к уроку «Перенос обучения и тонкая настройка»

Практические правила:

Размер набора данных Расстояние между доменами Рецепт
< 1k изображений близок к ImageNet Заморозьте backbone, обучайте только голову
1k–10k близок Заморозьте первые 2–3 стадии, тонко настройте остальные
10k–100k любой Тонко настройте всю модель с дискриминационной LR
100k+ далёк Тонко настройте всё; при достаточно далёком домене рассмотрите обучение с нуля

«Близкий к ImageNet» приблизительно означает естественные RGB-фотографии с содержимым, похожим на объекты. Медицинские КТ-снимки, спутниковые изображения сверху и микроскопия — далёкие домены: признаки всё ещё помогают, но вам придётся дать адаптироваться большему числу слоёв.

Почему замораживание вообще работает

Признаки ImageNet, которые изучает CNN, не специализированы для 1 000 категорий. Они специализированы для статистики естественных изображений: краёв определённых ориентаций, текстур, паттернов контраста, примитивов формы. Эта статистика стабильна почти во всех визуальных доменах, которые может назвать человек. Поэтому модель, обученная на ImageNet и оценённая zero-shot на CIFAR-10 лишь с новой линейной головой (без тонкой настройки backbone), достигает точности более 80 %. Голова учится тому, какие из уже изученных признаков взвешивать для этой задачи.

Дискриминационные скорости обучения

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

Typical recipe:

  stage 0 (stem + first group): lr = base_lr / 100    (mostly fixed)
  stage 1:                       lr = base_lr / 10
  stage 2:                       lr = base_lr / 3
  stage 3 (last backbone group): lr = base_lr
  head:                          lr = base_lr  (or slightly higher)

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

Проблема BatchNorm

Слои BN хранят буферы running_mean и running_var, вычисленные на ImageNet. Если в вашей задаче иное распределение пикселей — другое освещение, датчик или цветовое пространство, — эти буферы ошибочны. Есть три варианта в порядке предпочтительности:

  1. Тонкая настройка с BN в режиме train. Дайте BN обновлять скользящие статистики вместе со всеми остальными параметрами. Это вариант по умолчанию для наборов данных среднего размера (>= 5k примеров).
  2. Заморозка BN в режиме eval. Сохраните статистики ImageNet и обучайте только веса. Это правильно, когда набор данных настолько мал, что скользящее среднее BN будет шумным.
  3. Замена BN на GroupNorm. Полностью устраняет проблему скользящего среднего. Этот подход используется в backbone-сетях для детекции и сегментации, где размер батча на GPU мал.

Ошибка в этом выборе незаметно снижает точность на 5–15 %.

Проектирование головы

Классификационная голова — это 1–3 линейных слоя и необязательный dropout. Каждый backbone из torchvision поставляется с головой по умолчанию, которую вы заменяете:

backbone.fc = nn.Linear(backbone.fc.in_features, num_classes)          # ResNet
backbone.classifier[1] = nn.Linear(..., num_classes)                    # EfficientNet, MobileNet
backbone.heads.head = nn.Linear(..., num_classes)                       # torchvision ViT

Для небольших наборов данных обычно достаточно одного линейного слоя. Добавление скрытого слоя (Linear -> ReLU -> Dropout -> Linear) помогает, когда распределение задачи сильнее отличается от распределения, на котором обучался backbone.

Послойное затухание LR

Это более плавная версия дискриминационной LR, используемая в современной тонкой настройке (BEiT, DINOv2, тонкая настройка ViT-B). Вместо группировки слоёв по стадиям каждому слою назначается LR, немного меньшая, чем у следующего слоя.

lr_layer_k = base_lr * decay^(L - k)

При decay = 0.75 и L = 12 блоках трансформера первый блок обучается с 0.75^11 ≈ 0.04x от LR головы. Это важнее для тонкой настройки трансформеров, чем CNN, для которых обычно достаточно LR, сгруппированных по стадиям.

Что оценивать

В экспериментах с переносом обучения нужны два показателя, которые вы не отслеживали бы при обучении с нуля:

  • Точность только предобученной модели — точность головы при замороженном backbone. Это ваш нижний предел.
  • Точность после тонкой настройки — точность той же модели после сквозного обучения. Это ваш верхний предел.

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

Соберите сами

Шаг 1: Загрузите предобученный backbone и изучите его

import torch
import torch.nn as nn
from torchvision.models import resnet18, ResNet18_Weights

backbone = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
print(backbone)
print()
print("classifier head:", backbone.fc)
print("feature dim:", backbone.fc.in_features)

У ResNet18 четыре стадии (layer1..layer4), а также stem и голова fc. У каждого backbone для классификации в torchvision есть аналогичная структура.

Шаг 2: Извлечение признаков — заморозьте всё и замените голову

def make_feature_extractor(num_classes=10):
    model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for p in model.parameters():
        p.requires_grad = False
    model.fc = nn.Linear(model.fc.in_features, num_classes)
    return model

model = make_feature_extractor(num_classes=10)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in model.parameters() if not p.requires_grad)
print(f"trainable: {trainable:>10,}")
print(f"frozen:    {frozen:>10,}")

Обучаемой остаётся только model.fc. Backbone — замороженный экстрактор признаков.

Шаг 3: Дискриминационная тонкая настройка

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

def discriminative_param_groups(model, base_lr=1e-3, decay=0.3):
    stages = [
        ["conv1", "bn1"],
        ["layer1"],
        ["layer2"],
        ["layer3"],
        ["layer4"],
        ["fc"],
    ]
    groups = []
    for i, names in enumerate(stages):
        lr = base_lr * (decay ** (len(stages) - 1 - i))
        params = [p for n, p in model.named_parameters()
                  if any(n.startswith(k) for k in names)]
        if params:
            groups.append({"params": params, "lr": lr, "name": "_".join(names)})
    return groups

model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, 10)
for p in model.parameters():
    p.requires_grad = True

groups = discriminative_param_groups(model)
for g in groups:
    print(f"{g['name']:>10s}  lr={g['lr']:.2e}  params={sum(p.numel() for p in g['params']):>8,}")

decay=0.3 означает, что каждая стадия обучается с 30 % скорости следующей. fc получает base_lr, layer4 получает 0.3 * base_lr, а conv1 получает 0.3^5 * base_lr ≈ 0.00243 * base_lr. Это звучит экстремально, но эмпирически работает.

Шаг 4: Работа с BatchNorm

Вспомогательная функция для замораживания скользящих статистик BN без замораживания её весов.

def freeze_bn_stats(model):
    for m in model.modules():
        if isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)):
            m.eval()
            for p in m.parameters():
                p.requires_grad = False
    return model

Вызывайте её после model.train() в начале каждой эпохи. model.train() переключает всё в режим обучения; эта функция отменяет переключение только для слоёв BN.

Шаг 5: Минимальный сквозной цикл тонкой настройки

from torch.optim import SGD
from torch.utils.data import DataLoader
from torch.optim.lr_scheduler import CosineAnnealingLR
import torch.nn.functional as F

def fine_tune(model, train_loader, val_loader, device, epochs=5, base_lr=1e-3, freeze_bn=False):
    model = model.to(device)
    groups = discriminative_param_groups(model, base_lr=base_lr)
    optimizer = SGD(groups, momentum=0.9, weight_decay=1e-4, nesterov=True)
    scheduler = CosineAnnealingLR(optimizer, T_max=epochs)

    for epoch in range(epochs):
        model.train()
        if freeze_bn:
            freeze_bn_stats(model)
        tr_loss, tr_correct, tr_total = 0.0, 0, 0
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)
            logits = model(x)
            loss = F.cross_entropy(logits, y, label_smoothing=0.1)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            tr_loss += loss.item() * x.size(0)
            tr_total += x.size(0)
            tr_correct += (logits.argmax(-1) == y).sum().item()
        scheduler.step()

        model.eval()
        va_total, va_correct = 0, 0
        with torch.no_grad():
            for x, y in val_loader:
                x, y = x.to(device), y.to(device)
                pred = model(x).argmax(-1)
                va_total += x.size(0)
                va_correct += (pred == y).sum().item()
        print(f"epoch {epoch}  train {tr_loss/tr_total:.3f}/{tr_correct/tr_total:.3f}  "
              f"val {va_correct/va_total:.3f}")
    return model

Пять эпох по этому рецепту на CIFAR-10 поднимают ResNet18-IMAGENET1K_V1 примерно с 70 % точности zero-shot линейного зонда до 93 % после тонкой настройки. Одна голова остановилась бы примерно на 86 %, ни разу не изменив backbone.

Шаг 6: Постепенное размораживание

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

def progressive_unfreeze_schedule(model):
    stages = ["layer4", "layer3", "layer2", "layer1"]
    yielded = set()

    def start():
        for p in model.parameters():
            p.requires_grad = False
        for p in model.fc.parameters():
            p.requires_grad = True

    def unfreeze(epoch):
        if epoch < len(stages):
            name = stages[epoch]
            yielded.add(name)
            for n, p in model.named_parameters():
                if n.startswith(name):
                    p.requires_grad = True
            return name
        return None

    return start, unfreeze

Вызовите start() один раз перед первой эпохой. Вызывайте unfreeze(epoch) в начале каждой эпохи. Пересобирайте оптимизатор всякий раз, когда меняется набор обучаемых параметров, иначе замороженные параметры сохранят кэшированные моменты, которые его запутают.

Используйте

Для большинства реальных задач достаточно torchvision.models и трёх строк. Более тяжёлые механизмы выше нужны, когда вы сталкиваетесь с проблемами, которые настройки библиотек по умолчанию не исправляют.

from torchvision.models import resnet50, ResNet50_Weights

model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)
model.fc = nn.Linear(model.fc.in_features, num_classes)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4)

Ещё два подходящих для продакшена варианта по умолчанию:

  • timm поставляет около 800 предобученных backbone-сетей компьютерного зрения с единообразным API (timm.create_model("resnet50", pretrained=True, num_classes=10)). Для любой тонкой настройки за пределами зоопарка torchvision это стандартный выбор.
  • Для трансформеров transformers.AutoModelForImageClassification.from_pretrained(name, num_labels=N) предоставляет ViT / BEiT / DeiT с той же семантикой загрузки, что и у текстовых моделей.

Подготовьте к поставке

Этот урок создаёт:

  • outputs/prompt-fine-tune-planner.md — промпт, выбирающий извлечение признаков, постепенную или сквозную тонкую настройку по размеру набора данных, расстоянию между доменами и вычислительному бюджету.
  • outputs/skill-freeze-inspector.md — навык, который по модели PyTorch сообщает, какие параметры обучаемы, какие слои BatchNorm находятся в режиме eval и действительно ли оптимизатор получает обучаемые параметры.

Упражнения

  1. (Легко) Обучите ResNet18 как линейный зонд (backbone заморожен) и как модель с полной тонкой настройкой на одном и том же синтетическом наборе CIFAR. Выведите обе точности рядом. Объясните, какой разрыв говорит о хорошем переносе признаков, а какой — о плохом.
  2. (Средне) Намеренно внесите ошибку: задайте для стадии backbone base_lr = 1e-1 вместо головы. Покажите, как взрывается обучающая потеря, затем восстановите обучение с помощью discriminative_param_groups. Запишите LR, при которой каждая стадия начинает расходиться.
  3. (Сложно) Возьмите набор данных медицинских изображений (например, CheXpert-small, PatchCamelyon или HAM10000) и сравните три режима: (a) предобученный на ImageNet замороженный backbone + линейная голова; (b) предобученная на ImageNet сквозная тонкая настройка; (c) обучение с нуля. Сообщите точность и вычислительную стоимость каждого. При каком размере набора данных обучение с нуля становится конкурентоспособным?

Ключевые термины

Термин Что обычно говорят Что это действительно означает
Извлечение признаков «Заморозить и обучить голову» Параметры backbone заморожены, градиент получает только новая классификационная голова
Тонкая настройка «Переобучить сквозным образом» Все параметры обучаемы, обычно с намного меньшей LR, чем при обучении с нуля
Дискриминационная LR «Меньшая LR для ранних слоёв» Группы параметров оптимизатора, в которых LR ранних стадий составляет долю от LR поздних стадий
Послойное затухание LR «Плавный градиент LR» LR каждого слоя умножается на decay^(L - k); обычно используется при тонкой настройке трансформеров
Катастрофическое забывание «Модель потеряла ImageNet» Слишком высокая LR перезаписывает предобученные признаки до того, как будет усвоен сигнал новой задачи
Дрейф статистик BN «Скользящее среднее неверно» running_mean/var BatchNorm вычислены на распределении, отличном от текущей задачи, и незаметно ухудшают точность
Линейный зонд «Замороженный backbone + линейная голова» Оценка предобученных признаков — точность лучшего линейного классификатора поверх замороженного представления
Катастрофический коллапс «Всё предсказывает один класс» Возникает, когда LR при тонкой настройке достаточно высока, чтобы уничтожить признаки до стабилизации градиентов от головы

Дополнительные материалы


Источник: Transfer Learning & Fine-Tuning 04.04 — Классификация изображений · Фаза 04 — Компьютерное зрение · 04.06 — Обнаружение объектов: YOLO с нуля · Полный каталог