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