24 KiB
Заморозка, частичная заморозка и стратегии 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 (1–2 верхние стадии) + сильная регуляризация/аугментации. LP-FT. |
| Средний (10²–10⁴) | Partial freeze (последние 1–2 стадии) с discriminative lr | Partial freeze (2–3 стадии) либо 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 продолжают дрейфовать под целевой батч. Это:
- меняет распределение активаций, на котором обучалась голова → «заморозка» лишь частичная и нестабильная;
- на малых батчах целевого домена статистика шумная → деградация;
- рассинхрон 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 общих признаков. Типичная схема по эпохам:
эпоха 0–2: обучаем только голову (backbone frozen)
эпоха 3–5: + последняя стадия 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.). Поэтому:
- сначала warm-up головы при замороженном backbone (= фаза linear probing в LP-FT);
- затем разморозка нижних слоёв с малым 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.05–0.1% | Ben-Zaken et al., 2021 |
| Adapters | вставные bottleneck-MLP внутри блоков | ~0.5–5% | 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 |
Источники
- Kumar, Raghunathan, Jones, Ma, Liang. Fine-Tuning can Distort Pretrained Features and Underperform Out-of-Distribution. ICLR 2022
- Howard, Ruder. Universal Language Model Fine-tuning for Text Classification (ULMFiT). ACL 2018
- Ben-Zaken, Goldberg, Ravfogel. BitFit: Simple Parameter-efficient Fine-tuning. 2021
- Houlsby et al. Parameter-Efficient Transfer Learning for NLP (Adapters). ICML 2019
- Hu et al. LoRA: Low-Rank Adaptation of Large Language Models. 2021
- Jia et al. Visual Prompt Tuning (VPT). ECCV 2022
- Yin et al. 5%>100%: Breaking Performance Shackles of Full Fine-Tuning on Visual Recognition Tasks (Mona). CVPR 2025
- Lialin, Deshpande, Rumshisky. Scaling Down to Scale Up: A Guide to Parameter-Efficient Fine-Tuning. 2023
- Wu, Johnson. Rethinking "Batch" in BatchNorm. 2021
- PyTorch Forums: Should I use model.eval() when I freeze BatchNorm layers to finetune?
- PyTorch Forums: Frozen BatchNorm still differs train vs eval
- CS231n: Transfer Learning
- Optuna documentation