269 lines
24 KiB
Markdown
269 lines
24 KiB
Markdown
# Заморозка, частичная заморозка и стратегии 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…»](https://arxiv.org/abs/2202.10054) показано: при хороших предобученных признаках и большом сдвиге домена полный 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](https://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`
|
||
|
||
Заморозка параметра = отключение накопления градиента:
|
||
|
||
```python
|
||
for p in model.backbone.parameters():
|
||
p.requires_grad = False # градиент не считается, .grad остаётся None
|
||
```
|
||
|
||
При этом autograd не строит обратный путь к этим тензорам — экономия памяти и времени backward.
|
||
|
||
### Исключение замороженных параметров из optimizer
|
||
|
||
Optimizer должен получать **только обучаемые** параметры. Иначе для замороженных весов всё равно могут применяться weight decay и моментум-обновления (особенно у AdamW: decoupled weight decay двигает веса даже при нулевом градиенте), что тихо ломает «заморозку».
|
||
|
||
```python
|
||
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»](https://discuss.pytorch.org/t/should-i-use-model-eval-when-i-freeze-batchnorm-layers-to-finetune/39495) и [«Frozen BN still differs train vs eval»](https://discuss.pytorch.org/t/frozen-batch-normalization-still-shows-different-performance-in-model-train-from-model-eval/25176): чтобы по-настоящему заморозить BN, недостаточно `requires_grad=False` — нужно перевести BN-модули в `.eval()`. Это и есть концепция **FrozenBN** (constant affine transform с фиксированной population-статистикой), широко применяемая в detection (Detectron2, mmdetection) при переносе backbone.
|
||
|
||
Замечание: [«Rethinking Batch in BatchNorm», Wu & Johnson, 2021](https://arxiv.org/pdf/2105.07576) показывает обратное для **полного** 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](https://aclanthology.org/P18-1031.pdf) и стали стандартом 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.). Поэтому:
|
||
|
||
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](https://arxiv.org/pdf/2303.15647).
|
||
|
||
| Метод | Что обучается | Доля параметров | Ссылка |
|
||
|-------|---------------|------------------|--------|
|
||
| **BitFit** | только bias-термы (+ голова) | ~0.05–0.1% | [Ben-Zaken et al., 2021](https://huggingface.co/papers/2106.10199) |
|
||
| **Adapters** | вставные bottleneck-MLP внутри блоков | ~0.5–5% | [Houlsby et al., ICML 2019](https://arxiv.org/abs/1902.00751) |
|
||
| **LoRA** | низкоранговые $\Delta W = BA$, добавляемые к замороженным матрицам | ≪1% (до 10000× меньше) | [Hu et al., 2021](https://openreview.net/pdf?id=nZeVKeeFYf9) |
|
||
| **VPT** | обучаемые prompt-токены на входе ViT, backbone заморожен | <1% | [Jia et al., ECCV 2022](https://arxiv.org/pdf/2203.12119) |
|
||
| **Mona** | vision-friendly adapter (мульти-масштабные фильтры + scaled norm) | <5% | [Yin et al., CVPR 2025](https://arxiv.org/abs/2408.08345) |
|
||
|
||
Замечания:
|
||
- **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](https://optuna.readthedocs.io/) — каждая стадия как отдельная lr-группа:
|
||
|
||
```python
|
||
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-скелет
|
||
|
||
```python
|
||
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](https://arxiv.org/abs/2202.10054)) |
|
||
| Разморозили всё сразу со случайной головой | большие шумные градиенты искажают 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](https://arxiv.org/abs/2202.10054)
|
||
- [Howard, Ruder. Universal Language Model Fine-tuning for Text Classification (ULMFiT). ACL 2018](https://aclanthology.org/P18-1031.pdf)
|
||
- [Ben-Zaken, Goldberg, Ravfogel. BitFit: Simple Parameter-efficient Fine-tuning. 2021](https://huggingface.co/papers/2106.10199)
|
||
- [Houlsby et al. Parameter-Efficient Transfer Learning for NLP (Adapters). ICML 2019](https://arxiv.org/abs/1902.00751)
|
||
- [Hu et al. LoRA: Low-Rank Adaptation of Large Language Models. 2021](https://openreview.net/pdf?id=nZeVKeeFYf9)
|
||
- [Jia et al. Visual Prompt Tuning (VPT). ECCV 2022](https://arxiv.org/pdf/2203.12119)
|
||
- [Yin et al. 5%>100%: Breaking Performance Shackles of Full Fine-Tuning on Visual Recognition Tasks (Mona). CVPR 2025](https://arxiv.org/abs/2408.08345)
|
||
- [Lialin, Deshpande, Rumshisky. Scaling Down to Scale Up: A Guide to Parameter-Efficient Fine-Tuning. 2023](https://arxiv.org/pdf/2303.15647)
|
||
- [Wu, Johnson. Rethinking "Batch" in BatchNorm. 2021](https://arxiv.org/pdf/2105.07576)
|
||
- [PyTorch Forums: Should I use model.eval() when I freeze BatchNorm layers to finetune?](https://discuss.pytorch.org/t/should-i-use-model-eval-when-i-freeze-batchnorm-layers-to-finetune/39495)
|
||
- [PyTorch Forums: Frozen BatchNorm still differs train vs eval](https://discuss.pytorch.org/t/frozen-batch-normalization-still-shows-different-performance-in-model-train-from-model-eval/25176)
|
||
- [CS231n: Transfer Learning](https://cs231n.github.io/transfer-learning/)
|
||
- [Optuna documentation](https://optuna.readthedocs.io/)
|