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

269 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Заморозка, частичная заморозка и стратегии 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 (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`
Заморозка параметра = отключение накопления градиента:
```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** общих признаков. Типичная схема по эпохам:
```
эпоха 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](https://arxiv.org/pdf/2303.15647).
| Метод | Что обучается | Доля параметров | Ссылка |
|-------|---------------|------------------|--------|
| **BitFit** | только bias-термы (+ голова) | ~0.050.1% | [Ben-Zaken et al., 2021](https://huggingface.co/papers/2106.10199) |
| **Adapters** | вставные bottleneck-MLP внутри блоков | ~0.55% | [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/)