perf(config): default optional validation work off (#1372)

* perf(config): default optional validation work off
* logic: detect _monitor_ema consumers, log auto-skip once, cache resolved bool per epoch
* tests: cover val-loss consumer-detection edge cases and compute_val_loss coercion
* docs: document conditional per-class AP, default-flip CHANGELOG/migration notes
* lint: auto-fix violations after resolve cycle

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Jirka Borovec
2026-08-20 01:16:15 +02:00
committed by GitHub
parent d3b87f8072
commit 00639715a7
12 changed files with 510 additions and 55 deletions
+2
View File
@@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Training now skips PyTorch Lightning's pre-training sanity validation batches by default; set `TrainConfig(num_sanity_val_steps=N)` to restore it. Training loss component metrics are emitted once per epoch, with decoder and encoder auxiliary terms compacted into `train/<term>_aux` — 17 to 9 tracked metric keys per microbatch on the default `RFDETRSmall` config; `train/loss` preserves its `train_log_on_step` behavior, but component-level metrics no longer honor `train_log_on_step` by default — set `TrainConfig(compact_train_metrics=False)` to restore per-layer keys and `train_log_on_step` honoring for them. Learning-rate metrics now emit only for optimizer updates, including a final partial gradient-accumulation window, cutting LR log calls by about 75% at the default accumulation of four. These are call-count reductions in Lightning's metric bookkeeping, not a measured timing or memory improvement. Update dashboard queries that consume layer-specific auxiliary keys or assume one learning-rate point per microbatch.
- `TrainConfig.log_per_class_metrics` now defaults to `False` (was `True`), skipping per-class AP computation during validation/test unless explicitly re-enabled. `TrainConfig.compute_val_loss` now defaults to `"auto"` (was `True`): validation loss is computed only when something actually consumes it — a `ReduceLROnPlateau` scheduler monitoring `val/loss`, or a callback (e.g. `ModelCheckpoint(monitor="val/loss")`, early stopping) that requires it — and is skipped otherwise. For a default run with no such consumer, `"auto"` resolves to skip: `val/loss` no longer appears in `metrics.csv`, TensorBoard, or W&B for logger-only consumers that previously read it without configuring a monitor. Set `compute_val_loss=True` to force it back on unconditionally.
### Fixed
- Fixed TFLite export failure when `onnx2tf` could not resolve the installed `onnxsim` console script from a non-activated virtual environment. `onnx2tf` invokes the bare `onnxsim` name; if that lookup raises `FileNotFoundError` it logs `Failed to optimize the onnx file` (the same warning also appears in working runs). On a stock `RFDETRSmall()` export this then failed with `RuntimeError: onnx2tf conversion failed: Output tensors of a Functional model must be the output of a TensorFlow Layer`. RF-DETR now temporarily adds the running interpreter's script directory to `PATH` during conversion. ([#1365](https://github.com/roboflow/rf-detr/issues/1365))
+25 -3
View File
@@ -7,13 +7,35 @@ description: Per-version migration guide for RF-DETR. Covers breaking changes an
Read each section between your current version and your target — every section covers only the delta between two adjacent releases.
```
1.4.x → 1.5 → 1.6 → 1.7 → 1.8 → 1.9
1.4.x → 1.5 → 1.6 → 1.7 → 1.8 → 1.9 → 1.10
```
You can apply all changes in one go; working through sections one release at a time and verifying between each step is optional but makes failures easier to isolate. Deprecated APIs emit a `DeprecationWarning` until the version marked for removal. See the [Changelog](../changelog.md) for the full list of changes in each release.
---
## Upgrade 1.9 → 1.10
### Breaking changes
!!! warning "Breaking: `log_per_class_metrics` now defaults to `False`"
`TrainConfig.log_per_class_metrics` defaults to `False` (was `True`). Per-class AP keys (`test/AP/<class>`, `val/AP/<class>`) are no longer emitted by default — validation/test now skip that per-class computation. Set it explicitly to restore the old behavior:
```python
train_config = TrainConfig(log_per_class_metrics=True)
```
!!! warning "Breaking: `compute_val_loss` now defaults to `\"auto\"`"
`TrainConfig.compute_val_loss` defaults to `"auto"` (was `True`). In `"auto"` mode, validation loss is computed only when something actually consumes it — a `ReduceLROnPlateau` scheduler monitoring `val/loss`, or a callback (e.g. `ModelCheckpoint(monitor="val/loss")`, early stopping) that requires it — and is skipped otherwise. For a default run with no such consumer, `val/loss` no longer appears in `metrics.csv`, TensorBoard, or W&B. Set it explicitly to restore the old unconditional behavior:
```python
train_config = TrainConfig(compute_val_loss=True)
```
---
## Upgrade 1.8 → 1.9
### Breaking changes
@@ -54,13 +76,13 @@ You can apply all changes in one go; working through sections one release at a t
```python
from rfdetr.datasets.aug_configs import AUG_CONFIG
train_config = TrainConfig(aug_config=AUG_CONFIG, ...)
train_config = TrainConfig(aug_config=AUG_CONFIG)
```
Installing `rfdetr[augment]` alone is **not** sufficient to pin this behaviour — with Albumentations installed, `augmentation_backend="auto"`/`"cpu"` (the default) auto-selects Albumentations for you, but identical code on a machine without `[augment]` installed silently falls back to torchvision instead. The only setting that pins resize behaviour regardless of what is installed is:
```python
train_config = TrainConfig(augmentation_backend="torchvision", ...)
train_config = TrainConfig(augmentation_backend="torchvision")
```
### Removed
+27 -25
View File
@@ -128,7 +128,7 @@ train_config = TrainConfig(
train_config = TrainConfig(..., lr_scheduler=functools.partial(torch.optim.lr_scheduler.StepLR, step_size=30))
```
With `warmup_epochs > 0`, an explicit scheduler is automatically prepended with a linear warmup ramp via `SequentialLR` (managed presets bake warmup into their own schedule). `ReduceLROnPlateau` is special: it cannot be warmup-wrapped, always steps once per epoch, and reads the metric named by `lr_scheduler_monitor` (default `"val/loss"`). Use `lr_scheduler_interval="epoch"` to step other explicit schedulers per epoch instead of per optimizer step.
With `warmup_epochs > 0`, an explicit scheduler is automatically prepended with a linear warmup ramp via `SequentialLR` (managed presets bake warmup into their own schedule). `ReduceLROnPlateau` is special: it cannot be warmup-wrapped, always steps once per epoch, and reads the metric named by `lr_scheduler_monitor` (default `"val/loss"`). The default `compute_val_loss="auto"` preserves `val/loss` when that monitor is used. Use `lr_scheduler_interval="epoch"` to step other explicit schedulers per epoch instead of per optimizer step.
See [Training parameters — scheduler](training-parameters.md#scheduler-and-regularization) for the full parameter reference.
@@ -266,7 +266,9 @@ trainer.fit(module, datamodule, ckpt_path="new_checkpoint.ckpt")
trainer.validate(module, datamodule)
```
Runs one full validation pass and logs `val/mAP_50_95`, `val/mAP_50`, `val/F1`, and per-class AP metrics to all active loggers.
Runs one full validation pass and logs `val/mAP_50_95`, `val/mAP_50`, and `val/F1` to all active loggers. Set `log_per_class_metrics=True` to include per-class AP metrics.
A bare `trainer.validate(module, datamodule)` call like this configures no optimizers, so with `TrainConfig.compute_val_loss` at its default `"auto"` no `ReduceLROnPlateau`/`val/loss`-monitoring callback is present to trigger it — `val/loss` is absent from the returned metrics dict. Passing a callback that monitors `val/loss` (e.g. `ModelCheckpoint(monitor="val/loss")`) still causes `val/loss` to be computed and logged, since `"auto"` reacts to the configured callbacks, not just the optimizer.
### Inference with the data pipeline
@@ -416,29 +418,29 @@ All logged keys (`train/loss`, `val/mAP_50_95`, `val/keypoint_map_50_95`, `val/F
## Logged metrics reference
| Key | When logged | Description |
| ------------------------ | --------------------------------------------------- | --------------------------------------------------------- |
| `train/loss` | Epoch; also each step when `train_log_on_step=True` | Total weighted training loss |
| `train/<term>` | Each epoch | Base-loss metric (e.g. `train/loss_bbox`) |
| `train/<term>_aux` | Each epoch | Sum of decoder and encoder auxiliary terms for that loss |
| `train/lr` | Each optimizer step | First optimizer parameter group's learning rate |
| `train/lr_min` | Each optimizer step | Minimum learning rate across optimizer parameter groups |
| `train/lr_max` | Each optimizer step | Maximum learning rate across optimizer parameter groups |
| `val/loss` | Each epoch | Validation loss (if `train_config.compute_val_loss=True`) |
| `val/mAP_50_95` | Each eval epoch | COCO box mAP@[.50:.05:.95] |
| `val/mAP_50` | Each eval epoch | COCO box mAP@.50 |
| `val/mAP_75` | Each eval epoch | COCO box mAP@.75 |
| `val/mAR` | Each eval epoch | COCO mean average recall |
| `val/ema_mAP_50_95` | Each eval epoch | EMA-model mAP@[.50:.05:.95] (if EMA active) |
| `val/F1` | Each eval epoch | Macro F1 at best confidence threshold |
| `val/precision` | Each eval epoch | Precision at best F1 threshold |
| `val/recall` | Each eval epoch | Recall at best F1 threshold |
| `val/AP/<class>` | Each eval epoch | Per-class AP (if `log_per_class_metrics=True`) |
| `val/segm_mAP_50_95` | Each eval epoch | Segmentation mAP (segmentation models only) |
| `val/segm_mAP_50` | Each eval epoch | Segmentation mAP@.50 (segmentation models only) |
| `val/keypoint_map_50_95` | Each eval epoch | COCO keypoint AP@[.50:.05:.95] (keypoint preview only) |
| `val/keypoint_map_50` | Each eval epoch | COCO keypoint AP@.50 (keypoint preview only) |
| `test/*` | After `trainer.test()` | Mirror of `val/*` keys |
| Key | When logged | Description |
| ------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `train/loss` | Epoch; also each step when `train_log_on_step=True` | Total weighted training loss |
| `train/<term>` | Each epoch | Base-loss metric (e.g. `train/loss_bbox`) |
| `train/<term>_aux` | Each epoch | Sum of decoder and encoder auxiliary terms for that loss |
| `train/lr` | Each optimizer step | First optimizer parameter group's learning rate |
| `train/lr_min` | Each optimizer step | Minimum learning rate across optimizer parameter groups |
| `train/lr_max` | Each optimizer step | Maximum learning rate across optimizer parameter groups |
| `val/loss` | Each epoch when required | Validation loss (if `compute_val_loss=True` or an automatic consumer monitors it) |
| `val/mAP_50_95` | Each eval epoch | COCO box mAP@[.50:.05:.95] |
| `val/mAP_50` | Each eval epoch | COCO box mAP@.50 |
| `val/mAP_75` | Each eval epoch | COCO box mAP@.75 |
| `val/mAR` | Each eval epoch | COCO mean average recall |
| `val/ema_mAP_50_95` | Each eval epoch | EMA-model mAP@[.50:.05:.95] (if EMA active) |
| `val/F1` | Each eval epoch | Macro F1 at best confidence threshold |
| `val/precision` | Each eval epoch | Precision at best F1 threshold |
| `val/recall` | Each eval epoch | Recall at best F1 threshold |
| `val/AP/<class>` | Each eval epoch | Per-class AP (if `log_per_class_metrics=True`; aggregate mAP/mAR/F1 remain available otherwise) |
| `val/segm_mAP_50_95` | Each eval epoch | Segmentation mAP (segmentation models only) |
| `val/segm_mAP_50` | Each eval epoch | Segmentation mAP@.50 (segmentation models only) |
| `val/keypoint_map_50_95` | Each eval epoch | COCO keypoint AP@[.50:.05:.95] (keypoint preview only) |
| `val/keypoint_map_50` | Each eval epoch | COCO keypoint AP@.50 (keypoint preview only) |
| `test/*` | After `trainer.test()` | Mirror of `val/*` keys, except `test/loss`: `compute_test_loss` defaults to `True` unconditionally (unlike `compute_val_loss`'s `"auto"` default), so a default run emits `test/loss` even when `val/loss` is absent |
With gradient accumulation, learning-rate metrics are emitted only when an optimizer update occurs, including a partial final accumulation window. Layer-specific auxiliary loss keys (such as `train/loss_bbox_0` and `train/loss_bbox_enc`) are replaced by their compact `train/loss_bbox_aux` aggregate.
+19 -11
View File
@@ -206,11 +206,19 @@ model.train(
| ---------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eval_max_dets` | `int` | `500` | Maximum detections per image for detection/segmentation COCO AP and AR evaluation. Keypoint AP/AR uses fixed COCO `maxDets=20`; lower values speed up detection/segmentation evaluation. |
| `eval_interval` | `int` | `1` | Skip the whole COCO validation loop (forward pass, metric compute, EMA forward) on epochs that aren't a multiple of N, to reduce evaluation overhead during long training runs. The final epoch always validates regardless of this setting. |
| `log_per_class_metrics` | `bool` | `True` | Log per-class AP metrics to the console and loggers. Disable to also skip the underlying per-class `torchmetrics` computation (not just its display), reducing per-epoch compute when there are many classes. |
| `log_per_class_metrics` | `bool` | `False` | Log per-class AP metrics to the console and loggers. Enable it to also run the underlying per-class `torchmetrics` computation. Aggregate mAP/mAR and F1 metrics remain available either way. |
| `eval_ema_only` | `bool` | `False` | Forward through the EMA model only during validation, skipping the duplicate base-model pass. Requires `use_ema=True`. See [EMA](#ema-exponential-moving-average). |
| `eval_masks_head_resolution` | `bool` | `False` | Segmentation only. Skip upsampling predicted masks to full image resolution during validation, comparing at the mask head's native (lower) resolution instead. `val/segm_mAP` is then not comparable to a full-resolution run. No effect on `RFDETR.predict` output. |
| `progress_bar` | str \| bool \| None | `None` | Progress bar style: `"tqdm"`, `"rich"`, or `None`. Legacy booleans are still accepted. `"rich"` leaves each completed epoch's bar in the terminal history instead of overwriting it. |
### Validation performance
- `log_per_class_metrics=False` is the default. It retains aggregate mAP/mAR and F1/precision/recall while omitting per-class rows and their underlying per-class metric computation. Set it to `True` when per-class reporting is needed.
- `compute_val_loss="auto"` is the default. It computes `val/loss` only when a configured scheduler, checkpoint, or early-stopping callback monitors that key. Set it to `True` to always log validation loss or `False` to disable it; `False` is rejected when a configured consumer monitors `val/loss`.
- `eval_ema_only=True` requires `use_ema=True` and evaluates only EMA weights. Regular `val/mAP_*` keys are absent, EMA values use `val/ema_*`, and best-checkpoint or early-stopping routing follows the available EMA metric. `val/F1` remains under its regular key.
- `eval_interval` controls validation frequency, not the cost of a validation epoch: non-evaluation epochs skip the complete validation loop, while the final epoch always evaluates.
- Lowering `eval_max_dets` can reduce detection/segmentation evaluation work, but it also changes AP and AR semantics. Keypoint evaluation keeps COCO `maxDets=20`.
## Keypoint Preview Parameters
These parameters apply when training `RFDETRKeypointPreview` on COCO keypoint annotations or Ultralytics YOLO pose labels.
@@ -260,14 +268,14 @@ The parameters below are available for fine-grained control over training behavi
### Runtime and Accelerator
| Parameter | Type | Default | Description |
| ---------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `accelerator` | `str` | `"auto"` | PyTorch Lightning accelerator selection. `"auto"` picks GPU if available, then MPS, then CPU. |
| `seed` | `int` | `None` | Global random seed for reproducibility. `None` means no fixed seed is set. |
| `fp16_eval` | `bool` | `False` | Run evaluation passes in FP16 precision. Reduces memory usage but may lower numerical precision. |
| `compute_val_loss` | `bool` | `True` | Compute and log the detection loss on the validation set each epoch. |
| `compute_test_loss` | `bool` | `True` | Compute and log the detection loss during the final test run. |
| `num_sanity_val_steps` | `int` | `0` | PyTorch Lightning sanity-check validation batches run before training starts. `0` disables it (the default); increase to catch val-path errors before a full epoch runs. |
| Parameter | Type | Default | Description |
| ---------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `accelerator` | `str` | `"auto"` | PyTorch Lightning accelerator selection. `"auto"` picks GPU if available, then MPS, then CPU. |
| `seed` | `int` | `None` | Global random seed for reproducibility. `None` means no fixed seed is set. |
| `fp16_eval` | `bool` | `False` | Run evaluation passes in FP16 precision. Reduces memory usage but may lower numerical precision. |
| `compute_val_loss` | `bool \| "auto"` | `"auto"` | Compute and log validation loss only when a configured consumer monitors `val/loss`. Set `True` to always compute it or `False` to disable it. |
| `compute_test_loss` | `bool` | `True` | Compute and log the detection loss during the final test run. |
| `num_sanity_val_steps` | `int` | `0` | PyTorch Lightning sanity-check validation batches run before training starts. `0` disables it (the default); increase to catch val-path errors before a full epoch runs. |
### DataLoader Tuning
@@ -308,7 +316,7 @@ Below is a summary table of all training parameters:
| `best_model_metric` | `Literal["map","mar"]` | "map" | Metric family for best-checkpoint selection and early stopping — mAP or mAR. |
| `eval_max_dets` | int | 500 | Maximum detections per image for detection/segmentation COCO AP and AR evaluation. Keypoint AP/AR uses fixed COCO `maxDets=20`. |
| `eval_interval` | int | 1 | Skip the whole validation loop on epochs not a multiple of N; final epoch always validates. |
| `log_per_class_metrics` | bool | True | Log per-class AP metrics; disable to also skip the underlying per-class compute. |
| `log_per_class_metrics` | bool | False | Log per-class AP metrics; enable to run the underlying per-class compute. |
| `eval_ema_only` | bool | False | Forward through the EMA model only during validation. Requires use_ema=True. |
| `eval_masks_head_resolution` | bool | False | Segmentation only. Compare masks at native (lower) resolution instead of upsampling; not comparable across runs. |
| `progress_bar` | str \| bool \| None | None | Progress bar style: `"tqdm"`, `"rich"`, or `None`. Legacy booleans are still accepted. |
@@ -322,7 +330,7 @@ Below is a summary table of all training parameters:
| `lr_drop` | int | 100 | Deprecated — use lr_scheduler_kwargs["lr_drop"]. Epoch at which the "step" preset drops the LR by 10x. |
| `warmup_epochs` | float | 0.0 | Number of linear warmup epochs at the start of training. |
| `drop_path` | float | 0.0 | Stochastic depth drop-path rate for the backbone. |
| `compute_val_loss` | bool | True | Compute and log loss during validation. |
| `compute_val_loss` | bool \| "auto" | "auto" | Compute validation loss only for a configured `val/loss` consumer; `True` forces it and `False` disables it. |
| `compute_test_loss` | bool | True | Compute and log loss during the test run. |
| `num_sanity_val_steps` | int | 0 | PTL sanity-check validation batches run before training starts. 0 disables it; increase to catch val-path errors early. |
| `fp16_eval` | bool | False | Run evaluation in FP16 precision to reduce memory usage. |
+22 -2
View File
@@ -1113,7 +1113,7 @@ class TrainConfig(BaseConfig):
run_test: bool = False
eval_max_dets: int = 500
eval_interval: int = 1
log_per_class_metrics: bool = True
log_per_class_metrics: bool = False
# Segmentation only. Skip upsampling predicted masks to full image resolution during
# validation/test, returning them at the mask head's native (lower) resolution instead —
# cheaper, but ground-truth masks must then be compared at that same lower resolution
@@ -1245,7 +1245,9 @@ class TrainConfig(BaseConfig):
# Restores PTL's pre-training sanity-validation pass (0 = disabled, current default;
# increase to re-enable and catch val-path errors before a full epoch runs).
num_sanity_val_steps: int = 0
compute_val_loss: bool = True
compute_val_loss: bool | Literal["auto"] = "auto"
# No "auto" here: unlike val/loss, nothing (schedulers, callbacks) monitors test/loss, and
# trainer.test() runs once rather than every epoch, so consumer-based auto-detection doesn't apply.
compute_test_loss: bool = True
pin_memory: bool | None = None
persistent_workers: bool | None = None
@@ -1357,6 +1359,24 @@ class TrainConfig(BaseConfig):
raise ValueError("eval_ema_only=True requires use_ema=True.")
return self
@model_validator(mode="after")
def validate_explicit_val_loss_disable(self) -> "TrainConfig":
"""Reject disabling validation loss for a configured plateau loss monitor.
Reconstructable scheduler callables are normalized to their dotted path before this validator runs. Non-
reconstructable callables are checked after their concrete scheduler is created by ``RFDETRModelModule``.
"""
if (
self.compute_val_loss is False
and self.lr_scheduler == "torch.optim.lr_scheduler.ReduceLROnPlateau"
and self.lr_scheduler_monitor == "val/loss"
):
raise ValueError(
"compute_val_loss=False requires a non-val/loss monitor when "
"lr_scheduler is ReduceLROnPlateau. Set compute_val_loss=True or 'auto'."
)
return self
@model_validator(mode="after")
def validate_optimizer_kwargs(self) -> "TrainConfig":
"""Reserved optimizer kwargs are only rejected for managed (short-name) optimizers."""
+2 -1
View File
@@ -1083,7 +1083,8 @@ class RFDETR:
Returns:
Mapping of metric name to value for the evaluated split, e.g. ``{"test/mAP_50_95": ..., "test/mAP_50": ...,
"test/F1": ..., "test/AP/<class>": ...}``. Empty when the trainer returns no metrics.
"test/F1": ...}``. Per-class keys (``"test/AP/<class>"``) are included only when
``log_per_class_metrics=True`` (default ``False``). Empty when the trainer returns no metrics.
Raises:
ImportError: If training dependencies are not installed. Install with
+102 -4
View File
@@ -370,6 +370,12 @@ class RFDETRModelModule(LightningModule):
self._lr_scheduler_interval: str = "step"
self._lr_scheduler_monitor: str | None = None
self._accumulated_box_normalizer: Tensor | None = None
# One-shot guard for the notice announcing that the "auto" validation-loss policy resolved to skipping the
# loss; emitted from on_validation_epoch_start on the first real (non-sanity) validation epoch only.
self._logged_val_loss_skip_notice: bool = False
# Validation-loss policy resolved once per validation epoch by on_validation_epoch_start and read per batch by
# _should_compute_val_loss. None until that hook runs, so direct validation_step calls resolve it themselves.
self._resolved_compute_val_loss: bool | None = None
# Memoized loss_name -> aggregate train/ key (or None for a standalone key), built lazily by
# _aux_aggregate_map() and recomputed only when loss_dict's key set changes between calls.
self._aux_aggregate_cache: dict[str, str | None] | None = None
@@ -438,11 +444,20 @@ class RFDETRModelModule(LightningModule):
# ------------------------------------------------------------------
def on_fit_start(self) -> None:
"""Seed RNGs at fit start when ``TrainConfig.seed`` is set.
"""Validate loss consumers and seed RNGs at fit start.
This avoids hidden global side-effects in ``build_trainer`` while still preserving deterministic training
behaviour for actual fit runs.
Rejects ``compute_val_loss=False`` when a configured callback or scheduler
consumes ``val/loss``. Seeding here avoids hidden global side-effects in
``build_trainer`` while preserving deterministic training behaviour for fit runs.
Raises:
ValueError: If a callback or scheduler monitors ``val/loss`` while validation-loss computation is disabled.
"""
if self.train_config.compute_val_loss is False and self._validation_loss_is_monitored:
raise ValueError(
"compute_val_loss=False is incompatible with a callback or scheduler monitoring 'val/loss'. "
"Set compute_val_loss=True or 'auto', or monitor a metric that is produced."
)
if self.train_config.seed is not None:
seed_everything(self.train_config.seed + self.global_rank, workers=True)
@@ -872,6 +887,32 @@ class RFDETRModelModule(LightningModule):
return
scheduler.step()
def on_validation_epoch_start(self) -> None:
"""Resolve the validation-loss policy for this epoch, announcing an ``"auto"`` skip the first time it happens.
The resolution is cached here — unconditionally, before every early return below — because ``validation_step``
reads it on every batch while it depends only on configuration that cannot change mid-epoch.
The consumer scan behind ``compute_val_loss="auto"`` only sees programmatic consumers — a plateau scheduler or a
monitoring callback. It cannot see a human reading a ``val/loss`` curve out of ``metrics.csv``, TensorBoard, or
Weights & Biases, who would otherwise find the curve silently gone. Stating the resolution once, on the first
real validation epoch of the global-zero rank, gives that reader the knob to turn.
"""
self._resolved_compute_val_loss = self._resolve_should_compute_val_loss()
if self._logged_val_loss_skip_notice or self.train_config.compute_val_loss != "auto":
return
# The sanity-check pass runs before training and leaves the flag unset, so the first real epoch still logs.
if getattr(self.trainer, "sanity_checking", False) or not getattr(self.trainer, "is_global_zero", True):
return
if self._resolved_compute_val_loss:
return
self._logged_val_loss_skip_notice = True
logger.info(
"Skipping validation-loss computation: compute_val_loss='auto' found no scheduler or callback monitoring "
"'val/loss', so no 'val/loss' metric is logged this run. Set compute_val_loss=True to log it every "
"validation epoch (for example to read the curve from metrics.csv, TensorBoard, or Weights & Biases)."
)
def on_validation_epoch_end(self) -> None:
"""Step ``ReduceLROnPlateau`` from the monitored metric on the manual-optimization path.
@@ -1030,7 +1071,7 @@ class RFDETRModelModule(LightningModule):
"""
samples, targets = batch
outputs = self._resolve_eval_model()(samples)
if self.train_config.compute_val_loss:
if self._should_compute_val_loss:
loss_dict = self.criterion(outputs, targets)
weight_dict = self.criterion.weight_dict
loss = sum(loss_dict[k] * weight_dict[k] for k in loss_dict if k in weight_dict)
@@ -1040,6 +1081,58 @@ class RFDETRModelModule(LightningModule):
results = self.postprocess(outputs, orig_sizes)
return {"results": results, "targets": targets}
@property
def _validation_loss_is_monitored(self) -> bool:
"""Return whether a configured scheduler or callback consumes ``val/loss``.
``_lr_scheduler_monitor`` is set only after a concrete ``ReduceLROnPlateau`` scheduler is instantiated. Callback
inspection keeps the ``"auto"`` policy compatible with PTL-native checkpoint or early stopping callbacks
supplied through ``build_trainer(..., callbacks=...)``.
RF-DETR's own callbacks are not covered by the PTL-native ``monitor`` attribute alone: ``BestModelCallback`` and
``RFDETREarlyStopping`` track two metrics at once and keep their real targets in ``_monitor_regular`` /
``_monitor_ema`` (``RFDETREarlyStopping.monitor`` is a synthetic key it injects itself), so all three attributes
are inspected.
"""
if self._lr_scheduler_monitor == "val/loss":
return True
try:
callbacks = getattr(self.trainer, "callbacks", [])
except RuntimeError:
return False
return any(
"val/loss"
in {
getattr(callback, "monitor", None),
getattr(callback, "_monitor_regular", None),
getattr(callback, "_monitor_ema", None),
}
for callback in callbacks
)
def _resolve_should_compute_val_loss(self) -> bool:
"""Resolve whether validation should calculate loss for the current configuration.
Returns:
``True`` when the loss is requested outright, or when the ``"auto"`` policy finds a ``val/loss`` consumer.
"""
if self.train_config.compute_val_loss is True:
return True
return self.train_config.compute_val_loss == "auto" and self._validation_loss_is_monitored
@property
def _should_compute_val_loss(self) -> bool:
"""Return whether validation should calculate loss, reusing the resolution cached for this epoch.
``validation_step`` reads this once per batch while the ``"auto"`` policy resolution walks every configured
callback, so ``on_validation_epoch_start`` resolves it once per validation epoch and caches the result here.
The cache stays unset until that hook runs: a ``validation_step`` called directly, with no ``Trainer`` driving
the loop, resolves the live configuration rather than reading a value frozen before the trainer was attached.
"""
if self._resolved_compute_val_loss is not None:
return self._resolved_compute_val_loss
return self._resolve_should_compute_val_loss()
@property
def _fused_adamw_env_eligible(self) -> bool:
"""Return whether the runtime would enable fused AdamW, ignoring optimizer choice.
@@ -1183,6 +1276,11 @@ class RFDETRModelModule(LightningModule):
interval = tc.lr_scheduler_interval
if isinstance(scheduler, ReduceLROnPlateau):
monitor = tc.lr_scheduler_monitor
if monitor == "val/loss" and tc.compute_val_loss is False:
raise ValueError(
"compute_val_loss=False is incompatible with ReduceLROnPlateau monitoring 'val/loss'. "
"Set compute_val_loss=True or 'auto', or select a metric that is produced."
)
# The monitored metric (e.g. val/loss) is only available per epoch, so plateau always steps
# on the epoch boundary regardless of the configured interval.
interval = "epoch"
+60 -3
View File
@@ -323,9 +323,56 @@ class TestTrainConfigT42PromotedFields:
"""ema_update_interval defaults to 1 (update every step)."""
assert self._tc(tmp_path).ema_update_interval == 1
def test_compute_val_loss_default_is_true(self, tmp_path):
"""compute_val_loss defaults to True."""
assert self._tc(tmp_path).compute_val_loss is True
def test_compute_val_loss_default_is_auto(self, tmp_path):
"""compute_val_loss defaults to the automatic validation-monitor policy."""
assert self._tc(tmp_path).compute_val_loss == "auto"
@pytest.mark.parametrize(
"value",
[
pytest.param(True, id="enabled"),
pytest.param(False, id="disabled"),
pytest.param("auto", id="automatic"),
],
)
def test_compute_val_loss_accepts_bool_or_auto(self, tmp_path, value):
"""compute_val_loss preserves explicit boolean and automatic policies."""
assert self._tc(tmp_path, compute_val_loss=value).compute_val_loss == value
@pytest.mark.parametrize(
"raw, expected",
[
pytest.param("true", True, id="string-true-coerces-to-True"),
pytest.param("false", False, id="string-false-coerces-to-False"),
pytest.param("yes", True, id="string-yes-coerces-to-True"),
pytest.param("no", False, id="string-no-coerces-to-False"),
pytest.param("auto", "auto", id="string-auto-matches-literal"),
],
)
def test_compute_val_loss_coerces_string_aliases(self, tmp_path, raw, expected):
"""compute_val_loss silently coerces common string aliases via pydantic's lax bool parsing.
The bool | Literal["auto"] union tries the bool arm first, so "yes"/"no"/"true"/"false" coerce silently instead
of raising; "auto" instead matches the Literal arm unchanged. Pinning this behavior catches a pydantic upgrade
that changes union member resolution order.
"""
assert self._tc(tmp_path, compute_val_loss=raw).compute_val_loss == expected
@pytest.mark.parametrize(
"raw",
[
pytest.param("Auto", id="capitalized-auto-rejected"),
pytest.param("AUTO", id="uppercase-auto-rejected"),
],
)
def test_compute_val_loss_rejects_case_variant_of_auto(self, tmp_path, raw):
"""compute_val_loss rejects any casing of 'auto' other than the exact lowercase literal.
Literal["auto"] is case-strict and these variants don't match the bool arm's alias set either, so pydantic must
raise rather than silently normalizing casing.
"""
with pytest.raises(ValidationError):
self._tc(tmp_path, compute_val_loss=raw)
def test_compute_test_loss_default_is_true(self, tmp_path):
"""compute_test_loss defaults to True."""
@@ -544,6 +591,16 @@ class TestTrainConfigLRScheduler:
tc = self._tc(tmp_path, lr_scheduler="torch.optim.lr_scheduler.StepLR", lr_scheduler_kwargs={"step_size": 5})
assert tc.lr_scheduler == "torch.optim.lr_scheduler.StepLR"
def test_compute_val_loss_false_rejects_plateau_loss_monitor(self, tmp_path):
"""A plateau scheduler monitoring val/loss cannot disable the metric it requires."""
with pytest.raises(ValidationError, match="compute_val_loss=False requires a non-val/loss monitor"):
self._tc(
tmp_path,
compute_val_loss=False,
lr_scheduler="torch.optim.lr_scheduler.ReduceLROnPlateau",
lr_scheduler_monitor="val/loss",
)
def test_callable_class_desugars_to_dotted_path(self, tmp_path):
"""A plain scheduler class desugars to its canonical dotted import path for serialization."""
scheduler_class = torch.optim.lr_scheduler.StepLR
+1
View File
@@ -72,6 +72,7 @@ def evaluation_result(
batch_size=4,
num_workers=0,
tensorboard=False,
log_per_class_metrics=True,
)
return SimpleNamespace(
+6
View File
@@ -134,6 +134,12 @@ class TestBuildTrainerCallbacks:
assert coco_cb._eval_interval == 3
assert coco_cb._log_per_class_metrics is False
def test_coco_eval_default_skips_per_class_metrics(self, tmp_path):
"""The default TrainConfig disables the costly per-class metric path."""
trainer = build_trainer(_tc(tmp_path, use_ema=False), _mc())
coco_cb = next(cb for cb in trainer.callbacks if isinstance(cb, COCOEvalCallback))
assert coco_cb._log_per_class_metrics is False
def test_coco_eval_uses_keypoint_oks_sigmas(self, tmp_path):
"""COCOEvalCallback receives custom keypoint OKS sigmas from TrainConfig."""
sigmas = [0.05] * 25
+5 -4
View File
@@ -53,7 +53,7 @@ def _fit_and_read_csv(mc: RFDETRBaseConfig, tc: TrainConfig, criterion=None) ->
... tensorboard=False,
... ),
... )
... {'train/loss', 'val/loss'}.issubset(metrics.columns)
... {'train/loss', 'val/mAP_50_95'}.issubset(metrics.columns)
True
"""
fake_criterion = criterion or _FakeCriterion()
@@ -95,7 +95,6 @@ _REQUIRED_DETECTION = frozenset(
{
"train/loss",
"train/lr",
"val/loss",
"val/mAP_50",
"val/mAP_50_95",
"val/mAR",
@@ -120,25 +119,27 @@ class TestDetectionMetricsCSV:
"""metrics.csv contains all columns that plot_metrics() needs for detection."""
def test_base_metrics_present_without_ema(self, base_model_config, base_train_config):
"""Without EMA all core val/* columns must appear in metrics.csv with non-NaN data."""
"""Default validation logs aggregate metrics without optional validation loss."""
mc = base_model_config()
tc = base_train_config(use_ema=False, run_test=False)
df = _fit_and_read_csv(mc, tc)
missing = _REQUIRED_DETECTION - set(df.columns)
assert not missing, f"Missing columns in metrics.csv: {sorted(missing)}"
assert "val/loss" not in df.columns
all_nan = {c for c in _REQUIRED_DETECTION if df[c].isna().all()}
assert not all_nan, f"Columns with all-NaN values: {sorted(all_nan)}"
def test_ema_metrics_present_with_ema_enabled(self, base_model_config, base_train_config):
"""With use_ema=True the ema_* aliases must also appear in metrics.csv."""
"""EMA validation retains aggregate EMA metrics without optional validation loss."""
mc = base_model_config()
tc = base_train_config(use_ema=True, run_test=False)
df = _fit_and_read_csv(mc, tc)
missing = _REQUIRED_DETECTION_EMA - set(df.columns)
assert not missing, f"Missing EMA columns in metrics.csv: {sorted(missing)}"
assert "val/loss" not in df.columns
all_nan = {c for c in _REQUIRED_DETECTION_EMA if df[c].isna().all()}
assert not all_nan, f"EMA columns with all-NaN values: {sorted(all_nan)}"
+239 -2
View File
@@ -6,6 +6,7 @@
"""Comprehensive unit tests for RFDETRModelModule (LightningModule wrapper)."""
import random
import warnings
from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock, patch
@@ -17,6 +18,7 @@ from torch import nn
from rfdetr.config import RFDETRBaseConfig, RFDETRSmallConfig, TrainConfig
from rfdetr.models.lwdetr import build_criterion_from_config, build_model_from_config
from rfdetr.models.weights import apply_lora, load_pretrain_weights
from rfdetr.training.callbacks.best_model import RFDETREarlyStopping
from rfdetr.training.module_data import RFDETRDataModule
from rfdetr.training.module_model import RFDETRModelModule
from rfdetr.utilities.tensors import NestedTensor
@@ -1731,7 +1733,8 @@ class TestValidationStep:
loss_dict: dict[str, torch.Tensor] | None = None,
weight_dict: dict[str, float] | None = None,
):
module, fake_model, fake_criterion, fake_pp = _build_module(tmp_path=tmp_path)
tc = _base_train_config(tmp_path, compute_val_loss=True)
module, fake_model, fake_criterion, fake_pp = _build_module(train_config=tc, tmp_path=tmp_path)
samples, targets = _make_batch()
fake_model.return_value = {}
fake_criterion.return_value = loss_dict or {"loss_ce": torch.tensor(0.5)}
@@ -1821,6 +1824,129 @@ class TestValidationStep:
assert "val/loss" not in logged_keys
assert "results" in result and "targets" in result
def test_auto_val_loss_skips_criterion_without_a_loss_monitor(self, tmp_path):
"""compute_val_loss='auto' skips validation loss when no configured consumer monitors it."""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, fake_model, fake_criterion, _ = _build_module(train_config=tc, tmp_path=tmp_path)
samples, targets = _make_batch()
fake_model.return_value = {}
module.log = MagicMock()
result = module.validation_step((samples, targets), batch_idx=0)
fake_criterion.assert_not_called()
assert "results" in result and "targets" in result
def test_auto_val_loss_keeps_criterion_for_callback_monitor(self, tmp_path):
"""compute_val_loss='auto' retains validation loss for a callback monitoring val/loss."""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, fake_model, fake_criterion, _ = _build_module(train_config=tc, tmp_path=tmp_path)
samples, targets = _make_batch()
fake_model.return_value = {}
fake_criterion.return_value = {"loss_ce": torch.tensor(0.5)}
fake_criterion.weight_dict = {"loss_ce": 1.0}
module.trainer = SimpleNamespace(callbacks=[SimpleNamespace(monitor="val/loss")])
module.log = MagicMock()
module.log_dict = MagicMock()
module.validation_step((samples, targets), batch_idx=0)
fake_criterion.assert_called_once_with({}, targets)
assert any(call.args[0] == "val/loss" for call in module.log.call_args_list)
def test_auto_val_loss_keeps_criterion_for_rfdetr_early_stopping(self, tmp_path):
"""compute_val_loss='auto' retains validation loss for a real RFDETREarlyStopping monitoring val/loss.
``RFDETREarlyStopping.monitor`` is always the synthetic ``__rfdetr_effective_map__`` key it injects itself, so
the callback's real target only ever appears in ``_monitor_regular``. Stub callbacks carrying a plain
``monitor="val/loss"`` attribute exercise the generic half of the scan and would keep passing even if the half
covering RF-DETR's own callbacks regressed.
"""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(callbacks=[RFDETREarlyStopping(monitor_regular="val/loss")])
assert module._should_compute_val_loss is True
def test_auto_val_loss_detects_ema_monitor_attribute(self, tmp_path):
"""compute_val_loss='auto' retains validation loss for a callback consuming val/loss as its EMA monitor.
RF-DETR's ``BestModelCallback`` / ``RFDETREarlyStopping`` keep their EMA-track metric key in ``_monitor_ema``
rather than in the PTL-native ``monitor`` attribute, so a scan that inspects only ``monitor`` (and
``_monitor_regular``) would silently skip the loss those callbacks still read.
"""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(
callbacks=[SimpleNamespace(monitor="__rfdetr_effective_map__", _monitor_ema="val/loss")]
)
assert module._should_compute_val_loss is True
def test_auto_val_loss_skips_criterion_for_empty_callback_list(self, tmp_path):
"""compute_val_loss='auto' resolves to skipping the loss when the attached trainer carries no callbacks.
``any()`` over an empty callback list is False, which is the intended answer, but nothing in the scan states
it: an attached trainer with an empty ``callbacks`` list must resolve exactly like the unattached case rather
than raising or falling back to computing the loss.
"""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(callbacks=[])
assert module._should_compute_val_loss is False
def test_explicit_val_loss_disable_rejects_callback_monitor(self, tmp_path):
"""compute_val_loss=False rejects a callback that would consume val/loss."""
tc = _base_train_config(tmp_path, compute_val_loss=False)
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(callbacks=[SimpleNamespace(monitor="val/loss")])
with pytest.raises(ValueError, match="compute_val_loss=False is incompatible"):
module.on_fit_start()
def test_standalone_validate_run_skips_the_fit_start_rejection(self, tmp_path):
"""A standalone trainer.validate() with compute_val_loss=False runs despite a callback monitoring val/loss.
The rejection lives in ``on_fit_start``, which PTL invokes only when ``trainer.state.fn`` is ``FITTING``; a bare
``validate()`` call sets it to ``VALIDATING`` and skips the hook entirely. The asymmetry is intentional (a
validation-only run has no scheduler or early-stopping loop to starve), so this test documents the current
behaviour instead of asserting a raise.
"""
mc = _base_model_config()
tc = _base_train_config(tmp_path, compute_val_loss=False, num_workers=0)
class _ValLossMonitorCallback(Callback):
"""Callback declaring a val/loss monitor, as PTL-native checkpoint and early-stopping callbacks do."""
monitor = "val/loss"
fake_postprocess = MagicMock(side_effect=_helpers_fake_postprocess)
with (
patch("rfdetr.training.module_model.build_model_from_config", return_value=_TinyModel()),
patch(
"rfdetr.training.module_model.build_criterion_from_config",
return_value=(_FakeCriterion(), fake_postprocess),
),
patch("rfdetr.training.module_data.build_dataset", return_value=_FakeDataset(length=4)),
):
module = RFDETRModelModule(mc, tc)
datamodule = RFDETRDataModule(mc, tc)
trainer = Trainer(
limit_val_batches=1,
accelerator="cpu",
enable_progress_bar=False,
enable_model_summary=False,
enable_checkpointing=False,
logger=False,
callbacks=[_ValLossMonitorCallback()],
)
trainer.validate(module, datamodule)
# The validation batch reached postprocess, so the loop ran to completion instead of raising the guard.
fake_postprocess.assert_called_once()
def test_eval_ema_only_forwards_through_ema_model_not_base(self, tmp_path):
"""eval_ema_only=True must forward through the EMA-averaged model, not the base model (regression for #416:
@@ -1891,6 +2017,91 @@ class TestValidationStep:
fake_model.assert_called_once()
class TestValidationLossSkipNotice:
"""on_validation_epoch_start() announces an 'auto' policy that resolved to skipping validation-loss computation.
The consumer scan cannot see a human reading a ``val/loss`` curve from ``metrics.csv``, TensorBoard, or Weights &
Biases, so the resolution is stated once instead of the curve vanishing silently.
"""
def _build_module_with_trainer(self, tmp_path, **trainer_state):
"""Return an 'auto'-policy module whose stub trainer carries the given validation-loop state."""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
state = dict(callbacks=[], sanity_checking=False, is_global_zero=True)
state.update(trainer_state)
module.trainer = SimpleNamespace(**state)
return module
@patch("rfdetr.training.module_model.logger")
def test_notice_is_emitted_once_across_validation_epochs(self, mock_logger, tmp_path):
"""The skip notice is logged on the first validation epoch only, not once per epoch.
Every validation epoch re-enters the hook, so an unguarded notice would repeat for the whole run and drown the
per-epoch metric lines it sits next to.
"""
module = self._build_module_with_trainer(tmp_path)
module.on_validation_epoch_start()
module.on_validation_epoch_start()
mock_logger.info.assert_called_once()
@patch("rfdetr.training.module_model.logger")
def test_no_notice_when_a_callback_consumes_val_loss(self, mock_logger, tmp_path):
"""No notice is logged while a callback monitors val/loss, because the loss is still computed."""
module = self._build_module_with_trainer(tmp_path, callbacks=[SimpleNamespace(monitor="val/loss")])
module.on_validation_epoch_start()
mock_logger.info.assert_not_called()
@patch("rfdetr.training.module_model.logger")
def test_sanity_check_pass_defers_the_notice(self, mock_logger, tmp_path):
"""The pre-training sanity-check validation pass does not consume the one-shot notice.
Sanity checking runs before training starts and is invisible in most run logs; emitting the notice there would
spend the single announcement on a pass the user is least likely to be watching.
"""
module = self._build_module_with_trainer(tmp_path, sanity_checking=True)
module.on_validation_epoch_start()
mock_logger.info.assert_not_called()
class TestValidationLossPolicyCaching:
"""_should_compute_val_loss reuses the resolution that on_validation_epoch_start cached for the running epoch."""
def test_per_batch_reads_reuse_the_epoch_resolution(self, tmp_path):
"""A callback attached mid-epoch does not change the policy the running validation epoch already resolved.
The resolution walks every configured callback, so ``validation_step`` must not redo it per batch. Mutating
``trainer.callbacks`` after the epoch hook ran is the observable proxy: an unchanged answer proves the batch
read came from the cached resolution rather than a fresh scan.
"""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(callbacks=[], sanity_checking=False, is_global_zero=True)
module.on_validation_epoch_start()
module.trainer.callbacks.append(SimpleNamespace(monitor="val/loss"))
assert module._should_compute_val_loss is False
def test_resolution_is_refreshed_at_every_validation_epoch(self, tmp_path):
"""Each validation epoch re-resolves the policy, so a fit -> validate transition cannot serve a stale answer."""
tc = _base_train_config(tmp_path, compute_val_loss="auto")
module, *_ = _build_module(train_config=tc, tmp_path=tmp_path)
module.trainer = SimpleNamespace(callbacks=[], sanity_checking=False, is_global_zero=True)
module.on_validation_epoch_start()
module.trainer.callbacks.append(SimpleNamespace(monitor="val/loss"))
module.on_validation_epoch_start()
assert module._should_compute_val_loss is True
class TestTestStep:
"""Tests for test_step() — verifies output dict shape, postprocessor invocation with correct original sizes, and
test/loss logging.
@@ -2405,7 +2616,7 @@ class TestConfigureOptimizers:
@patch("rfdetr.training.module_model.get_param_dict")
def test_reduce_on_plateau_sets_monitor_and_epoch_interval(self, mock_get_param_dict, tmp_path):
"""A ReduceLROnPlateau scheduler is configured with its monitor and stepped per epoch."""
"""A plateau loss monitor makes automatic validation loss available each epoch."""
module, param_dicts = self._setup_module(
tmp_path,
warmup_epochs=0.0,
@@ -2420,6 +2631,32 @@ class TestConfigureOptimizers:
assert isinstance(config["scheduler"], torch.optim.lr_scheduler.ReduceLROnPlateau)
assert config["monitor"] == "val/loss"
assert config["interval"] == "epoch"
assert module._should_compute_val_loss is True
@patch("rfdetr.training.module_model.get_param_dict")
def test_callable_plateau_scheduler_rejects_disabled_val_loss(self, mock_get_param_dict, tmp_path):
"""A closure-built ReduceLROnPlateau monitoring val/loss is rejected when compute_val_loss=False.
``TrainConfig.validate_explicit_val_loss_disable`` only string-compares ``lr_scheduler`` against the
ReduceLROnPlateau dotted path, and a lambda closure — unlike a plain ``functools.partial``, which is desugared
to that path — never reaches the comparison. The runtime check in ``configure_optimizers`` is therefore the only
guard left once the concrete scheduler exists.
"""
with warnings.catch_warnings():
# A lambda lr_scheduler cannot round-trip through training_config.json; that reproducibility warning is
# expected here and unrelated to the conflict under test.
warnings.simplefilter("ignore", UserWarning)
module, param_dicts = self._setup_module(
tmp_path,
warmup_epochs=0.0,
lr_scheduler=lambda optimizer: torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.5),
lr_scheduler_monitor="val/loss",
compute_val_loss=False,
)
mock_get_param_dict.return_value = param_dicts
with pytest.raises(ValueError, match="incompatible with ReduceLROnPlateau"):
module.configure_optimizers()
@patch("rfdetr.training.module_model.get_param_dict")
def test_uninstalled_scheduler_path_raises_value_error(self, mock_get_param_dict, tmp_path):