Skip to content

Commit 9bb455d

Browse files
authored
fix(training): restore efficientnet-lite0 fine-tuning quality lost in timm swap (#16)
Switching the efficientnet-lite0 backbone to timm silently dropped three model-level defaults that the previous package applied, regressing FixMatch fine-tuning AUROC (~0.96 -> ~0.81 on the internal benchmark): - Classifier head over-scaling (main cause): timm initialises the head with TF-EfficientNet's 1/sqrt(fan_in+fan_out) scale tuned for 1000 classes, which is ~num_classes too large for the 2-class head (weight std ~0.4 vs PyTorch's ~0.016). The overconfident fresh head makes ~all unlabeled images clear the FixMatch p_cutoff from step 1 with random pseudo-labels, poisoning the backbone. Reset the classifier to PyTorch's default init after create_model. - Unapplied bn_momentum: cfg.bn_momentum (= 1 - ema_m, ~0.01) was computed and validated but never set on the model, so BatchNorm ran at timm's 0.1 default (~10x too fast for the small batch size). Apply it to both models' BatchNorm. - Unapplied seed: cfg.seed was defined and validated but set_seeds was never called, so training was not reproducible. Seed all RNGs after validation. Add a regression test asserting the classifier head weight std stays near PyTorch's Linear bound.
1 parent 1ce094a commit 9bb455d

3 files changed

Lines changed: 76 additions & 8 deletions

File tree

anomaly_match/pipeline/session.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from anomaly_match.utils.get_optimizer import get_optimizer
3737
from anomaly_match.utils.print_cfg import print_cfg
3838
from anomaly_match.utils.set_log_level import set_log_level
39+
from anomaly_match.utils.set_seeds import set_seeds
3940
from anomaly_match.utils.validate_config import validate_config
4041

4142

@@ -83,6 +84,11 @@ def __init__(self, cfg):
8384
# Validate the config
8485
validate_config(cfg)
8586

87+
# Seed all RNGs before datasets and model are built so that the train/test
88+
# split, weight initialisation and augmentation sampling are reproducible.
89+
# cfg.seed was defined and validated but never actually applied.
90+
set_seeds(int(cfg.seed))
91+
8692
self.cfg = cfg
8793
self.cached_image_normalisation_enum = cfg.normalisation.normalisation_method
8894
self.out = None # Initialize out attribute to None
@@ -116,6 +122,15 @@ def _init_model(self):
116122
session_tracker=self.session_tracker,
117123
)
118124

125+
# Apply the configured BatchNorm momentum (cfg.bn_momentum = 1 - ema_m, ~0.01).
126+
# It was computed and validated but never set on the model, so BatchNorm ran at
127+
# timm's 0.1 default (~10x too fast for our small batch size), destabilising the
128+
# running statistics during fine-tuning.
129+
for submodel in (self.model.train_model, self.model.eval_model):
130+
for module in submodel.modules():
131+
if isinstance(module, torch.nn.modules.batchnorm._BatchNorm):
132+
module.momentum = self.cfg.bn_momentum
133+
119134
# get optimizer, ADAM and SGD are supported.
120135
optimizer = get_optimizer(
121136
self.model.train_model,

anomaly_match/utils/get_net_builder.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ def _resolve_timm_name(net_name, pretrained):
9595
return timm_base, False
9696

9797

98+
def _reset_classifier_head(model):
99+
"""Reset the classifier head to PyTorch's default Linear initialisation.
100+
101+
timm initialises EfficientNet classifiers with TF-EfficientNet's
102+
``1/sqrt(fan_in + fan_out)`` scale, which is tuned for the 1000-class ImageNet
103+
head. For AnomalyMatch's 2-class head this produces weights that are roughly
104+
``num_classes`` times too large (std ~0.4 vs PyTorch's ~0.016). Such an
105+
overconfident fresh head makes almost every unlabeled image clear the FixMatch
106+
``p_cutoff`` from the very first step with essentially random pseudo-labels,
107+
poisoning the backbone during fine-tuning. Resetting to PyTorch's default
108+
kaiming-uniform init restores the pre-timm fine-tuning quality.
109+
110+
Args:
111+
model: A timm model exposing ``get_classifier()``.
112+
113+
Returns:
114+
The same model, with its classifier head re-initialised in place.
115+
"""
116+
classifier = model.get_classifier()
117+
if isinstance(classifier, nn.Linear):
118+
classifier.reset_parameters()
119+
return model
120+
121+
98122
def get_net_builder(net_name, pretrained=False, in_channels=3):
99123
"""Create a neural network builder function for the specified architecture.
100124
@@ -136,7 +160,7 @@ def build_model(
136160
effective_pretrained = pretrained if pretrained is not None else _pretrained
137161
if effective_pretrained:
138162
try:
139-
return timm.create_model(
163+
model = timm.create_model(
140164
_timm_name,
141165
pretrained=True,
142166
num_classes=num_classes,
@@ -148,17 +172,22 @@ def build_model(
148172
f"Bundled pretrained weights not available (clone with git-lfs to avoid "
149173
f"re-downloading). Downloading {_timm_name} from HuggingFace."
150174
)
151-
return timm.create_model(
175+
model = timm.create_model(
152176
_timm_name,
153177
pretrained=True,
154178
num_classes=num_classes,
155179
in_chans=in_channels,
156180
)
157-
return timm.create_model(
158-
_timm_name,
159-
pretrained=False,
160-
num_classes=num_classes,
161-
in_chans=in_channels,
162-
)
181+
else:
182+
model = timm.create_model(
183+
_timm_name,
184+
pretrained=False,
185+
num_classes=num_classes,
186+
in_chans=in_channels,
187+
)
188+
# timm's fresh classifier head is scaled for the 1000-class ImageNet head and
189+
# is ~num_classes too large for AnomalyMatch's 2-class head; reset it to
190+
# PyTorch's default init to avoid poisoning FixMatch fine-tuning.
191+
return _reset_classifier_head(model)
163192

164193
return build_model

tests/unit/test_net_builder.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright (c) European Space Agency, 2025.
2+
#
3+
# This file is subject to the terms and conditions defined in file 'LICENCE.txt', which
4+
# is part of this source code package. No part of the package, including
5+
# this file, may be copied, modified, propagated, or distributed except according to
6+
# the terms contained in the file 'LICENCE.txt'.
7+
import torch.nn as nn
8+
9+
from anomaly_match.utils.get_net_builder import get_net_builder
10+
11+
12+
def test_efficientnet_classifier_head_init_is_pytorch_scale():
13+
"""The 2-class classifier head must use PyTorch's default init, not timm's.
14+
15+
timm scales the fresh EfficientNet head for the 1000-class ImageNet head, which is
16+
far too large for AnomalyMatch's 2-class head and poisons FixMatch fine-tuning.
17+
After the head reset the weight std must sit near PyTorch's Linear bound.
18+
"""
19+
model = get_net_builder("efficientnet-lite0", pretrained=False)(num_classes=2, in_channels=3)
20+
classifier = model.get_classifier()
21+
assert isinstance(classifier, nn.Linear)
22+
weight = classifier.weight
23+
pytorch_bound = 1.0 / (weight.shape[1] ** 0.5)
24+
assert weight.std().item() < 2 * pytorch_bound

0 commit comments

Comments
 (0)