Files
wehub-resource-sync 16031aae96
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:26:24 +08:00

2098 lines
89 KiB
Python

# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for :class:`BestModelCallback` and :class:`RFDETREarlyStopping`."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import torch
from pytorch_lightning import Callback, LightningModule, Trainer
from pytorch_lightning import __version__ as ptl_version
from pytorch_lightning.trainer.states import TrainerFn
from torch.utils.data import DataLoader, TensorDataset
from rfdetr.config import RFDETRLargeDeprecatedConfig, RFDETRMediumConfig
from rfdetr.training.callbacks.best_model import BestModelCallback, RFDETREarlyStopping
from rfdetr.training.callbacks.ema import RFDETREMACallback
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_trainer(
metrics: dict[str, float],
current_epoch: int = 1,
is_global_zero: bool = True,
callbacks: list[object] | None = None,
) -> MagicMock:
"""Create a minimal mock Trainer with controllable callback_metrics.
Sets the attributes required by ModelCheckpoint and EarlyStopping skip-guards so that callbacks run normally in unit
tests.
"""
trainer = MagicMock()
trainer.callback_metrics = {k: torch.tensor(v) for k, v in metrics.items()}
trainer.current_epoch = current_epoch
trainer.is_global_zero = is_global_zero
trainer.callbacks = callbacks or []
trainer.should_stop = False
# Required by ModelCheckpoint._should_skip_saving_checkpoint
trainer.fast_dev_run = False
trainer.state.fn = TrainerFn.FITTING
trainer.sanity_checking = False
trainer.global_step = 1 # int; differs from _last_global_step_saved=0
# Required by EarlyStopping._log_info (world_size > 1 check)
trainer.world_size = 1
# Required by ModelCheckpoint.check_monitor_top_k and EarlyStopping (DDP reduce)
trainer.strategy.reduce_boolean_decision.side_effect = lambda x, **kwargs: x
# Prevent MagicMock auto-attribute from triggering class_names enrichment.
trainer.datamodule.class_names = None
return trainer
def _make_pl_module() -> MagicMock:
"""Create a minimal mock RFDETRModule with state_dict and train_config."""
pl_module = MagicMock()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
# Use a real dict so torch.save can pickle it (MagicMock is not picklable).
pl_module.train_config = {"lr": 0.001}
return pl_module
class _ResumeTinyModule(LightningModule):
"""Tiny LightningModule used to validate real ckpt_path resume behavior."""
def __init__(self) -> None:
super().__init__()
self.model = torch.nn.Linear(4, 1)
self.train_config = {"lr": 0.01}
def training_step(self, batch, batch_idx):
x, y = batch
pred = self.model(x)
return torch.nn.functional.mse_loss(pred, y)
def validation_step(self, batch, batch_idx):
del batch, batch_idx
self.log("val/mAP_50_95", torch.tensor(0.5), on_step=False, on_epoch=True, prog_bar=False)
def configure_optimizers(self):
return torch.optim.SGD(self.model.parameters(), lr=0.01)
class _EvalIntervalModule(LightningModule):
"""Tiny module that only logs val/mAP_50_95 every ``eval_interval`` epochs.
Simulates RF-DETR's COCO-eval skip behaviour: validation runs every epoch but the metric key is absent on non-eval
epochs.
"""
def __init__(self, eval_interval: int = 2) -> None:
super().__init__()
self.model = torch.nn.Linear(4, 1)
self.train_config = {"lr": 0.01}
self._eval_interval = eval_interval
def training_step(self, batch, batch_idx):
x, y = batch
return torch.nn.functional.mse_loss(self.model(x), y)
def validation_step(self, batch, batch_idx):
del batch, batch_idx
if self.current_epoch % self._eval_interval == 0:
self.log("val/mAP_50_95", torch.tensor(0.5), on_step=False, on_epoch=True, prog_bar=False)
def configure_optimizers(self):
return torch.optim.SGD(self.model.parameters(), lr=0.01)
class _ResumeProbeCallback(Callback):
"""Capture the first train epoch index for resume assertions."""
def __init__(self) -> None:
super().__init__()
self.first_train_epoch: int | None = None
def on_train_epoch_start(self, trainer, pl_module):
del pl_module
if self.first_train_epoch is None:
self.first_train_epoch = trainer.current_epoch
# ---------------------------------------------------------------------------
# TestBestModelCallback
# ---------------------------------------------------------------------------
class TestBestModelCallback:
"""Verify best-model checkpoint saving and selection."""
def test_checkpoint_payload_includes_model_config_when_provided(self) -> None:
"""Saved ``.pth`` checkpoints should carry the architecture schema needed for reload."""
trainer = _make_trainer({"val/mAP_50_95": 0.5})
payload = BestModelCallback._build_checkpoint_payload(
{"w": torch.zeros(1)},
{"num_classes": 1},
trainer,
model_name="RFDETRKeypointPreview",
model_config_dict={"num_keypoints_per_class": [0, 17], "use_grouppose_keypoints": True},
)
assert payload["model_config"] == {"num_keypoints_per_class": [0, 17], "use_grouppose_keypoints": True}
def test_checkpoint_payload_loops_include_validate_and_test_stubs(self) -> None:
"""Checkpoint loops dict must include validate_loop and test_loop stubs.
trainer.validate(ckpt_path=...) and trainer.test(ckpt_path=...) call restore_loops() which does
checkpoint["loops"]["validate_loop"] / checkpoint["loops"]["test_loop"] — KeyError if absent.
"""
trainer = _make_trainer({"val/mAP_50_95": 0.5})
payload = BestModelCallback._build_checkpoint_payload(
{"w": torch.zeros(1)},
{"num_classes": 1},
trainer,
)
loops = payload["loops"]
assert "validate_loop" in loops, "validate_loop missing — trainer.validate(ckpt_path=...) will KeyError"
assert "test_loop" in loops, "test_loop missing — trainer.test(ckpt_path=...) will KeyError"
assert "state_dict" in loops["validate_loop"]
assert "state_dict" in loops["test_loop"]
@pytest.mark.parametrize(
"monitor_ema, metrics, checkpoint_file",
[
pytest.param(None, {"val/mAP_50_95": 0.9}, "checkpoint_best_regular.pth", id="regular"),
pytest.param(
"val/ema_mAP_50_95",
{"val/mAP_50_95": 0.5, "val/ema_mAP_50_95": 0.9},
"checkpoint_best_ema.pth",
id="ema",
),
],
)
def test_skip_best_epochs_no_checkpoint_during_skip_window(
self, tmp_path: Path, monitor_ema: str | None, metrics: dict, checkpoint_file: str
) -> None:
"""No checkpoint written for epochs before skip_best_epochs."""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema=monitor_ema, skip_best_epochs=2)
pl_module = _make_pl_module()
cb.on_validation_end(_make_trainer(metrics, current_epoch=0), pl_module)
cb.on_validation_end(_make_trainer(metrics, current_epoch=1), pl_module)
assert not (tmp_path / checkpoint_file).exists()
@pytest.mark.parametrize(
"monitor_ema, skip_metrics, eligible_metrics, checkpoint_file",
[
pytest.param(
None,
{"val/mAP_50_95": 0.9},
{"val/mAP_50_95": 0.7},
"checkpoint_best_regular.pth",
id="regular",
),
pytest.param(
"val/ema_mAP_50_95",
{"val/mAP_50_95": 0.5, "val/ema_mAP_50_95": 0.9},
{"val/mAP_50_95": 0.5, "val/ema_mAP_50_95": 0.7},
"checkpoint_best_ema.pth",
id="ema",
),
],
)
def test_skip_best_epochs_checkpoint_saved_on_first_eligible_epoch(
self,
tmp_path: Path,
monitor_ema: str | None,
skip_metrics: dict,
eligible_metrics: dict,
checkpoint_file: str,
) -> None:
"""Checkpoint written on the first epoch at or after skip_best_epochs."""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema=monitor_ema, skip_best_epochs=2)
pl_module = _make_pl_module()
cb.on_validation_end(_make_trainer(skip_metrics, current_epoch=0), pl_module)
cb.on_validation_end(_make_trainer(skip_metrics, current_epoch=1), pl_module)
cb.on_validation_end(_make_trainer(eligible_metrics, current_epoch=2), pl_module)
assert (tmp_path / checkpoint_file).exists()
@pytest.mark.parametrize(
"kwargs",
[
pytest.param({}, id="default"),
pytest.param({"skip_best_epochs": 0}, id="explicit_zero"),
],
)
def test_skip_best_epochs_zero_does_not_defer_epoch_zero(self, tmp_path: Path, kwargs: dict) -> None:
"""skip_best_epochs=0 (explicit or default) makes epoch 0 eligible for checkpoint."""
cb = BestModelCallback(output_dir=str(tmp_path), **kwargs)
trainer = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=0)
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
assert (tmp_path / "checkpoint_best_regular.pth").exists()
assert cb.best_model_score is not None
def test_skip_best_epochs_exceeds_total_epochs_produces_no_checkpoint(self, tmp_path: Path) -> None:
"""No checkpoint when skip_best_epochs >= total epochs (all epochs skipped)."""
cb = BestModelCallback(output_dir=str(tmp_path), skip_best_epochs=3)
pl_module = _make_pl_module()
for epoch in range(2):
trainer = _make_trainer({"val/mAP_50_95": 0.9}, current_epoch=epoch)
cb.on_validation_end(trainer, pl_module)
assert not (tmp_path / "checkpoint_best_regular.pth").exists()
assert cb.best_model_score is None
@pytest.mark.parametrize(
"invalid_value, exc_type",
[
pytest.param(True, TypeError, id="bool_true"),
pytest.param(2.5, TypeError, id="float"),
pytest.param("3", TypeError, id="string"),
pytest.param(-1, ValueError, id="negative"),
],
)
def test_skip_best_epochs_invalid_input_raises(
self, tmp_path: Path, invalid_value: object, exc_type: type[Exception]
) -> None:
"""BestModelCallback raises TypeError for non-int and ValueError for negative skip_best_epochs."""
with pytest.raises(exc_type):
BestModelCallback(output_dir=str(tmp_path), skip_best_epochs=invalid_value) # type: ignore[arg-type]
def test_regular_checkpoint_saved_on_improvement(self, tmp_path: Path) -> None:
"""Metric 0.5 > initial 0.0 causes checkpoint_best_regular.pth to be saved."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
assert (tmp_path / "checkpoint_best_regular.pth").exists()
def test_regular_checkpoint_not_saved_on_no_improvement(self, tmp_path: Path) -> None:
"""Metric 0.3 after best 0.5 does not create a checkpoint file."""
cb = BestModelCallback(output_dir=str(tmp_path))
pl_module = _make_pl_module()
# First call sets best to 0.5
trainer1 = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer1, pl_module)
# Record mtime to verify no overwrite
path = tmp_path / "checkpoint_best_regular.pth"
stat_before = path.stat().st_mtime_ns
# Second call with worse metric (same global_step → ModelCheckpoint skip guard fires)
trainer2 = _make_trainer({"val/mAP_50_95": 0.3})
cb.on_validation_end(trainer2, pl_module)
assert path.stat().st_mtime_ns == stat_before
def test_ema_checkpoint_saved_on_ema_improvement(self, tmp_path: Path) -> None:
"""When monitor_ema is set and EMA metric improves, EMA checkpoint is saved."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
assert (tmp_path / "checkpoint_best_ema.pth").exists()
def test_ema_checkpoint_saves_ema_callback_weights(self, tmp_path: Path) -> None:
"""EMA checkpoint must store EMA callback weights, not live model weights."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
ema_state = {"w": torch.ones(1)}
ema_callback = MagicMock()
ema_callback.get_ema_model_state_dict.return_value = ema_state
trainer = _make_trainer(
{"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6},
callbacks=[ema_callback],
)
pl_module = _make_pl_module()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(tmp_path / "checkpoint_best_ema.pth", map_location="cpu", weights_only=False)
assert checkpoint["model"] == ema_state
def test_regular_checkpoint_uses_live_weights_when_ema_enabled(self, tmp_path: Path) -> None:
"""Regular checkpoint must store live weights even when EMA is tracked separately."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
ema_state = {"w": torch.ones(1)}
ema_callback = MagicMock()
ema_callback.get_ema_model_state_dict.return_value = ema_state
trainer = _make_trainer(
{"val/mAP_50_95": 0.6, "val/ema_mAP_50_95": 0.6},
callbacks=[ema_callback],
)
pl_module = _make_pl_module()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=False)
assert checkpoint["model"] == {"w": torch.zeros(1)}
def test_best_total_regular_wins(self, tmp_path: Path) -> None:
"""Regular model wins when best_regular > best_ema."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
run_test=False,
)
pl_module = _make_pl_module()
# Epoch with regular=0.6, ema=0.5
trainer = _make_trainer({"val/mAP_50_95": 0.6, "val/ema_mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
assert total.exists()
data = torch.load(total, map_location="cpu", weights_only=False)
assert "model" in data
assert "args" in data
def test_best_total_ema_wins(self, tmp_path: Path) -> None:
"""EMA wins when best_ema > best_regular (strict >)."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
run_test=False,
)
pl_module = _make_pl_module()
# Give regular a lower value, EMA a higher value
trainer = _make_trainer({"val/mAP_50_95": 0.5, "val/ema_mAP_50_95": 0.7})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
assert total.exists()
# The EMA checkpoint should have been the source
ema_data = torch.load(
tmp_path / "checkpoint_best_ema.pth",
map_location="cpu",
weights_only=False,
)
total_data = torch.load(total, map_location="cpu", weights_only=False)
# total is stripped so only model + args
assert total_data["model"] == ema_data["model"]
def test_best_total_ema_equal_uses_regular(self, tmp_path: Path) -> None:
"""When best_ema == best_regular, regular wins (strict > for EMA)."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
run_test=False,
)
pl_module = _make_pl_module()
# Equal metrics
trainer = _make_trainer({"val/mAP_50_95": 0.6, "val/ema_mAP_50_95": 0.6})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
assert total.exists()
# Regular should have been chosen since EMA didn't strictly win
regular_data = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
total_data = torch.load(total, map_location="cpu", weights_only=False)
assert total_data["model"] == regular_data["model"]
def test_best_total_stripped_of_optimizer(self, tmp_path: Path) -> None:
"""checkpoint_best_total.pth must NOT contain optimizer or lr_scheduler keys."""
cb = BestModelCallback(
output_dir=str(tmp_path),
run_test=False,
)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
data = torch.load(total, map_location="cpu", weights_only=False)
assert "optimizer" not in data
assert "lr_scheduler" not in data
# Must contain model and args
assert "model" in data
assert "args" in data
def test_run_test_true_calls_trainer_test(self, tmp_path: Path) -> None:
"""run_test=True causes trainer.test() when module defines test_step()."""
from pytorch_lightning import LightningModule
class _ModuleWithTestStep(LightningModule):
def test_step(self, batch: object, batch_idx: int) -> None: ...
# Use a real subclass (not MagicMock) so type() inspection sees test_step.
pl_module = _ModuleWithTestStep()
pl_module.model = MagicMock()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
pl_module.train_config = {"lr": 0.001}
cb = BestModelCallback(output_dir=str(tmp_path), run_test=True)
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
trainer.test.assert_called_once_with(pl_module, datamodule=trainer.datamodule, verbose=False)
def test_run_test_true_without_test_step_skips_trainer_test(self, tmp_path: Path) -> None:
"""run_test=True but no test_step override — trainer.test() is NOT called.
The guard in BestModelCallback.on_fit_end() skips trainer.test() for modules that do not override
LightningModule.test_step() to avoid a MisconfigurationException from PTL.
"""
cb = BestModelCallback(output_dir=str(tmp_path), run_test=True)
pl_module = _make_pl_module() # MagicMock — no test_step on its class
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
trainer.test.assert_not_called()
def test_run_test_true_without_best_checkpoint_skips_trainer_test(self, tmp_path: Path) -> None:
"""run_test=True must not test final weights when no best checkpoint was produced."""
from pytorch_lightning import LightningModule
class _ModuleWithTestStep(LightningModule):
def test_step(self, batch: object, batch_idx: int) -> None: ...
pl_module = _ModuleWithTestStep()
pl_module.model = MagicMock()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
pl_module.train_config = {"lr": 0.001}
cb = BestModelCallback(output_dir=str(tmp_path), run_test=True, skip_best_epochs=2)
trainer = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=0)
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
assert not (tmp_path / "checkpoint_best_total.pth").exists()
trainer.test.assert_not_called()
def test_run_test_loads_best_weights_before_test(self, tmp_path: Path) -> None:
"""on_fit_end loads checkpoint_best_total.pth weights before trainer.test().
Mirrors legacy main.py:602-609 which loads the best checkpoint into the model before running test evaluation so
the test loop measures the best model, not the end-of-training state.
"""
from pytorch_lightning import LightningModule
class _ModuleWithTestStep(LightningModule):
def test_step(self, batch: object, batch_idx: int) -> None: ...
pl_module = _ModuleWithTestStep()
pl_module.model = MagicMock()
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
pl_module.train_config = {"lr": 0.001}
cb = BestModelCallback(output_dir=str(tmp_path), run_test=True)
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
# Model weights must be loaded from checkpoint_best_total.pth with strict=True
pl_module.model.load_state_dict.assert_called_once()
call_kwargs = pl_module.model.load_state_dict.call_args.kwargs
assert call_kwargs.get("strict") is True, "load_state_dict must be called with strict=True"
def test_run_test_false_skips_trainer_test(self, tmp_path: Path) -> None:
"""run_test=False means trainer.test() is never called."""
cb = BestModelCallback(output_dir=str(tmp_path), run_test=False)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
trainer.test.assert_not_called()
def test_checkpoint_class_names_populated_from_datamodule(self, tmp_path: Path) -> None:
"""Saved checkpoint args.class_names reflects dataset class names.
Regression test for #509: checkpoints were saved with class_names=None when the user did not pass class_names
explicitly, causing reloaded-model inference to fall through to COCO labels instead of dataset labels.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
custom_names = ["cat", "dog"]
trainer = _make_trainer({"val/mAP_50_95": 0.5})
trainer.datamodule.class_names = custom_names
pl_module = _make_pl_module()
# Real TrainConfig with class_names unset — the bug scenario.
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == custom_names
def test_ema_checkpoint_class_names_populated_from_datamodule(self, tmp_path: Path) -> None:
"""EMA checkpoint args.class_names also reflects dataset class names.
Regression test for #509: EMA checkpoint path was not enriched with class names, so EMA-selected runs would
still return COCO labels after reload.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
custom_names = ["cat", "dog"]
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
trainer.datamodule.class_names = custom_names
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_ema.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == custom_names
def test_checkpoint_class_names_not_overwritten_when_already_set(self, tmp_path: Path) -> None:
"""Explicitly-set class_names in TrainConfig are preserved in the checkpoint.
When the user passes class_names=['defect'] to TrainConfig, the saved checkpoint must keep that value even if
the datamodule reports different names.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
trainer.datamodule.class_names = ["other_class"] # would overwrite if bug exists
pl_module = _make_pl_module()
explicit_names = ["defect"]
pl_module.train_config = TrainConfig(
dataset_dir=str(tmp_path / "ds"), tensorboard=False, class_names=explicit_names
)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == explicit_names
def test_checkpoint_explicit_empty_class_names_not_overwritten_by_datamodule(self, tmp_path: Path) -> None:
"""TrainConfig(class_names=[]) is preserved even when datamodule has non-empty names.
Guard-bypass regression: the truthiness check `not getattr(..., "class_names", None)` treated an explicit empty
list the same as None (both falsy), silently overwriting the user's intent with the datamodule's names. The fix
uses `is None` identity.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
trainer.datamodule.class_names = ["cat", "dog"] # would overwrite if bug exists
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False, class_names=[])
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == [], (
"Explicit class_names=[] in TrainConfig must not be overwritten by datamodule names"
)
def test_ema_checkpoint_explicit_empty_class_names_not_overwritten_by_datamodule(self, tmp_path: Path) -> None:
"""EMA path: TrainConfig(class_names=[]) is preserved even when datamodule has non-empty names.
Mirrors the regular checkpoint guard-bypass regression test for the EMA path.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
trainer.datamodule.class_names = ["cat", "dog"] # would overwrite if bug exists
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False, class_names=[])
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_ema.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == [], (
"Explicit class_names=[] in TrainConfig must not be overwritten by datamodule names (EMA path)"
)
def test_checkpoint_empty_class_names_populated_from_datamodule(self, tmp_path: Path) -> None:
"""Checkpoint preserves explicitly-empty dataset class names.
Empty list should be treated as a provided value, not as missing.
"""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
trainer.datamodule.class_names = []
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == []
def test_ema_checkpoint_empty_class_names_populated_from_datamodule(self, tmp_path: Path) -> None:
"""EMA checkpoint preserves explicitly-empty dataset class names."""
from rfdetr.config import TrainConfig
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
trainer.datamodule.class_names = []
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_ema.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["class_names"] == []
# --- PTL-compatible format tests ---
def test_regular_checkpoint_args_is_dict(self, tmp_path: Path) -> None:
"""Saved args must be a plain dict (not a Pydantic object) for weights_only=True compat."""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
# weights_only=True must succeed now that args is a plain dict.
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=True)
assert isinstance(ckpt["args"], dict)
def test_regular_checkpoint_has_ptl_state_dict_key(self, tmp_path: Path) -> None:
"""Saved regular checkpoint must include 'state_dict' with model.
prefix.
"""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=False)
assert "state_dict" in ckpt
assert all(k.startswith("model.") for k in ckpt["state_dict"])
def test_regular_checkpoint_has_loops_key(self, tmp_path: Path) -> None:
"""Saved regular checkpoint must include 'loops' with fit_loop epoch counter."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=3)
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=False)
assert "loops" in ckpt
ep = ckpt["loops"]["fit_loop"]["epoch_progress"]
assert ep["current"]["completed"] == 4 # epoch 3 + 1
def test_regular_checkpoint_has_ptl_version_key(self, tmp_path: Path) -> None:
"""Saved regular checkpoint must include 'pytorch-lightning_version'."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=False)
assert ckpt.get("pytorch-lightning_version") == ptl_version
def test_ema_checkpoint_has_ptl_state_dict_key(self, tmp_path: Path) -> None:
"""Saved EMA checkpoint must include 'state_dict' with model.
prefix.
"""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_ema.pth", map_location="cpu", weights_only=False)
assert "state_dict" in ckpt
assert all(k.startswith("model.") for k in ckpt["state_dict"])
def test_ema_checkpoint_has_loops_key(self, tmp_path: Path) -> None:
"""Saved EMA checkpoint must include 'loops' with fit_loop epoch counter."""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6}, current_epoch=5)
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_ema.pth", map_location="cpu", weights_only=False)
assert "loops" in ckpt
ep = ckpt["loops"]["fit_loop"]["epoch_progress"]
assert ep["current"]["completed"] == 6 # epoch 5 + 1
def test_state_dict_values_match_model_weights(self, tmp_path: Path) -> None:
"""state_dict values must be identical to the original model weights."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
weights = {"w": torch.randn(3, 3)}
pl_module.model.state_dict.return_value = weights
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", map_location="cpu", weights_only=False)
assert torch.equal(ckpt["state_dict"]["model.w"], weights["w"])
def test_best_total_preserves_ptl_keys_after_strip(self, tmp_path: Path) -> None:
"""strip_checkpoint must preserve state_dict and loops in the final file."""
cb = BestModelCallback(output_dir=str(tmp_path), run_test=False)
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
data = torch.load(total, map_location="cpu", weights_only=False)
assert "state_dict" in data, "strip_checkpoint must preserve 'state_dict'"
assert "loops" in data, "strip_checkpoint must preserve 'loops'"
assert "pytorch-lightning_version" in data, "strip_checkpoint must preserve 'pytorch-lightning_version'"
assert "optimizer_states" in data, "strip_checkpoint must preserve 'optimizer_states'"
assert "lr_schedulers" in data, "strip_checkpoint must preserve 'lr_schedulers'"
def test_not_global_zero_does_not_save(self, tmp_path: Path) -> None:
"""Non-main process (is_global_zero=False) must not write any files."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
pl_module = _make_pl_module()
trainer = _make_trainer(
{"val/mAP_50_95": 0.9, "val/ema_mAP_50_95": 0.9},
is_global_zero=False,
)
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
assert not (tmp_path / "checkpoint_best_regular.pth").exists()
assert not (tmp_path / "checkpoint_best_ema.pth").exists()
assert not (tmp_path / "checkpoint_best_total.pth").exists()
def test_train_epoch_end_ignores_missing_validation_metrics(self, tmp_path: Path) -> None:
"""Train-epoch end must not try to checkpoint when validation metrics were not logged."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({})
cb.on_train_epoch_end(trainer, _make_pl_module())
assert not (tmp_path / "checkpoint_best_regular.pth").exists()
def test_validation_end_ignores_missing_validation_metrics(self, tmp_path: Path) -> None:
"""on_validation_end must not raise when val/mAP_50_95 was not logged (non-eval epoch)."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({}) # empty metrics — no val/mAP_50_95 key
trainer.fit_loop.epoch_loop.val_loop._has_run = True
cb.on_validation_end(trainer, _make_pl_module()) # must not raise
assert not (tmp_path / "checkpoint_best_regular.pth").exists()
def test_eval_interval_does_not_crash(self, tmp_path: Path) -> None:
"""BestModelCallback must not crash over 3 epochs when metrics are only logged every 2nd epoch."""
torch.manual_seed(0)
x = torch.randn(8, 4)
y = torch.randn(8, 1)
train_loader = DataLoader(TensorDataset(x, y), batch_size=2)
val_loader = DataLoader(TensorDataset(x, y), batch_size=2)
cb = BestModelCallback(output_dir=str(tmp_path), run_test=False)
trainer = Trainer(
max_epochs=3,
accelerator="cpu",
enable_progress_bar=False,
enable_model_summary=False,
logger=False,
num_sanity_val_steps=0,
limit_train_batches=2,
limit_val_batches=1,
callbacks=[cb],
default_root_dir=str(tmp_path),
)
trainer.fit(_EvalIntervalModule(eval_interval=2), train_dataloaders=train_loader, val_dataloaders=val_loader)
# Checkpoint must be written on eval epochs (0 and 2) — at least one must exist.
assert (tmp_path / "checkpoint_best_regular.pth").exists()
def test_best_total_checkpoint_resumes_via_trainer_fit_ckpt_path(self, tmp_path: Path) -> None:
"""checkpoint_best_total.pth must restore epoch/step when passed to Trainer.fit(ckpt_path=...)."""
torch.manual_seed(0)
x = torch.randn(8, 4)
y = torch.randn(8, 1)
train_loader = DataLoader(TensorDataset(x, y), batch_size=2)
val_loader = DataLoader(TensorDataset(x, y), batch_size=2)
save_cb = BestModelCallback(output_dir=str(tmp_path), run_test=False)
trainer_first = Trainer(
max_epochs=1,
accelerator="cpu",
enable_progress_bar=False,
enable_model_summary=False,
logger=False,
num_sanity_val_steps=0,
limit_train_batches=2,
limit_val_batches=1,
callbacks=[save_cb],
default_root_dir=str(tmp_path),
)
trainer_first.fit(_ResumeTinyModule(), train_dataloaders=train_loader, val_dataloaders=val_loader)
ckpt_path = tmp_path / "checkpoint_best_total.pth"
assert ckpt_path.exists()
first_phase_global_step = trainer_first.global_step
assert first_phase_global_step == 2
ckpt_data = torch.load(ckpt_path, map_location="cpu", weights_only=False)
assert ckpt_data["global_step"] == first_phase_global_step
resume_probe = _ResumeProbeCallback()
trainer_second = Trainer(
max_epochs=2,
accelerator="cpu",
enable_progress_bar=False,
enable_model_summary=False,
logger=False,
num_sanity_val_steps=0,
limit_train_batches=2,
limit_val_batches=1,
callbacks=[resume_probe],
default_root_dir=str(tmp_path),
)
trainer_second.fit(
_ResumeTinyModule(),
train_dataloaders=train_loader,
val_dataloaders=val_loader,
ckpt_path=str(ckpt_path),
)
# PTL applies loop restoration by the first train epoch start.
assert resume_probe.first_train_epoch == 1
# In the stripped-checkpoint resume path, optimizer state is intentionally
# fresh; this resumed phase contributes exactly one epoch with 2 steps.
assert trainer_second.current_epoch == 2
assert trainer_second.global_step == 2
# ---------------------------------------------------------------------------
# TestRFDETREarlyStopping
# ---------------------------------------------------------------------------
class TestRFDETREarlyStopping:
"""Verify early stopping logic mirrors legacy EarlyStoppingCallback."""
def test_patience_not_counted_during_skip_window(self) -> None:
"""Patience wait_count stays 0 and training does not stop during skipped epochs."""
cb = RFDETREarlyStopping(patience=1, min_delta=0.001, skip_best_epochs=2)
pl_module = _make_pl_module()
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.9}, current_epoch=0), pl_module)
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=1)
cb.on_validation_end(trainer, pl_module)
assert cb.wait_count == 0
assert trainer.should_stop is False
def test_first_eligible_epoch_sets_baseline_with_zero_wait(self) -> None:
"""First eligible epoch becomes best_score baseline; patience wait_count stays 0."""
cb = RFDETREarlyStopping(patience=1, min_delta=0.001, skip_best_epochs=2)
pl_module = _make_pl_module()
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.9}, current_epoch=0), pl_module)
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.8}, current_epoch=1), pl_module)
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.7}, current_epoch=2), pl_module)
assert cb.best_score.item() == pytest.approx(0.7)
assert cb.wait_count == 0
def test_patience_triggers_stop_after_skip_window(self) -> None:
"""Patience counts normally after skip window; triggers stop when exhausted."""
cb = RFDETREarlyStopping(patience=1, min_delta=0.001, skip_best_epochs=2)
pl_module = _make_pl_module()
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.9}, current_epoch=0), pl_module)
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.8}, current_epoch=1), pl_module)
cb.on_validation_end(_make_trainer({"val/mAP_50_95": 0.7}, current_epoch=2), pl_module)
trainer = _make_trainer({"val/mAP_50_95": 0.7}, current_epoch=3)
cb.on_validation_end(trainer, pl_module)
assert trainer.should_stop is True
def test_skip_best_epochs_uses_first_eligible_epoch_as_baseline(self) -> None:
"""A stronger skipped epoch must not block the first eligible epoch from becoming best."""
cb = RFDETREarlyStopping(patience=2, min_delta=0.001, skip_best_epochs=2)
pl_module = _make_pl_module()
trainer_epoch0 = _make_trainer({"val/mAP_50_95": 0.95}, current_epoch=0)
cb.on_validation_end(trainer_epoch0, pl_module)
trainer_epoch1 = _make_trainer({"val/mAP_50_95": 0.85}, current_epoch=1)
cb.on_validation_end(trainer_epoch1, pl_module)
trainer_epoch2 = _make_trainer({"val/mAP_50_95": 0.40}, current_epoch=2)
cb.on_validation_end(trainer_epoch2, pl_module)
assert cb.best_score.item() == pytest.approx(0.40)
assert cb.wait_count == 0
@pytest.mark.parametrize(
"kwargs",
[
pytest.param({}, id="default"),
pytest.param({"skip_best_epochs": 0}, id="explicit_zero"),
],
)
def test_skip_best_epochs_zero_does_not_defer_epoch_zero(self, kwargs: dict) -> None:
"""skip_best_epochs=0 (explicit or default) makes epoch 0 eligible for patience tracking."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001, **kwargs)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=0)
cb.on_validation_end(trainer, pl_module)
assert cb.best_score is not None
assert cb.best_score.item() == pytest.approx(0.5)
@pytest.mark.parametrize(
"invalid_value, exc_type",
[
pytest.param(True, TypeError, id="bool_true"),
pytest.param(2.5, TypeError, id="float"),
pytest.param("3", TypeError, id="string"),
pytest.param(-1, ValueError, id="negative"),
],
)
def test_skip_best_epochs_invalid_input_raises(self, invalid_value: object, exc_type: type[Exception]) -> None:
"""RFDETREarlyStopping raises TypeError for non-int and ValueError for negative skip_best_epochs."""
with pytest.raises(exc_type):
RFDETREarlyStopping(patience=5, skip_best_epochs=invalid_value) # type: ignore[arg-type]
def test_no_stop_within_patience(self) -> None:
"""3 epochs with no improvement, patience=5 -- training continues."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001)
pl_module = _make_pl_module()
# Seed best_score with initial improvement
trainer0 = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer0, pl_module)
# 3 stagnant epochs
for _ in range(3):
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
assert trainer.should_stop is False
assert cb.wait_count == 3
def test_stops_after_patience_exceeded(self) -> None:
"""Patience=3 with 3 no-improvement epochs triggers stop."""
cb = RFDETREarlyStopping(patience=3, min_delta=0.001)
pl_module = _make_pl_module()
# Set baseline
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
# 3 stagnant epochs
for _ in range(3):
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
assert trainer.should_stop is True
def test_counter_resets_on_improvement(self) -> None:
"""2 stagnant epochs then 1 improvement resets counter to 0."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001)
pl_module = _make_pl_module()
# Set baseline
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
# 2 stagnant
for _ in range(2):
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
assert cb.wait_count == 2
# Improvement
trainer = _make_trainer({"val/mAP_50_95": 0.6})
cb.on_validation_end(trainer, pl_module)
assert cb.wait_count == 0
def test_min_delta_respected(self) -> None:
"""Improvement smaller than min_delta does not reset counter."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.01)
pl_module = _make_pl_module()
# Set baseline at 0.5
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
# Improve by only half of min_delta
trainer = _make_trainer({"val/mAP_50_95": 0.505})
cb.on_validation_end(trainer, pl_module)
assert cb.wait_count == 1 # not reset
def test_use_ema_true_monitors_ema_only(self) -> None:
"""use_ema=True with both metrics available uses EMA value only."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001, use_ema=True)
pl_module = _make_pl_module()
# EMA is 0.3 (low), regular is 0.8 (high)
trainer = _make_trainer({"val/mAP_50_95": 0.8, "val/ema_mAP_50_95": 0.3})
cb.on_validation_end(trainer, pl_module)
# best_score should reflect EMA value, not regular
assert cb.best_score.item() == pytest.approx(0.3)
def test_use_ema_false_monitors_max(self) -> None:
"""use_ema=False with both metrics uses max(regular, ema)."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001, use_ema=False)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
cb.on_validation_end(trainer, pl_module)
# max(0.4, 0.6) = 0.6
assert cb.best_score.item() == pytest.approx(0.6)
def test_only_regular_available(self) -> None:
"""When EMA key is absent, uses regular metric without error."""
cb = RFDETREarlyStopping(patience=5, min_delta=0.001)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.45})
cb.on_validation_end(trainer, pl_module)
assert cb.best_score.item() == pytest.approx(0.45)
assert cb.wait_count == 0
def test_neither_available_is_noop(self) -> None:
"""Neither metric present causes no counter increment and no stop."""
cb = RFDETREarlyStopping(patience=1, min_delta=0.001)
pl_module = _make_pl_module()
trainer = _make_trainer({}) # no metrics at all
cb.on_validation_end(trainer, pl_module)
assert cb.wait_count == 0
assert trainer.should_stop is False
def test_train_epoch_end_ignores_missing_validation_metrics(self) -> None:
"""Train-epoch end must not evaluate early stopping when validation did not run."""
cb = RFDETREarlyStopping(patience=1, min_delta=0.001)
trainer = _make_trainer({})
cb.on_train_epoch_end(trainer, _make_pl_module())
assert cb.wait_count == 0
assert trainer.should_stop is False
@pytest.mark.parametrize(
("use_ema", "maps", "patience", "min_delta", "expected_stop_epoch"),
[
pytest.param(
False,
[0.10, 0.20, 0.30, 0.30, 0.30, 0.30, 0.30, 0.30],
3,
0.01,
5,
id="use_ema_false_plateau",
),
pytest.param(
True,
[0.05, 0.15, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25],
3,
0.01,
5,
id="use_ema_true_plateau",
),
],
)
def test_trigger_epoch_matches_expected(
self,
use_ema: bool,
maps: list,
patience: int,
min_delta: float,
expected_stop_epoch: int,
) -> None:
"""RFDETREarlyStopping stops at the expected epoch for a plateau sequence.
Drives the callback with an identical mAP sequence and asserts the trigger epoch matches the expected value.
"""
new_cb = RFDETREarlyStopping(
patience=patience,
min_delta=min_delta,
use_ema=use_ema,
verbose=False,
)
pl_module = _make_pl_module()
new_stop_epoch: int | None = None
for epoch, m in enumerate(maps):
metrics = {"val/mAP_50_95": m}
if use_ema:
metrics["val/ema_mAP_50_95"] = m
trainer = _make_trainer(metrics, current_epoch=epoch)
new_cb.on_validation_end(trainer, pl_module)
if trainer.should_stop:
new_stop_epoch = epoch
break
assert new_stop_epoch == expected_stop_epoch
# ---------------------------------------------------------------------------
# model_name in checkpoint payload (#887)
# ---------------------------------------------------------------------------
class TestCheckpointModelName:
"""Verify model_name is stored in checkpoint payloads."""
def test_regular_checkpoint_contains_model_name(self, tmp_path: Path) -> None:
"""Regular checkpoint includes model_name from model_config."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_name = "RFDETRLarge"
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", weights_only=False)
assert ckpt["model_name"] == "RFDETRLarge"
def test_regular_checkpoint_model_name_absent_when_not_set(self, tmp_path: Path) -> None:
"""model_name key is absent when model_config has no model_name attribute."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", weights_only=False)
assert "model_name" not in ckpt
def test_regular_checkpoint_infers_model_name_from_model_config_type(self, tmp_path: Path) -> None:
"""When model_name is unset, infer class name from concrete ModelConfig type."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.model_config = RFDETRMediumConfig(model_name=None)
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", weights_only=False)
assert ckpt["model_name"] == "RFDETRMedium"
def test_ema_checkpoint_contains_model_name(self, tmp_path: Path) -> None:
"""EMA checkpoint also includes model_name."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_name = "RFDETRMedium"
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_ema.pth", weights_only=False)
assert ckpt["model_name"] == "RFDETRMedium"
def test_deprecated_config_raises_runtime_error(self, tmp_path: Path) -> None:
"""RFDETRLargeDeprecatedConfig raises RuntimeError — deprecated configs are unsupported."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.model_config = RFDETRLargeDeprecatedConfig(model_name=None)
with pytest.raises(RuntimeError, match="Deprecated model config"):
cb.on_validation_end(trainer, pl_module)
# ---------------------------------------------------------------------------
# rfdetr_version in checkpoint payload
# ---------------------------------------------------------------------------
class TestCheckpointRfdetrVersion:
"""Verify rfdetr_version is stored in checkpoint payloads."""
def test_regular_checkpoint_contains_rfdetr_version(self, tmp_path: Path) -> None:
"""Regular checkpoint includes rfdetr_version string."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
expected_version = "test-version"
with patch(
"rfdetr.training.callbacks.best_model.get_version",
return_value=expected_version,
):
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", weights_only=False)
assert "rfdetr_version" in ckpt
assert ckpt["rfdetr_version"] == expected_version
def test_ema_checkpoint_contains_rfdetr_version(self, tmp_path: Path) -> None:
"""EMA checkpoint also includes rfdetr_version."""
cb = BestModelCallback(
output_dir=str(tmp_path),
monitor_ema="val/ema_mAP_50_95",
)
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.6})
pl_module = _make_pl_module()
expected_version = "test-version"
with patch(
"rfdetr.training.callbacks.best_model.get_version",
return_value=expected_version,
):
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_ema.pth", weights_only=False)
assert "rfdetr_version" in ckpt
assert ckpt["rfdetr_version"] == expected_version
def test_best_total_preserves_rfdetr_version_after_strip(self, tmp_path: Path) -> None:
"""strip_checkpoint must preserve rfdetr_version in the final checkpoint."""
cb = BestModelCallback(output_dir=str(tmp_path), run_test=False)
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
expected_version = "test-version"
with patch(
"rfdetr.training.callbacks.best_model.get_version",
return_value=expected_version,
):
cb.on_validation_end(trainer, pl_module)
cb.on_fit_end(trainer, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
data = torch.load(total, map_location="cpu", weights_only=False)
assert "rfdetr_version" in data
assert data["rfdetr_version"] == expected_version
def test_rfdetr_version_absent_when_get_version_returns_none(self, tmp_path: Path) -> None:
"""rfdetr_version must be omitted when get_version() cannot resolve the version."""
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
with patch("rfdetr.training.callbacks.best_model.get_version", return_value=None):
cb.on_validation_end(trainer, pl_module)
ckpt = torch.load(tmp_path / "checkpoint_best_regular.pth", weights_only=False)
assert "rfdetr_version" not in ckpt
# ---------------------------------------------------------------------------
# _best_ema state persistence across resume (#969)
# ---------------------------------------------------------------------------
class TestBestEmaStatePersistence:
"""Regression tests for _best_ema not surviving Lightning checkpoint resume.
Before the fix, BestModelCallback did not override state_dict() / load_state_dict(), so _best_ema was never included
in the Lightning callback state bundle. On resume via trainer.fit(ckpt_path=...) the callback was reconstructed
fresh with _best_ema=0.0, causing any positive post-resume EMA value to trivially overwrite checkpoint_best_ema.pth
with inferior weights.
Regression tests for GitHub issue #969.
"""
def test_state_dict_includes_best_ema(self, tmp_path: Path) -> None:
"""state_dict() must include _best_ema so it survives Lightning checkpointing."""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
pl_module = _make_pl_module()
# Drive _best_ema to 0.75 via a validation pass.
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.75})
cb.on_validation_end(trainer, pl_module)
state = cb.state_dict()
assert "_best_ema" in state, "_best_ema must be present in state_dict() output"
assert state["_best_ema"] == pytest.approx(0.75)
def test_load_state_dict_restores_best_ema(self, tmp_path: Path) -> None:
"""load_state_dict() must restore _best_ema from the persisted state."""
# First callback: train to _best_ema=0.75.
cb_first = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.75})
cb_first.on_validation_end(trainer, pl_module)
saved_state = cb_first.state_dict()
# Second callback: simulate a fresh resume by loading the saved state.
cb_resumed = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
cb_resumed.load_state_dict(saved_state)
assert cb_resumed._best_ema == pytest.approx(0.75), (
"load_state_dict() must restore _best_ema; without fix it stays 0.0"
)
def test_resume_does_not_clobber_ema_checkpoint_with_inferior_weights(self, tmp_path: Path) -> None:
"""After resume, inferior post-resume EMA must not overwrite checkpoint_best_ema.pth.
Without the fix: _best_ema resets to 0.0 on resume, so any positive EMA metric (0.5) trivially satisfies ema_val
> _best_ema and overwrites the checkpoint saved pre-resume (0.75).
"""
# --- Pre-resume phase: establish EMA best of 0.75 ---
cb_pre = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
pl_module = _make_pl_module()
trainer_pre = _make_trainer(
{"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.75},
current_epoch=5,
)
cb_pre.on_validation_end(trainer_pre, pl_module)
ema_path = tmp_path / "checkpoint_best_ema.pth"
assert ema_path.exists()
baseline_epoch = torch.load(ema_path, map_location="cpu", weights_only=False)["epoch"]
saved_state = cb_pre.state_dict()
# --- Resume phase: fresh callback loaded from saved state ---
cb_resumed = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
cb_resumed.load_state_dict(saved_state)
# Post-resume validation reports EMA=0.5 — worse than pre-resume best (0.75).
trainer_post = _make_trainer(
{"val/mAP_50_95": 0.45, "val/ema_mAP_50_95": 0.5},
current_epoch=7,
)
cb_resumed.on_validation_end(trainer_post, pl_module)
assert torch.load(ema_path, map_location="cpu", weights_only=False)["epoch"] == baseline_epoch, (
"checkpoint_best_ema.pth must not be overwritten by an inferior post-resume EMA value"
)
def test_on_fit_end_selects_ema_winner_after_resume(self, tmp_path: Path) -> None:
"""on_fit_end picks EMA winner correctly when _best_ema is properly restored.
Without the fix: _best_ema=0.0 after resume, so regular (0.6) wins over the true EMA best (0.8) —
checkpoint_best_total.pth is built from the wrong source. Use epoch number as a distinguisher: pre-resume EMA
was saved at epoch 3; regular was saved at epoch 1; total epoch must be 3 (EMA epoch) when EMA correctly wins.
"""
# Pre-resume epoch 1: regular best=0.6.
cb_pre = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95", run_test=False)
pl_module = _make_pl_module()
trainer_ep1 = _make_trainer({"val/mAP_50_95": 0.6, "val/ema_mAP_50_95": 0.5}, current_epoch=1)
cb_pre.on_validation_end(trainer_ep1, pl_module)
# Pre-resume epoch 3: EMA best=0.8 (better than epoch-1 EMA=0.5).
trainer_ep3 = _make_trainer({"val/mAP_50_95": 0.55, "val/ema_mAP_50_95": 0.8}, current_epoch=3)
cb_pre.on_validation_end(trainer_ep3, pl_module)
saved_state = cb_pre.state_dict()
# Resume: fresh callback, load state, run fit_end.
cb_resumed = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95", run_test=False)
cb_resumed.load_state_dict(saved_state)
# fit_end uses _best_ema to decide EMA vs regular winner.
# trainer_ep3 carries best_model_score=0.6 (regular) and _best_ema=0.8 (EMA wins).
cb_resumed.on_fit_end(trainer_ep3, pl_module)
total = tmp_path / "checkpoint_best_total.pth"
assert total.exists()
total_data = torch.load(total, map_location="cpu", weights_only=False)
# strip_checkpoint preserves the `loops` key.
# epoch_progress.current.completed == trainer.current_epoch + 1 at save time.
# EMA checkpoint was saved at epoch 3 → completed=4.
# Regular checkpoint was saved at epoch 1 → completed=2.
# If _best_ema was NOT restored (bug), regular wins → completed=2.
# If _best_ema IS restored (fix), EMA wins → completed=4.
completed = total_data["loops"]["fit_loop"]["epoch_progress"]["current"]["completed"]
assert completed == 4, (
"on_fit_end must select EMA (epoch 3, best=0.8) over regular (epoch 1, best=0.6); "
f"got epoch_completed={completed} — _best_ema not restored from state_dict"
)
@pytest.mark.parametrize(
("mutate_state", "expected_best_ema"),
[
pytest.param(
lambda state: state.pop("_best_ema"),
0.0,
id="missing_key",
),
pytest.param(
lambda state: state.__setitem__("_best_ema", int(1)),
1.0,
id="int_coercion",
),
pytest.param(
lambda state: state.__setitem__("_best_ema", str("0.75")),
0.75,
id="string_coercion",
),
],
)
def test_load_state_dict_backward_compat(self, tmp_path: Path, mutate_state, expected_best_ema: float) -> None:
"""load_state_dict() keeps backward-compatible _best_ema restoration behavior."""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
state = cb.state_dict()
mutate_state(state)
cb.load_state_dict(state)
assert isinstance(cb._best_ema, float)
assert cb._best_ema == expected_best_ema
@pytest.mark.parametrize(
"bad_value",
[
pytest.param(float("nan"), id="nan"),
pytest.param(float("inf"), id="inf"),
pytest.param(float("-inf"), id="neg_inf"),
],
)
def test_load_state_dict_non_finite_values(self, bad_value) -> None:
"""load_state_dict() resets non-finite persisted _best_ema values to 0.0."""
cb = BestModelCallback(output_dir=".")
cb._best_ema = 999.0
state = cb.state_dict()
state["_best_ema"] = bad_value
cb.load_state_dict(state)
assert cb._best_ema == 0.0
def test_state_dict_round_trip_preserves_all_three_smooth_fields(self, tmp_path: Path) -> None:
"""state_dict/load_state_dict round-trip preserves _best_ema, _smoothed_regular, and _best_raw_regular.
Scenario: user trains with smooth_alpha=0.5, two validation epochs advance all three accumulators,
then a fresh callback is restored from the persisted state — all three fields must survive unchanged.
"""
cb = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95", smooth_alpha=0.5)
pl_module = _make_pl_module()
# Epoch 0: raw=0.6 → smoothed=0.3, EMA=0.5 (saves EMA checkpoint)
trainer0 = _make_trainer({"val/mAP_50_95": 0.6, "val/ema_mAP_50_95": 0.5}, current_epoch=0)
trainer0.global_step = 1
cb.on_validation_end(trainer0, pl_module)
# Epoch 1: raw=0.4 → smoothed=0.35, EMA=0.7 > 0.5 (saves new EMA checkpoint); raw at smoothed-best=0.4
trainer1 = _make_trainer({"val/mAP_50_95": 0.4, "val/ema_mAP_50_95": 0.7}, current_epoch=1)
trainer1.global_step = 2
cb.on_validation_end(trainer1, pl_module)
state = cb.state_dict()
cb_resumed = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95", smooth_alpha=0.5)
cb_resumed.load_state_dict(state)
assert cb_resumed._best_ema == pytest.approx(cb._best_ema), "_best_ema must survive round-trip"
assert cb_resumed._smoothed_regular == pytest.approx(cb._smoothed_regular), (
"_smoothed_regular must survive round-trip"
)
assert cb_resumed._best_raw_regular == pytest.approx(cb._best_raw_regular), (
"_best_raw_regular must survive round-trip"
)
def test_load_state_dict_does_not_mutate_caller_dict(self, tmp_path: Path) -> None:
"""load_state_dict must not pop or mutate the caller-supplied dict."""
cb1 = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
cb1._best_ema = 0.75
original_sd = cb1.state_dict()
saved = dict(original_sd)
cb2 = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
cb2.load_state_dict(original_sd)
assert original_sd["_best_ema"] == 0.75
assert original_sd == saved
def test_state_dict_roundtrip_initial_zero(self, tmp_path: Path) -> None:
"""state_dict/load_state_dict round-trips the default _best_ema=0.0 correctly."""
cb1 = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
sd = cb1.state_dict()
assert "_best_ema" in sd
assert sd["_best_ema"] == 0.0
cb2 = BestModelCallback(output_dir=str(tmp_path), monitor_ema="val/ema_mAP_50_95")
cb2.load_state_dict(sd)
assert cb2._best_ema == 0.0
# ---------------------------------------------------------------------------
# TestBestModelSmoothAlpha
# ---------------------------------------------------------------------------
class TestBestModelSmoothAlpha:
"""Verify ``smooth_alpha`` smooths the monitored metric before checkpoint comparison.
Without smoothing, a single noisy spike (e.g. 0.9 surrounded by 0.3-0.4 values) locks the best checkpoint to that
spike epoch. With ``smooth_alpha=0.5`` the EMA of the metric is what the parent ModelCheckpoint compares, so the
later, steadily-improving epochs can overtake the early spike.
"""
@pytest.mark.parametrize(
("invalid_value", "exc_type"),
[
pytest.param(True, TypeError, id="bool_true"),
pytest.param("0.5", TypeError, id="string"),
pytest.param(-0.1, ValueError, id="negative"),
pytest.param(1.0, ValueError, id="exactly_one"),
pytest.param(1.5, ValueError, id="greater_than_one"),
pytest.param(float("nan"), ValueError, id="nan"),
pytest.param(float("inf"), ValueError, id="inf"),
],
)
def test_invalid_smooth_alpha_raises(
self, tmp_path: Path, invalid_value: object, exc_type: type[Exception]
) -> None:
"""BestModelCallback raises TypeError for non-numeric and ValueError for out-of-range smooth_alpha."""
with pytest.raises(exc_type):
BestModelCallback(output_dir=str(tmp_path), smooth_alpha=invalid_value) # type: ignore[arg-type]
def test_smoothing_disabled_when_alpha_zero(self, tmp_path: Path) -> None:
"""With smooth_alpha=0.0 (default), the raw metric drives checkpoint selection unchanged."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.0)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.42}, current_epoch=0)
cb.on_validation_end(trainer, pl_module)
# best_model_score reflects the raw value because no smoothing was applied.
assert cb.best_model_score is not None
assert cb.best_model_score.item() == pytest.approx(0.42)
# The smoothing accumulator must stay at its zero default when smoothing is disabled.
assert cb._smoothed_regular == 0.0
def test_smoothing_uses_ema_of_raw_metric(self, tmp_path: Path) -> None:
"""With smooth_alpha=0.5, best_model_score reflects the smoothed EMA, not the raw value."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=0)
cb.on_validation_end(trainer, pl_module)
# EMA: 0.5 * 0.0 + 0.5 * 0.8 = 0.4 — the smoothed value, not the raw 0.8.
assert cb._smoothed_regular == pytest.approx(0.4)
assert cb.best_model_score is not None
assert cb.best_model_score.item() == pytest.approx(0.4)
def test_smoothing_prefers_steady_improvement_over_early_spike(self, tmp_path: Path) -> None:
"""A late, smoothed run that beats the smoothed early spike wins the best checkpoint."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
# Epoch 0: raw=0.8 → smoothed=0.4
# Epoch 1: raw=0.3 → smoothed=0.35
# Epoch 2: raw=0.4 → smoothed=0.375
# Epoch 3: raw=0.6 → smoothed=0.4875 (overtakes the early spike's smoothed 0.4)
raw_per_epoch = [0.8, 0.3, 0.4, 0.6]
for epoch, value in enumerate(raw_per_epoch):
trainer = _make_trainer({"val/mAP_50_95": value}, current_epoch=epoch)
# Distinct global_step per epoch so ModelCheckpoint does not skip saves on
# the second-and-later calls due to its same-step guard.
trainer.global_step = epoch + 1
cb.on_validation_end(trainer, pl_module)
# The best smoothed value should be epoch-3's 0.4875, not epoch-0's 0.4.
assert cb._smoothed_regular == pytest.approx(0.4875)
assert cb.best_model_score is not None
assert cb.best_model_score.item() == pytest.approx(0.4875)
def test_callback_metrics_restored_after_super_call(self, tmp_path: Path) -> None:
"""trainer.callback_metrics[monitor] must hold the original raw tensor after on_validation_end returns."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=0)
raw_tensor = trainer.callback_metrics["val/mAP_50_95"]
cb.on_validation_end(trainer, pl_module)
# The original tensor (raw value 0.8) must be back in callback_metrics so other
# callbacks and metrics.csv see the unsmoothed value.
assert trainer.callback_metrics["val/mAP_50_95"] is raw_tensor
assert trainer.callback_metrics["val/mAP_50_95"].item() == pytest.approx(0.8)
def test_state_dict_includes_smoothed_regular(self, tmp_path: Path) -> None:
"""state_dict() must include _smoothed_regular so resumed training continues from the correct EMA value."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=0)
cb.on_validation_end(trainer, pl_module)
state = cb.state_dict()
assert "_smoothed_regular" in state
assert state["_smoothed_regular"] == pytest.approx(0.4)
def test_load_state_dict_restores_smoothed_regular(self, tmp_path: Path) -> None:
"""load_state_dict() must restore _smoothed_regular so the EMA continues from the persisted value."""
cb_first = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=0)
cb_first.on_validation_end(trainer, pl_module)
saved_state = cb_first.state_dict()
cb_resumed = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
cb_resumed.load_state_dict(saved_state)
assert cb_resumed._smoothed_regular == pytest.approx(0.4)
@pytest.mark.parametrize(
"bad_value",
[
pytest.param(float("nan"), id="nan"),
pytest.param(float("inf"), id="inf"),
pytest.param(float("-inf"), id="neg_inf"),
],
)
def test_load_state_dict_non_finite_smoothed_regular_resets_to_zero(self, tmp_path: Path, bad_value: float) -> None:
"""load_state_dict() resets non-finite persisted _smoothed_regular values to 0.0."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
state = cb.state_dict()
state["_smoothed_regular"] = bad_value
cb.load_state_dict(state)
assert cb._smoothed_regular == 0.0
def test_load_state_dict_smoothed_regular_missing_key_defaults_to_zero(self, tmp_path: Path) -> None:
"""load_state_dict() defaults _smoothed_regular to 0.0 when key is absent (backward compat)."""
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
cb._smoothed_regular = 0.42
state = cb.state_dict()
state.pop("_smoothed_regular")
cb.load_state_dict(state)
assert cb._smoothed_regular == 0.0
@pytest.mark.parametrize(
"skip_epochs",
[
pytest.param(2, id="skip_two"),
],
)
def test_ema_accumulator_warms_up_during_skip_window(self, tmp_path: Path, skip_epochs: int) -> None:
"""_smoothed_regular is pre-warmed by skip-window epochs so epoch 2 starts from a non-zero EMA.
Scenario: skip_best_epochs=2, smooth_alpha=0.5, metric=0.5 each epoch.
The EMA accumulator must update on epochs 0 and 1 (skip window) so that by epoch 2 it
is already non-zero. No checkpoint file should be written during the skip window.
"""
cb = BestModelCallback(
output_dir=str(tmp_path), monitor_regular="val/mAP_50_95", smooth_alpha=0.5, skip_best_epochs=skip_epochs
)
pl_module = _make_pl_module()
# Epochs 0 and 1 are inside the skip window.
for epoch in range(skip_epochs):
trainer_skip = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=epoch)
trainer_skip.global_step = epoch + 1
cb.on_validation_end(trainer_skip, pl_module)
# No checkpoint must have been written during the skip window.
assert not (tmp_path / "checkpoint_best_regular.pth").exists(), (
"checkpoint must not be written during the skip window (epochs 0 and 1)"
)
# Epoch 2 is the first eligible epoch — accumulator must be pre-warmed.
trainer_eligible = _make_trainer({"val/mAP_50_95": 0.5}, current_epoch=skip_epochs)
trainer_eligible.global_step = skip_epochs + 1
cb.on_validation_end(trainer_eligible, pl_module)
assert cb._smoothed_regular > 0.0, (
"_smoothed_regular must be > 0.0 at epoch 2 because the skip-window epochs warmed the EMA"
)
def test_callback_metrics_restored_when_super_raises(self, tmp_path: Path) -> None:
"""trainer.callback_metrics[monitor] is restored in the finally block even when super() raises."""
from unittest.mock import patch
cb = BestModelCallback(output_dir=str(tmp_path), smooth_alpha=0.5)
pl_module = _make_pl_module()
trainer = _make_trainer({"val/mAP_50_95": 0.8}, current_epoch=0)
raw_tensor = trainer.callback_metrics["val/mAP_50_95"]
with patch(
"rfdetr.training.callbacks.best_model.ModelCheckpoint.on_validation_end",
side_effect=RuntimeError("simulated failure"),
):
with pytest.raises(RuntimeError, match="simulated failure"):
cb.on_validation_end(trainer, pl_module)
assert trainer.callback_metrics["val/mAP_50_95"] is raw_tensor
# ---------------------------------------------------------------------------
# TestCheckpointNotes
# ---------------------------------------------------------------------------
class TestCheckpointNotes:
"""Verify user-supplied notes are persisted in .pth checkpoint files."""
@pytest.mark.parametrize(
"notes",
[
pytest.param("simple string", id="string"),
pytest.param({"date": "2026-01-01", "labeller": "Alice"}, id="dict"),
pytest.param(["class_a", "class_b"], id="list"),
pytest.param(42, id="int"),
],
)
def test_notes_accessible_via_args_dict(self, tmp_path: Path, notes: object) -> None:
"""Notes supplied via TrainConfig are accessible under checkpoint['args']['notes']."""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False, notes=notes)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["notes"] == notes
def test_notes_absent_when_not_provided(self, tmp_path: Path) -> None:
"""When notes=None (default), no top-level 'notes' key is written to checkpoint."""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert "notes" not in checkpoint
@pytest.mark.parametrize(
"notes",
[
pytest.param("", id="empty_string"),
pytest.param({}, id="empty_dict"),
pytest.param([], id="empty_list"),
pytest.param(0, id="zero"),
pytest.param(False, id="false"),
],
)
def test_falsy_notes_stored_in_args_dict(self, tmp_path: Path, notes: object) -> None:
"""Falsy but non-None notes values are preserved in checkpoint['args']['notes']."""
from rfdetr.config import TrainConfig
cb = BestModelCallback(output_dir=str(tmp_path))
trainer = _make_trainer({"val/mAP_50_95": 0.5})
pl_module = _make_pl_module()
pl_module.train_config = TrainConfig(dataset_dir=str(tmp_path / "ds"), tensorboard=False, notes=notes)
cb.on_validation_end(trainer, pl_module)
checkpoint = torch.load(
tmp_path / "checkpoint_best_regular.pth",
map_location="cpu",
weights_only=False,
)
assert checkpoint["args"]["notes"] == notes
# ---------------------------------------------------------------------------
# _serialize_model_config — schema sync from live weights
# ---------------------------------------------------------------------------
def _make_kp_active_mask(schema: list[int]) -> torch.Tensor:
"""Build a bool _kp_active_mask tensor from a keypoints-per-class schema list."""
max_kp = max(schema) if schema else 0
mask = torch.zeros(len(schema), max_kp, dtype=torch.bool)
for cls_idx, n in enumerate(schema):
mask[cls_idx, :n] = True
return mask
class TestSerializeModelConfig:
"""Verify _serialize_model_config syncs schema-critical fields from live model weights."""
def test_syncs_keypoint_schema_from_kp_active_mask(self) -> None:
"""Stale num_keypoints_per_class in model_config is overridden by _kp_active_mask from weights."""
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_dump.return_value = {
"num_keypoints_per_class": [0, 17],
"num_classes": 2,
}
pl_module.model.state_dict.return_value = {
"_kp_active_mask": _make_kp_active_mask([0, 33]),
}
result = BestModelCallback._serialize_model_config(pl_module)
assert result is not None
assert result["num_keypoints_per_class"] == [0, 33]
def test_syncs_num_classes_from_class_embed_weight(self) -> None:
"""Stale num_classes in model_config is overridden by class_embed.weight.shape[0] from weights."""
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_dump.return_value = {
"num_keypoints_per_class": [0, 33],
"num_classes": 90,
}
pl_module.model.state_dict.return_value = {
"class_embed.weight": torch.zeros(3, 256),
}
result = BestModelCallback._serialize_model_config(pl_module)
assert result is not None
assert result["num_classes"] == 2
def test_no_sync_when_kp_mask_absent(self) -> None:
"""num_keypoints_per_class is left unchanged when model has no _kp_active_mask."""
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_dump.return_value = {
"num_keypoints_per_class": [0, 17],
"num_classes": 2,
}
pl_module.model.state_dict.return_value = {"w": torch.zeros(1)}
result = BestModelCallback._serialize_model_config(pl_module)
assert result is not None
assert result["num_keypoints_per_class"] == [0, 17]
def test_no_sync_when_field_absent_from_dumped_config(self) -> None:
"""_kp_active_mask in weights does not insert num_keypoints_per_class when key absent from config."""
pl_module = _make_pl_module()
pl_module.model_config = MagicMock()
pl_module.model_config.model_dump.return_value = {"num_classes": 2}
pl_module.model.state_dict.return_value = {
"_kp_active_mask": _make_kp_active_mask([0, 33]),
}
result = BestModelCallback._serialize_model_config(pl_module)
assert result is not None
assert "num_keypoints_per_class" not in result
def test_returns_none_when_model_config_absent(self) -> None:
"""Returns None when pl_module exposes no model_config."""
pl_module = _make_pl_module()
pl_module.model_config = None
result = BestModelCallback._serialize_model_config(pl_module)
assert result is None
def test_returns_dict_directly_for_dict_model_config(self) -> None:
"""Returns a dict model_config as-is without any weight-based sync."""
pl_module = _make_pl_module()
raw = {"num_classes": 3, "num_keypoints_per_class": [0, 5]}
pl_module.model_config = raw
result = BestModelCallback._serialize_model_config(pl_module)
assert result is raw
# ---------------------------------------------------------------------------
# TestOnFitEndEMASwapSuppression
# ---------------------------------------------------------------------------
class _TestStepLinearModule(LightningModule):
"""Real module with ``test_step`` and a tiny linear model for weight-identity assertions."""
def __init__(self) -> None:
super().__init__()
self.model = torch.nn.Linear(1, 1, bias=False)
self.train_config = {"lr": 0.001}
def test_step(self, batch: object, batch_idx: int) -> None:
"""No-op test step so BestModelCallback.on_fit_end runs trainer.test()."""
def _fill_module_weight(pl_module: LightningModule, value: float) -> None:
"""Set the single linear weight of *pl_module* to *value* in-place."""
with torch.no_grad():
pl_module.model.weight.fill_(value)
class TestOnFitEndEMASwapSuppression:
"""The fit-end test run must evaluate the best checkpoint, not the final EMA weights.
Regression tests: ``BestModelCallback.on_fit_end`` loads ``checkpoint_best_total.pth`` into the module and then
calls ``trainer.test()`` — but ``RFDETREMACallback.on_test_epoch_start`` used to swap in the final EMA weights,
silently overwriting the just-loaded best weights for the whole test run.
"""
@staticmethod
def _build_fit_end_scenario(
tmp_path: Path,
) -> tuple[BestModelCallback, RFDETREMACallback, LightningModule, MagicMock, list[torch.Tensor]]:
"""Arrange best=3.0 checkpoint, EMA=5.0 average model, live=7.0 weights, and a hook-simulating trainer."""
from torch.optim.swa_utils import AveragedModel
pl_module = _TestStepLinearModule()
cb = BestModelCallback(output_dir=str(tmp_path), run_test=True)
ema_cb = RFDETREMACallback()
# Best checkpoint saved at weight 3.0.
_fill_module_weight(pl_module, 3.0)
trainer = _make_trainer({"val/mAP_50_95": 0.5})
cb.on_validation_end(trainer, pl_module)
# EMA average model captured at weight 5.0.
_fill_module_weight(pl_module, 5.0)
ema_cb._average_model = AveragedModel(
model=pl_module,
use_buffers=True,
avg_fn=ema_cb._avg_fn,
)
ema_cb._average_model.eval()
# Final live weights end training at 7.0.
_fill_module_weight(pl_module, 7.0)
trainer.callbacks = [ema_cb, cb]
observed_weights: list[torch.Tensor] = []
def _fake_test(module: object, datamodule: object = None, verbose: bool = False) -> None:
# Simulate the PTL test loop invoking the EMA callback's test hooks.
ema_cb.on_test_epoch_start(trainer, pl_module)
observed_weights.append(pl_module.model.weight.detach().clone())
ema_cb.on_test_epoch_end(trainer, pl_module)
trainer.test = MagicMock(side_effect=_fake_test)
return cb, ema_cb, pl_module, trainer, observed_weights
def test_fit_end_test_runs_on_best_weights_not_ema(self, tmp_path: Path) -> None:
"""During the fit-end test run the module must hold the best checkpoint weights (3.0), not EMA (5.0)."""
cb, _ema_cb, pl_module, trainer, observed_weights = self._build_fit_end_scenario(tmp_path)
cb.on_fit_end(trainer, pl_module)
assert observed_weights, "trainer.test() must have been invoked"
expected = torch.full_like(observed_weights[0], 3.0)
assert torch.allclose(observed_weights[0], expected), (
f"test ran with weight {observed_weights[0].item():.1f}; expected best-checkpoint weight 3.0"
)
def test_ema_swap_suppression_cleared_after_fit_end(self, tmp_path: Path) -> None:
"""After on_fit_end the EMA callback must swap again for standalone trainer.test() runs."""
cb, ema_cb, pl_module, trainer, _observed = self._build_fit_end_scenario(tmp_path)
cb.on_fit_end(trainer, pl_module)
assert ema_cb.suppress_test_swap is False
def test_ema_swap_suppression_cleared_when_test_raises(self, tmp_path: Path) -> None:
"""Suppression must be lifted even when trainer.test() raises."""
cb, ema_cb, pl_module, trainer, _observed = self._build_fit_end_scenario(tmp_path)
trainer.test = MagicMock(side_effect=RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
cb.on_fit_end(trainer, pl_module)
assert ema_cb.suppress_test_swap is False