Files
PracticeClassif/_research/SECTION_freezing.md
2026-06-30 15:17:40 +03:00

24 KiB
Raw Permalink Blame History

Заморозка, частичная заморозка и стратегии fine-tuning при transfer learning

Раздел методички. Научный руководитель — мнс Павленко Б.В. Контекст: подбор глубины заморозки backbone (EdgeNeXt) как гиперпараметра HPO.

1. Три режима адаптации: full FT / feature extraction / partial freeze

При переносе предобученной сети (backbone f_\theta + голова g_\phi) на целевую задачу существует континуум стратегий — от «обучаем только голову» до «обучаем всё». Граничные и промежуточные точки:

Режим Что обучается Trainable params Риск переобучения Сохранение pretrained features
Linear probing (feature extraction) только голова g_\phi; backbone заморожен минимум (доли %) низкий максимальное (backbone не трогаем)
Partial freeze голова + последние k стадий backbone средний средний частичное (нижние слои фиксированы)
Full fine-tuning все параметры \theta, \phi 100% высокий (на малых данных) минимальное (всё дрейфует)
LP-FT (linear probe → fine-tune) сначала голова, затем всё с малым lr 100% (на 2-й фазе) средний высокое в ID, существенно лучше OOD

Ключевой нюанс — full FT не всегда лучше. В работе Kumar et al., ICLR 2022 «Fine-Tuning can Distort Pretrained Features…» показано: при хороших предобученных признаках и большом сдвиге домена полный fine-tuning искажает нижние слои (feature distortion), пока голова ещё «недоучена» с случайной инициализации. На 10 датасетах с distribution shift full FT давал в среднем +2% in-distribution (ID), но 7% out-of-distribution (OOD) против linear probing. Рекомендованный фикс — LP-FT: сначала linear probing (голова сходится к разумному решению), затем full FT с малым lr; даёт +1% ID и +10% OOD относительно чистого full FT.

Что когда выбирать: размер данных × близость домена

Классическое правило (CS231n Transfer Learning, cs231n.github.io/transfer-learning) расширенное с учётом Kumar et al.:

Размер датасета \ Домен Близкий к источнику (ImageNet-подобный) Далёкий от источника (спутник/UAV/медицина)
Малый (сотни–тысячи) Linear probing / разморозить 1 последнюю стадию. Full FT переобучится. Опасная зона. Partial freeze (12 верхние стадии) + сильная регуляризация/аугментации. LP-FT.
Средний (10²10⁴) Partial freeze (последние 12 стадии) с discriminative lr Partial freeze (23 стадии) либо LP-FT; следить за OOD-метрикой
Большой (10⁵+) Full FT (можно с layer-wise lr decay) Full FT — данных хватает, чтобы переучить нижние слои без распада признаков

Интуиция: чем меньше данных — тем больше слоёв замораживаем (меньше степеней свободы → меньше переобучение); чем дальше домен — тем глубже придётся разморозить (нижние/средние фичи источника плохо переносятся), но компенсируем регуляризацией. Граничные случаи лучше решать через HPO глубины заморозки (§5).

2. Механика заморозки во фреймворке (PyTorch)

requires_grad = False

Заморозка параметра = отключение накопления градиента:

for p in model.backbone.parameters():
    p.requires_grad = False   # градиент не считается, .grad остаётся None

При этом autograd не строит обратный путь к этим тензорам — экономия памяти и времени backward.

Исключение замороженных параметров из optimizer

Optimizer должен получать только обучаемые параметры. Иначе для замороженных весов всё равно могут применяться weight decay и моментум-обновления (особенно у AdamW: decoupled weight decay двигает веса даже при нулевом градиенте), что тихо ломает «заморозку».

optimizer = torch.optim.AdamW(
    (p for p in model.parameters() if p.requires_grad),  # ТОЛЬКО trainable
    lr=1e-3, weight_decay=1e-2,
)

model.eval() vs model.train() для замороженных модулей

requires_grad=False управляет градиентом весов, но НЕ управляет режимом модулей с состоянием — BatchNorm и Dropout. Их поведение определяется флагом .training (model.train() / model.eval()):

Модуль train() eval()
BatchNorm нормирует по статистике текущего батча; обновляет running_mean/running_var (EMA) нормирует по накопленной running_mean/var; статистику НЕ обновляет
LayerNorm по статистике текущего сэмпла (нет running stats) то же — режим не меняет поведение
Dropout активен (зануляет случайные активации) отключён (identity)

Почему важно не обновлять BN-статистику замороженных слоёв

Это самая частая и коварная ошибка. Если backbone заморожен через requires_grad=False, но модель оставлена в model.train(), то веса BN (\gamma, \beta) не обучаются, НО running_mean/running_var продолжают дрейфовать под целевой батч. Это:

  1. меняет распределение активаций, на котором обучалась голова → «заморозка» лишь частичная и нестабильная;
  2. на малых батчах целевого домена статистика шумная → деградация;
  3. рассинхрон train/eval: на инференсе используется уже «уехавшая» running-статистика.

Из обсуждений PyTorch Forums «Should I use model.eval() when I freeze BatchNorm» и «Frozen BN still differs train vs eval»: чтобы по-настоящему заморозить BN, недостаточно requires_grad=False — нужно перевести BN-модули в .eval(). Это и есть концепция FrozenBN (constant affine transform с фиксированной population-статистикой), широко применяемая в detection (Detectron2, mmdetection) при переносе backbone.

Замечание: «Rethinking Batch in BatchNorm», Wu & Johnson, 2021 показывает обратное для полного fine-tuning при достатке данных — там НЕ замораживать BN-статистику обычно лучше. То есть правило «BN в eval» относится именно к замороженным слоям, а не ко всем сценариям.

LayerNorm-сети (ViT, ConvNeXt, EdgeNeXt) этой ловушки лишены: LN не хранит running stats, его поведение одинаково в train/eval. Поэтому для EdgeNeXt главный риск BN отсутствует — но если в гибридной голове/стеме встречается BN, правило выше остаётся в силе.

3. Постепенная разморозка и layer-wise learning rates

Эти приёмы введены в ULMFiT, Howard & Ruder, ACL 2018 и стали стандартом transfer learning.

Gradual / progressive unfreezing

Размораживать не всё сразу, а по одной стадии, начиная с верхних (ближе к голове) слоёв. Нижние слои дольше остаются замороженными, что предотвращает catastrophic forgetting общих признаков. Типичная схема по эпохам:

эпоха 02: обучаем только голову (backbone frozen)
эпоха 35: + последняя стадия backbone
эпоха 6+ : + предпоследняя стадия ...

Discriminative / layer-wise learning rates

Разные слои несут разную информацию: нижние — общие низкоуровневые фичи (их менять рискованно), верхние — task-specific. Поэтому lr убывает от головы к стему. Геометрическое затухание (layer-wise LR decay, LLRD):


\eta_\ell = \eta_{\text{base}} \cdot \xi^{\,(L-\ell)}, \qquad \xi \in (0, 1]

где \ell — индекс слоя/стадии (0 — стем, L — голова), \xi — decay factor (типично $0.6$0.9). Голова получает \eta_{\text{base}}, стем — наименьший lr. В ULMFiT правило проще: \eta_{\ell-1} = \eta_\ell / 2.6. LLRD — стандарт для тонкой настройки BERT/ViT.

Warm-up головы перед разморозкой

Случайно инициализированная голова в начале даёт большие, «шумные» градиенты, которые при немедленном full FT распространяются вниз и искажают pretrained-фичи (тот самый механизм из Kumar et al.). Поэтому:

  1. сначала warm-up головы при замороженном backbone (= фаза linear probing в LP-FT);
  2. затем разморозка нижних слоёв с малым lr.

Это совмещает gradual unfreezing, LP-FT и discriminative lr в единую практику.

4. Связь с PEFT — «средний путь» между заморозкой и full FT

PEFT (Parameter-Efficient Fine-Tuning) замораживает backbone полностью, но добавляет/обучает крошечное число параметров — компромисс между жёсткой заморозкой (мало ёмкости адаптации) и full FT (переобучение + дорого). Обзор: «Scaling Down to Scale Up», Lialin et al., 2023.

Метод Что обучается Доля параметров Ссылка
BitFit только bias-термы (+ голова) ~0.050.1% Ben-Zaken et al., 2021
Adapters вставные bottleneck-MLP внутри блоков ~0.55% Houlsby et al., ICML 2019
LoRA низкоранговые \Delta W = BA, добавляемые к замороженным матрицам ≪1% (до 10000× меньше) Hu et al., 2021
VPT обучаемые prompt-токены на входе ViT, backbone заморожен <1% Jia et al., ECCV 2022
Mona vision-friendly adapter (мульти-масштабные фильтры + scaled norm) <5% Yin et al., CVPR 2025

Замечания:

  • LoRA: W_{\text{eff}} = W_0 + \frac{\alpha}{r} B A, где W_0 заморожена, A\in\mathbb{R}^{r\times d}, B\in\mathbb{R}^{d\times r}, ранг r \ll d. На инференсе \Delta W сливается в W — нулевая задержка.
  • BitFit конкурентоспособен с full FT на малых/средних данных, но проседает на моделях >1B и больших датасетах.
  • Mona — единственный delta-tuning метод, превзошедший full FT на всех рассмотренных visual-задачах (классификация, детекция, сегментация), обучая <5% параметров: ключ — vision-friendly фильтры вместо «языковых» линейных и scaled normalization внутри адаптера. Для CV-backbone (как EdgeNeXt) это наиболее релевантный PEFT.

Связь с темой раздела: PEFT — это «заморозка backbone + малая обучаемая надстройка». Глубина заморозки (§5) и выбор PEFT-модуля можно рассматривать в одном пространстве HPO как разные точки на оси «ёмкость адаптации vs риск переобучения».

5. Глубина заморозки как гиперпараметр

Идея: число размороженных стадий n_unfrozen ∈ {0,1,2,3,4} (для 4-стадийного backbone типа EdgeNeXt/ConvNeXt) — это категориальный/ординальный гиперпараметр, подбираемый наравне с lr, weight decay, аугментациями.

Способы параметризации:

Параметр Тип Диапазон Смысл
n_unfrozen int (ordinal) 0..4 сколько верхних стадий разморожено (0 = linear probing, 4 = full FT)
llrd_decay \xi float 0.5..0.95 затухание lr вглубь
head_lr log-uniform 1e-4..1e-2 lr головы
backbone_lr_mult log-uniform 1e-3..1.0 множитель lr backbone относительно головы
freeze_bn bool {T,F} переводить ли BN в eval (если BN присутствует)
warmup_head_epochs int 0..5 фаза LP перед разморозкой

Подбор через Optuna — каждая стадия как отдельная lr-группа:

import optuna

def objective(trial):
    n_unfrozen = trial.suggest_int("n_unfrozen", 0, 4)
    head_lr    = trial.suggest_float("head_lr", 1e-4, 1e-2, log=True)
    bb_mult    = trial.suggest_float("backbone_lr_mult", 1e-3, 1.0, log=True)
    xi         = trial.suggest_float("llrd_decay", 0.5, 0.95)
    freeze_bn  = trial.suggest_categorical("freeze_bn", [True, False])

    model = build_model()
    set_trainable_stages(model, n_unfrozen, freeze_bn=freeze_bn)
    pgroups = build_param_groups(model, head_lr, bb_mult, xi)
    opt = torch.optim.AdamW(pgroups, weight_decay=1e-2)

    val_metric = train_and_eval(model, opt)   # вернуть метрику на валидации (OOD!)
    return val_metric

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=40)

Рекомендация: валидационную метрику для HPO считать на домен-сдвинутой выборке (OOD-подобной), иначе HPO выберет full FT по ID-метрике и проиграет на проде (урок Kumar et al.). Для ускорения — optuna.pruners.MedianPruner + чекпойнт предобученного backbone.

6. Практический PyTorch-скелет

import torch
import torch.nn as nn

def set_trainable_stages(model, n_unfrozen: int, freeze_bn: bool = True):
    """Размораживает последние n_unfrozen стадий backbone + всю голову.

    n_unfrozen=0 -> linear probing; =len(stages) -> full FT.
    Предполагается backbone.stages = [stage0(стем-ближе) ... stageN(голова-ближе)].
    """
    stages = list(model.backbone.stages)
    n_frozen = len(stages) - n_unfrozen

    # 1) backbone: замораживаем нижние стадии, размораживаем верхние
    for i, stage in enumerate(stages):
        requires = i >= n_frozen
        for p in stage.parameters():
            p.requires_grad = requires

    # 2) голова всегда обучается
    for p in model.head.parameters():
        p.requires_grad = True

    # 3) корректная заморозка BatchNorm в ЗАМОРОЖЕННЫХ стадиях
    if freeze_bn:
        for i, stage in enumerate(stages):
            if i < n_frozen:                      # только замороженные стадии
                for m in stage.modules():
                    if isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d,
                                      nn.BatchNorm3d, nn.SyncBatchNorm)):
                        m.eval()                  # не обновлять running stats
                        m.momentum = 0            # доп. страховка (на случай train())
                        # m.weight/bias уже requires_grad=False из шага 1
    return model


def build_param_groups(model, head_lr: float, bb_mult: float, xi: float):
    """Discriminative LR: голова = head_lr, стадии backbone = head_lr*bb_mult*xi^depth."""
    groups = [{"params": [p for p in model.head.parameters()
                          if p.requires_grad], "lr": head_lr}]

    stages = list(model.backbone.stages)
    L = len(stages)
    for i, stage in enumerate(stages):
        params = [p for p in stage.parameters() if p.requires_grad]
        if not params:                # стадия заморожена -> пропускаем
            continue
        depth = L - i                 # чем глубже (ближе к стему) — тем меньше lr
        lr_i = head_lr * bb_mult * (xi ** depth)
        groups.append({"params": params, "lr": lr_i})
    return groups


# Важно: при каждом model.train() BN замороженных стадий снова уходит в train.
# Поэтому после .train() надо повторно фиксировать BN:
def freeze_bn_eval(module):
    for m in module.modules():
        if isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)):
            m.eval()

# использование в train-цикле:
# model.train()
# freeze_bn_eval(model.backbone.stages[:n_frozen])   # вернуть FrozenBN

Тонкость, которую часто упускают: model.train() в начале каждой эпохи рекурсивно переводит все подмодули (включая «замороженные» BN) обратно в train-режим. Поэтому FrozenBN нужно восстанавливать после каждого model.train(), либо переопределить model.train() так, чтобы он оставлял замороженные BN в eval (паттерн train()-override в Detectron2).

7. Типичные ошибки

Ошибка Симптом Как избежать
Забыли eval() для BN в замороженном backbone метрика «плывёт», train/eval расходятся, нестабильность на малых батчах set_trainable_stages(..., freeze_bn=True) + повторная фиксация после model.train()
Замороженные веса всё равно обновляются веса меняются несмотря на requires_grad=False передавать в optimizer только p.requires_grad; помнить про decoupled weight decay в AdamW
model.train() сбрасывает FrozenBN BN снова обновляет running stats каждую эпоху override train() или вызывать freeze_bn_eval() после каждого .train()
Один lr на всё нижние слои либо «недоучены», либо распадаются discriminative / layer-wise LR decay (§3)
Full FT на малых данных высокая train-, низкая val/OOD-метрика; feature distortion linear probing / partial freeze; LP-FT (Kumar et al., 2022)
Разморозили всё сразу со случайной головой большие шумные градиенты искажают pretrained-фичи warm-up головы (LP) → gradual unfreezing
HPO по ID-метрике на проде/OOD хуже, чем простой linear probe валидация на домен-сдвинутой выборке
Dropout/BN активны в feature extraction без необходимости несогласованность с инференсом осознанно выставлять режим модулей, а не только requires_grad

Источники