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
160 lines
6.6 KiB
Python
160 lines
6.6 KiB
Python
# ------------------------------------------------------------------------
|
||
# RF-DETR
|
||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||
# ------------------------------------------------------------------------
|
||
"""Integration tests: metrics.csv contains all columns used by plot_metrics().
|
||
|
||
Runs a minimal PTL training loop (1 epoch, 2 batches each) using mocked model internals so no real dataset or GPU is
|
||
required. After training, reads the CSVLogger output and asserts that every metric column that ``plot_metrics()`` needs
|
||
is present and has at least one non-NaN value.
|
||
|
||
Also verifies that ``train/loss`` is logged at the same scale as ``val/loss`` (i.e. NOT divided by ``grad_accum_steps``
|
||
before logging).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pandas as pd
|
||
import torch
|
||
|
||
from rfdetr.config import RFDETRBaseConfig, TrainConfig
|
||
from rfdetr.training import build_trainer
|
||
from rfdetr.training.module_data import RFDETRDataModule
|
||
from rfdetr.training.module_model import RFDETRModelModule
|
||
|
||
from .helpers import _fake_postprocess, _FakeCriterion, _FakeDataset, _make_param_dicts, _TinyModel
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers local to this module
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _fit_and_read_csv(mc: RFDETRBaseConfig, tc: TrainConfig, criterion=None) -> pd.DataFrame:
|
||
"""Run 1 epoch (2 train + 2 val batches) and return the resulting metrics.csv."""
|
||
fake_criterion = criterion or _FakeCriterion()
|
||
with (
|
||
patch("rfdetr.training.module_model.build_model_from_config", return_value=_TinyModel()),
|
||
patch(
|
||
"rfdetr.training.module_model.build_criterion_from_config",
|
||
return_value=(fake_criterion, MagicMock(side_effect=_fake_postprocess)),
|
||
),
|
||
patch("rfdetr.training.module_data.build_dataset", return_value=_FakeDataset(length=20)),
|
||
patch(
|
||
"rfdetr.training.module_model.get_param_dict",
|
||
side_effect=lambda args, model: _make_param_dicts(model),
|
||
),
|
||
):
|
||
module = RFDETRModelModule(mc, tc)
|
||
datamodule = RFDETRDataModule(mc, tc)
|
||
trainer = build_trainer(
|
||
tc,
|
||
mc,
|
||
accelerator="cpu",
|
||
max_epochs=1,
|
||
limit_train_batches=2,
|
||
limit_val_batches=2,
|
||
log_every_n_steps=1,
|
||
)
|
||
trainer.fit(module, datamodule=datamodule)
|
||
|
||
csv_path = Path(tc.output_dir) / "metrics.csv"
|
||
assert csv_path.exists(), "CSVLogger must write metrics.csv to output_dir"
|
||
return pd.read_csv(csv_path)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Expected columns (must exist and have ≥1 non-NaN row after one epoch)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_REQUIRED_DETECTION = frozenset(
|
||
{
|
||
"train/loss",
|
||
"train/lr",
|
||
"val/loss",
|
||
"val/mAP_50",
|
||
"val/mAP_50_95",
|
||
"val/mAR",
|
||
}
|
||
)
|
||
|
||
_REQUIRED_DETECTION_EMA = _REQUIRED_DETECTION | frozenset(
|
||
{
|
||
"val/ema_mAP_50",
|
||
"val/ema_mAP_50_95",
|
||
"val/ema_mAR",
|
||
}
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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."""
|
||
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)}"
|
||
|
||
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."""
|
||
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)}"
|
||
|
||
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)}"
|
||
|
||
def test_train_loss_is_unscaled(self, base_model_config, base_train_config):
|
||
"""Train/loss must be logged at the raw criterion scale, not divided by grad_accum_steps.
|
||
|
||
With grad_accum_steps=4 the old code divided the logged value by 4, making train/loss ~4× smaller than val/loss.
|
||
After the fix the logged value equals the raw weighted criterion output so both losses are on the same scale.
|
||
"""
|
||
fixed_loss_value = 5.0
|
||
grad_accum_steps = 4
|
||
|
||
class _FixedCriterion:
|
||
weight_dict = {"loss_ce": 1.0}
|
||
|
||
def num_boxes_for_targets(self, outputs, targets):
|
||
dummy = outputs.get("dummy", torch.zeros(1))
|
||
return torch.ones((), dtype=dummy.dtype, device=dummy.device)
|
||
|
||
def __call__(self, outputs, targets, num_boxes=None):
|
||
# Loss is always fixed_loss_value, connected to model params for gradient.
|
||
dummy = outputs.get("dummy", torch.zeros(1))
|
||
denominator = self.num_boxes_for_targets(outputs, targets) if num_boxes is None else num_boxes
|
||
return {"loss_ce": (dummy.mean() * 0 + fixed_loss_value) / denominator}
|
||
|
||
mc = base_model_config()
|
||
tc = base_train_config(use_ema=False, run_test=False, grad_accum_steps=grad_accum_steps)
|
||
df = _fit_and_read_csv(mc, tc, criterion=_FixedCriterion())
|
||
|
||
logged = df["train/loss"].dropna().mean()
|
||
expected_unscaled = fixed_loss_value
|
||
expected_if_divided = fixed_loss_value / grad_accum_steps
|
||
|
||
assert abs(logged - expected_unscaled) < abs(logged - expected_if_divided), (
|
||
f"train/loss={logged:.4f} is closer to the grad-accum-divided value "
|
||
f"({expected_if_divided:.4f}) than the raw criterion output "
|
||
f"({expected_unscaled:.4f}). The division must have been removed."
|
||
)
|