init commit

This commit is contained in:
2026-06-30 15:17:40 +03:00
commit e622129c30
8 changed files with 2588 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
# Заморозка, частичная заморозка и стратегии 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/)