-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathmodule_model.py
More file actions
447 lines (384 loc) · 20.9 KB
/
Copy pathmodule_model.py
File metadata and controls
447 lines (384 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""LightningModule for RF-DETR training and validation."""
from __future__ import annotations
import math
import random
import warnings
from typing import Any, Dict, Optional, Tuple
import torch
import torch.nn.functional as F # noqa: N812
from pytorch_lightning import LightningModule, seed_everything
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import ModelConfig, TrainConfig
from rfdetr.datasets.coco import compute_multi_scale_scales
from rfdetr.models import build_criterion_from_config, build_model_from_config
from rfdetr.models.weights import (
apply_lora,
interpolate_position_embeddings,
load_pretrain_weights,
)
from rfdetr.training.param_groups import get_param_dict
from rfdetr.utilities.logger import get_logger
logger = get_logger()
class RFDETRModelModule(LightningModule):
"""LightningModule wrapping the RF-DETR model and training loop.
Args:
model_config: Architecture configuration.
train_config: Training hyperparameter configuration.
"""
def __init__(self, model_config: ModelConfig, train_config: TrainConfig) -> None:
super().__init__()
self.model_config = model_config
self.train_config = train_config
# Allow partial state-dict loading when resuming from a .pth checkpoint
# (which contains only model weights, not criterion/postprocess state).
self.strict_loading = False
# Model, criterion, and postprocessor.
self.model = build_model_from_config(model_config, train_config)
if model_config.pretrain_weights is not None:
# Capture the configured class count before loading weights so we can
# detect any automatic alignment to the checkpoint.
prev_num_classes = self.model_config.num_classes
load_pretrain_weights(self.model, self.model_config)
# If the loaded checkpoint changed the model's effective number of
# classes (e.g. to match a fine-tuned head), persist that back onto
# the model_config so downstream components see the aligned value.
if hasattr(self.model, "num_classes"):
model_num_classes = getattr(self.model, "num_classes")
if model_num_classes is not None and model_num_classes != prev_num_classes:
self.model_config.num_classes = model_num_classes
if model_config.backbone_lora:
apply_lora(self.model)
# Build criterion and postprocessor after potential num_classes
# alignment so they match the current model head.
self.criterion, self.postprocess = build_criterion_from_config(self.model_config, self.train_config)
# torch.compile is opt-in: set model_config.compile=True to enable.
# Only enabled on CUDA; MPS and CPU do not benefit from compilation.
# Use the fork-safe DEVICE constant instead of torch.cuda.is_available(),
# which creates a CUDA driver context that breaks fork-based DDP.
from rfdetr.config import DEVICE
accelerator = str(train_config.accelerator).lower()
uses_cuda_accelerator = accelerator in {"auto", "gpu", "cuda"}
compile_enabled = (
model_config.compile and DEVICE == "cuda" and uses_cuda_accelerator and not train_config.multi_scale
)
if model_config.compile and train_config.multi_scale:
logger.info("Disabling torch.compile because multi_scale=True introduces dynamic input shapes.")
if compile_enabled:
# dynamic=True: one compiled graph handles all multi-scale input sizes instead
# of recompiling per (H, W) pair. suppress_errors=True: if inductor can't
# compile a subgraph (e.g. bicubic backward with symbolic shapes), it falls
# back to eager mode for that subgraph rather than crashing.
# capture_scalar_outputs=True: include Tensor.item() calls
# (gen_encoder_output_proposals / ms_deform_attn use spatial-shape .item()
# as Python slice indices). Safe with dynamic=True because item() results
# are backed symbols derived from input shapes — not unbacked symbols that
# would cause PendingUnbackedSymbolNotFound (which only occurs without dynamic).
torch._dynamo.config.suppress_errors = True
torch._dynamo.config.capture_scalar_outputs = True
self.model = torch.compile(self.model, dynamic=True)
# ------------------------------------------------------------------
# PTL lifecycle hooks
# ------------------------------------------------------------------
def on_fit_start(self) -> None:
"""Seed RNGs at fit start when ``TrainConfig.seed`` is set.
This avoids hidden global side-effects in ``build_trainer`` while still
preserving deterministic training behaviour for actual fit runs.
"""
if self.train_config.seed is not None:
seed_everything(self.train_config.seed + self.global_rank, workers=True)
def on_train_batch_start(self, batch: Tuple, batch_idx: int) -> None:
"""Apply optional multi-scale resize to the incoming batch.
Modifications to ``batch`` (in-place on ``NestedTensor``) are visible
in ``training_step`` because they share the same object.
Args:
batch: Tuple of (NestedTensor samples, list of target dicts).
batch_idx: Index of the current batch within the epoch.
"""
tc = self.train_config
mc = self.model_config
if tc.multi_scale and not tc.do_random_resize_via_padding:
samples, _ = batch
scales = compute_multi_scale_scales(mc.resolution, tc.expanded_scales, mc.patch_size, mc.num_windows)
step = self.trainer.global_step
random.seed(step)
scale = random.choice(scales)
with torch.no_grad():
samples.tensors = F.interpolate(samples.tensors, size=scale, mode="bilinear", align_corners=False)
samples.mask = (
F.interpolate(samples.mask.unsqueeze(1).float(), size=scale, mode="nearest").squeeze(1).bool()
)
def training_step(self, batch: Tuple, batch_idx: int) -> torch.Tensor:
"""Compute loss for one training step and log metrics.
PTL handles gradient accumulation (``accumulate_grad_batches``), AMP
(``precision``), and gradient clipping (``gradient_clip_val``) — no
manual ``GradScaler`` or loss scaling here. The loss is divided by
``trainer.accumulate_grad_batches`` so that the accumulated gradient
magnitude matches the legacy engine (which scales each sub-batch by
``1/grad_accum_steps`` before calling ``backward()``).
Args:
batch: Tuple of (NestedTensor samples, list of target dicts).
batch_idx: Batch index within the epoch.
Returns:
Scalar loss tensor.
"""
samples, targets = batch
batch_size = len(targets)
outputs = self.model(samples, targets)
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)
# Normalise by grad-accum steps so the accumulated gradient matches the
# legacy engine, which scales each sub-batch by 1/grad_accum_steps before
# backward(). PTL accumulates full-scale gradients by default; dividing
# here keeps the effective LR identical to the non-PTL training path.
# We return the scaled loss to PTL but log the unscaled value so that
# train/loss and val/loss are on the same scale.
loss_scaled = loss / self.trainer.accumulate_grad_batches
train_log_sync_dist = bool(self.train_config.train_log_sync_dist)
train_log_on_step = bool(self.train_config.train_log_on_step)
self.log_dict(
{f"train/{k}": v for k, v in loss_dict.items()},
on_step=train_log_on_step,
on_epoch=True,
sync_dist=train_log_sync_dist,
batch_size=batch_size,
)
self.log(
"train/loss",
loss,
prog_bar=True,
on_step=train_log_on_step,
on_epoch=True,
sync_dist=train_log_sync_dist,
batch_size=batch_size,
)
optimizer = self.optimizers()
if isinstance(optimizer, list):
optimizer = optimizer[0]
# Optimizer may have multiple param groups with different LRs (e.g., backbone/decoder).
# Preserve the first group's LR for backward compatibility, but also log the
# min/max across all groups so the progress bar reflects the full schedule.
group_lrs = [pg["lr"] for pg in optimizer.param_groups if "lr" in pg]
if group_lrs:
base_lr = group_lrs[0]
min_lr = min(group_lrs)
max_lr = max(group_lrs)
self.log("train/lr", base_lr, prog_bar=True, on_step=True, on_epoch=False)
self.log("train/lr_min", min_lr, prog_bar=True, on_step=True, on_epoch=False)
self.log("train/lr_max", max_lr, prog_bar=True, on_step=True, on_epoch=False)
return loss_scaled
def validation_step(self, batch: Tuple, batch_idx: int) -> Dict[str, Any]:
"""Run forward pass and postprocess for one validation step.
Returns raw results and targets so ``COCOEvalCallback`` can accumulate
them across the epoch via ``on_validation_batch_end``.
Args:
batch: Tuple of (NestedTensor samples, list of target dicts).
batch_idx: Batch index within the validation epoch.
Returns:
Dict with ``results`` (postprocessed predictions) and ``targets``.
"""
samples, targets = batch
outputs = self.model(samples)
if self.train_config.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)
self.log("val/loss", loss, prog_bar=True, on_epoch=True, sync_dist=True, batch_size=len(targets))
orig_sizes = torch.stack([t["orig_size"] for t in targets])
cap = getattr(self.train_config, "max_eval_orig_size", None)
if cap is not None:
scale = (cap / orig_sizes.float().amax(dim=1, keepdim=True)).clamp(max=1.0)
orig_sizes = (orig_sizes.float() * scale).long()
results = self.postprocess(outputs, orig_sizes)
return {"results": results, "targets": targets}
@property
def _use_fused_optimizer(self) -> bool:
"""Return whether fused AdamW should be used for the current training configuration.
Fused AdamW is only safe when the trainer's actual precision is a BF16
variant. Checking GPU capability alone (``is_bf16_supported()``) is
insufficient: on Ampere+ hardware that flag is always ``True`` even when
the trainer is configured for ``32-true``, which causes a
``params, grads, exp_avgs, and exp_avg_sqs must have same dtype, device,
and layout`` crash in DDP because gradient bucket views have non-matching
strides in FP32.
Returns:
``True`` when fused AdamW is both requested and safe to use.
Examples:
>>> # Fused is disabled when trainer precision is 32-true
>>> module = RFDETRModelModule.__new__(RFDETRModelModule)
>>> module.model_config = type("Cfg", (), {"fused_optimizer": True})()
>>> module._trainer = type("Trainer", (), {"precision": "32-true"})()
>>> module._trainer.precision = "32-true"
>>> module._use_fused_optimizer
False
"""
return (
self.model_config.fused_optimizer
and torch.cuda.is_available()
and torch.cuda.is_bf16_supported()
and str(self.trainer.precision) in {"bf16-mixed", "bf16", "bf16-true"}
)
def configure_optimizers(self) -> Dict[str, Any]:
"""Build AdamW optimizer with layer-wise LR decay and LambdaLR scheduler.
Uses ``trainer.estimated_stepping_batches`` for total step count so
cosine annealing covers the full training run regardless of dataset
size or accumulation settings.
Returns:
PTL optimizer config dict with optimizer and step-interval scheduler.
"""
tc = self.train_config
ns = _namespace_from_configs(self.model_config, tc)
# Unwrap torch.compile's OptimizedModule so get_param_dict sees the
# original module's named_parameters() — compiled wrapper can cause
# name-prefix mismatches that put the same tensor in multiple groups.
model_for_params = getattr(self.model, "_orig_mod", self.model)
param_dicts = get_param_dict(ns, model_for_params)
param_dicts = [p for p in param_dicts if p["params"].requires_grad]
optimizer = torch.optim.AdamW(
param_dicts,
lr=tc.lr,
weight_decay=tc.weight_decay,
fused=self._use_fused_optimizer,
)
total_steps = int(self.trainer.estimated_stepping_batches)
steps_per_epoch = max(1, total_steps // tc.epochs)
warmup_steps = int(steps_per_epoch * tc.warmup_epochs)
def lr_lambda(current_step: int) -> float:
if current_step < warmup_steps:
return float(current_step) / float(max(1, warmup_steps))
if tc.lr_scheduler == "cosine":
progress = float(current_step - warmup_steps) / float(max(1, total_steps - warmup_steps))
return tc.lr_min_factor + (1 - tc.lr_min_factor) * 0.5 * (1 + math.cos(math.pi * progress))
# Step decay: drop by 10× after lr_drop epochs.
if current_step < tc.lr_drop * steps_per_epoch:
return 1.0
return 0.1
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)
return {
"optimizer": optimizer,
"lr_scheduler": {"scheduler": scheduler, "interval": "step"},
}
def clip_gradients(
self,
optimizer: torch.optim.Optimizer,
gradient_clip_val: Optional[float] = None,
gradient_clip_algorithm: Optional[str] = None,
) -> None:
"""Override PTL gradient clipping to support fused AdamW.
PTL's AMP precision plugin refuses to clip gradients when the optimizer
declares it handles unscaling internally (fused=True). When fused is
active we are on BF16 (no GradScaler) so ``clip_grad_norm_`` is
correct. For the non-fused path (FP16 + GradScaler or FP32) we
delegate to ``super()`` to preserve scaler-aware unscaling.
Args:
optimizer: The current optimizer.
gradient_clip_val: Maximum gradient norm.
gradient_clip_algorithm: Clipping algorithm; forwarded to super()
for the non-fused path.
"""
if self._use_fused_optimizer:
if gradient_clip_val and gradient_clip_val > 0:
torch.nn.utils.clip_grad_norm_(self.parameters(), gradient_clip_val)
else:
super().clip_gradients(
optimizer,
gradient_clip_val=gradient_clip_val,
gradient_clip_algorithm=gradient_clip_algorithm,
)
def test_step(self, batch: Tuple, batch_idx: int) -> Dict[str, Any]:
"""Run forward pass and postprocess for one test step.
Mirrors :meth:`validation_step` so ``COCOEvalCallback`` can accumulate
results via ``on_test_batch_end`` when ``trainer.test()`` is called (e.g.
from :class:`~rfdetr.training.callbacks.BestModelCallback` at end of training).
Args:
batch: Tuple of (NestedTensor samples, list of target dicts).
batch_idx: Batch index within the test epoch.
Returns:
Dict with ``results`` (postprocessed predictions) and ``targets``.
"""
samples, targets = batch
outputs = self.model(samples)
if self.train_config.compute_test_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)
self.log("test/loss", loss, sync_dist=True, batch_size=len(targets))
orig_sizes = torch.stack([t["orig_size"] for t in targets])
cap = getattr(self.train_config, "max_eval_orig_size", None)
if cap is not None:
scale = (cap / orig_sizes.float().amax(dim=1, keepdim=True)).clamp(max=1.0)
orig_sizes = (orig_sizes.float() * scale).long()
results = self.postprocess(outputs, orig_sizes)
return {"results": results, "targets": targets}
def predict_step(self, batch: Tuple, batch_idx: int, dataloader_idx: int = 0) -> Any:
"""Run inference on a preprocessed batch and return postprocessed results.
Args:
batch: Tuple of (NestedTensor samples, list of target dicts).
batch_idx: Batch index.
dataloader_idx: Index of the predict dataloader.
Returns:
Postprocessed detection results from ``PostProcess``.
"""
samples, targets = batch
with torch.no_grad():
outputs = self.model(samples)
orig_sizes = torch.stack([t["orig_size"] for t in targets])
return self.postprocess(outputs, orig_sizes)
def on_load_checkpoint(self, checkpoint: dict[str, Any]) -> None:
"""Auto-detect legacy formats and reconcile PE shapes at checkpoint load time.
PTL calls this hook before applying ``checkpoint["state_dict"]`` to
the module. Three normalisation steps are applied in order:
1. **Raw legacy format** — a ``*.pth`` file loaded directly by
``Trainer`` (e.g. via ``ckpt_path=``). Recognised by the presence
of ``"model"`` without ``"state_dict"``. The state dict is
rewritten in-place with the ``"model."`` prefix so PTL can apply it
normally.
2. **Positional-embedding interpolation** — when the checkpoint was
saved at a different image resolution than the current model, the
DINOv2 ``position_embeddings`` tensor shape will mismatch.
:func:`~rfdetr.models.weights.interpolate_position_embeddings` is
called to bicubic-resize the PE to ``model_config.positional_encoding_size``
before PTL applies the state dict. Regression fix for :issue:`998`.
3. **Converted format** — a file produced by
:func:`~rfdetr.training.checkpoint.convert_legacy_checkpoint` that
already has ``"state_dict"`` but also carries
``"legacy_ema_state_dict"``. The EMA weights are stashed on
``self._pending_legacy_ema_state`` for optional restoration by
:class:`~rfdetr.training.callbacks.ema.RFDETREMACallback`.
Args:
checkpoint: Checkpoint dict passed in by PTL (mutated in-place).
"""
# Raw legacy .pth: no "state_dict" key — build it from "model".
if "model" in checkpoint and "state_dict" not in checkpoint:
checkpoint["state_dict"] = {"model." + k: v for k, v in checkpoint["model"].items()}
# Interpolate DINOv2 positional embeddings when the checkpoint was saved
# at a different resolution than the current model. PTL applies
# checkpoint["state_dict"] immediately after this hook, so the shapes
# must already match at this point. Regression: #998.
if "state_dict" in checkpoint:
interpolate_position_embeddings(
checkpoint["state_dict"],
self.model_config.positional_encoding_size,
)
# Stash legacy EMA weights for RFDETREMACallback.setup(), which restores
# them into AveragedModel when resuming from converted legacy checkpoints.
if "legacy_ema_state_dict" in checkpoint:
self._pending_legacy_ema_state = checkpoint["legacy_ema_state_dict"]
warnings.warn(
"Checkpoint contains legacy EMA weights (`legacy_ema_state_dict`). "
"Add RFDETREMACallback to your trainer callbacks to restore them; "
"without it the stashed weights will be ignored.",
UserWarning,
stacklevel=2,
)
def reinitialize_detection_head(self, num_classes: int) -> None:
"""Reinitialize the detection head for a new class count.
Args:
num_classes: New number of classes (excluding background).
"""
self.model.reinitialize_detection_head(num_classes)