🏋️ Training¶
The Lightning pieces a training run is assembled from: the module that owns the model, loss and optimizer, the data module that builds the loaders, and the two callbacks the recipe needs. Generated from the docstrings in src/lucid_yolo/ptl/.
Module and data¶
DetectionLitModule
¶
Bases: LightningModule
Lightning detector: model stack, dual-branch loss, and MuSGD (WP-034).
Composes :class:~lucid_yolo.models.backbone.DetectionBackbone, :class:~lucid_yolo.models.neck.DetectionNeck,
and :class:~lucid_yolo.models.heads.detect.DualDetectionHead through
:func:~lucid_yolo.models.build.build_detection_stages — the same factory
:class:~lucid_yolo.models.build.Detector uses, so the model the fidelity gate measures and the model training
runs cannot drift apart (WP-087). It scores their dense predictions with
:class:~lucid_yolo.losses.dual_loss.DualBranchLoss, and optimizes under Lightning automatic optimization (D4)
with :class:~lucid_yolo.optim.musgd.MuSGD. See the module docstring for the A8 constant-LR deferral,
the task conditioning, and the :attr:alpha seam.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
depth
|
float
|
Depth multiplier scaling per-stage repeat counts of the backbone and neck. |
required |
width
|
float
|
Width multiplier scaling channel counts. |
required |
max_channels
|
int
|
Channel cap applied before the width multiply. |
required |
num_classes
|
int
|
Number of object classes the head predicts. |
required |
task
|
str
|
Supervision task; one of |
'detect'
|
num_keypoints
|
int | None
|
Point count |
None
|
lr
|
float
|
Base learning rate for MuSGD ( |
0.01
|
lrf
|
float
|
Final LR fraction of the A8 linear decay — the LR ends at |
0.01
|
warmup_epochs
|
float
|
Length of the opening linear LR warmup, in epochs (fractions allowed). |
3.0
|
momentum
|
float
|
MuSGD momentum coefficient. Defaults to |
0.95
|
weight_decay
|
float
|
Decoupled weight decay (matrix parameters only). Defaults to |
0.0005
|
w_muon
|
float
|
Additive gain on the MuSGD Muon branch. Defaults to |
0.5
|
w_sgd
|
float
|
Additive gain on the MuSGD SGD branch. Defaults to |
0.5
|
box_gain
|
float
|
Gain on the IoU term of both loss branches — the axis-aligned Complete-IoU under
|
7.5
|
cls_gain
|
float
|
Classification-term gain shared by both branches. Defaults to |
0.5
|
l1_gain
|
float
|
Gain on the L1 box term of both branches: against the axis-aligned target under
|
6.0
|
alpha
|
float
|
Initial one-to-many branch weight seeding the dual loss (used before training and whenever the epoch
schedule is inactive). Defaults to |
0.5
|
alpha_init
|
float
|
One-to-many branch weight the progressive schedule sets on the first epoch (branch weights
|
0.8
|
alpha_final
|
float
|
One-to-many branch weight the schedule ramps to on the last epoch (branch weights |
0.1
|
mask_gain
|
float
|
Weight on the instance-mask term under |
2.5
|
semantic_gain
|
float
|
Weight on the auxiliary semantic term under |
0.5
|
angle_gain
|
float
|
Weight on R1 Eq. 15's square-object angle term under |
0.25
|
rotated_iou_form
|
str
|
Which of R17's two rotated-IoU losses the |
DEFAULT_ROTATED_IOU_FORM
|
keypoint_gain
|
float
|
Weight on the R14 residual-log-likelihood term under |
1.0
|
keypoint_loss
|
str
|
Which keypoint objective |
'rle'
|
oks_gain
|
float
|
Weight on the Object-Keypoint-Similarity term (:class: |
0.0
|
fused_assignment
|
bool
|
Opt into CUDA target-assignment fusion. It retains the current objectives and native CPU/MPS,
compiler and unsupported-layout paths. Default |
False
|
compile_step
|
bool
|
Whether to :func: |
False
|
capture_orthogonalization
|
bool
|
Enable MuSGD's optional CUDA graph replay of its existing Newton--Schulz calls.
Defaults to |
False
|
capture_network
|
bool
|
Replay fixed detection, keypoint, oriented or segmentation network stages' CUDA forward and
backward with :func: |
False
|
capture_updates
|
bool
|
Enable MuSGD's optional replay of the existing update preparation, including momentum,
followed by a native batched parameter write at the current learning rate. Defaults to |
False
|
capture_loss
|
bool
|
Replay the existing detection assignment and loss forward/backward, for detection or pose, on
single-device CUDA automatic training. Default off: recording pays setup and retains a graph pool. Masked
target capacity grows geometrically; the method, label checks, live branch schedule and optimizer stay
unchanged. Requires |
False
|
capture_segment_heads
|
bool
|
Record independent CUDA segmentation prediction groups, including first-order backward.
Defaults to |
False
|
capture_gradient_norm
|
bool
|
Replay PyTorch's L2 gradient-norm reduction on single-device CUDA with torch>=2.9.
Default false. Native scaling reads the current clipping limit; one fit-scoped gradient signature is
retained and drift retires recording. Value clipping, disabled clipping (limit |
False
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import torch
>>> from lucid_yolo.data.targets import Targets
>>> module = DetectionLitModule(depth=0.34, width=0.25, max_channels=1024, num_classes=4)
>>> images = torch.zeros(1, 3, 160, 160)
>>> targets = [Targets(boxes=torch.tensor([[8.0, 8.0, 40.0, 40.0]]), labels=torch.tensor([1]))]
>>> loss = module.training_step((images, targets), 0) # doctest: +SKIP
>>> bool(torch.isfinite(loss)) # doctest: +SKIP
True
task
property
¶
Supervision task this module was built for: "detect", "segment", "obb" or "keypoints".
Read-only, and the supported way for a consumer to ask whether a loaded checkpoint has a mask branch, an angle
branch or a keypoint branch — :meth:forward_segmentation gates on this same value, so the caller's question
and the module's own behaviour cannot answer differently. Reading hparams["task"] instead would be a
stringly-typed lookup into a bag that is only as complete as the checkpoint that filled it, and it raises
rather than defaulting when a checkpoint predates the hyperparameter.
Returns:
| Type | Description |
|---|---|
str
|
The task string given at construction. |
Examples:
alpha
property
writable
¶
One-to-many branch weight of the dual loss (the WP-035 schedule seam).
Returns:
| Type | Description |
|---|---|
float
|
The current :attr: |
float
|
can update the ramp per epoch. |
Examples:
save_hyperparameters
¶
save_hyperparameters(*args: Any, ignore: Sequence[str] | str | None = None, frame: FrameType | None = None, logger: bool = True) -> None
Record Lightning hyperparameters, with task in its plain spelling, on every save.
The plain spelling, even when a :class:~lucid_yolo.tasks.Task member was passed: a checkpoint loads under
torch.load(weights_only=True), whose restricted unpickler refuses a Task in hparams["task"].
Normalizing the constructor's local before the call is not enough, because the save merges every subclass
__init__ frame's arguments over this one's, **kwargs contents included, and correcting the record once
after the constructor's own call is not enough either, because a subclass may save again after
super().__init__ returns. So every save corrects both records it wrote: hparams, which the checkpoint
stores, and the initial snapshot a logger writes. The arguments keep Lightning's meaning; with no frame,
the caller's frame is passed on, since Lightning would otherwise inspect this override's. A hyperparameter dict
given without task (LightningCLI's path) stays as given.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
Any
|
Lightning's positional hyperparameters: argument names, or one dict or namespace. |
()
|
ignore
|
Sequence[str] | str | None
|
Argument names Lightning leaves out of the record. |
None
|
frame
|
FrameType | None
|
The frame whose |
None
|
logger
|
bool
|
Whether the initial snapshot is offered to the logger. |
True
|
Examples:
setup
¶
Pick the CUDA memory layout and objective, then refuse multi-process runs the protocols cannot gather.
With compile_step on, :meth:_compile_objective decides once whether the detection objective is compiled
(CUDA accelerator, "detect" task) or stays eager with a warning naming why; a second setup — fit
then validate — changes nothing.
On a CUDA accelerator the parameters are converted to channels_last (NHWC), the layout cuDNN's convolution
kernels prefer: eager training of the n model stepped 8% faster on an RTX PRO 6000 with no other change,
and the datamodule hands over its images in the same layout so no op has to permute. This hook is where the
conversion belongs because it runs before device placement and before every callback's on_fit_start — so
:class:EMACallback's shadow, cloned there, inherits the layout rather than averaging across a permute.
The decision reads the trainer's accelerator rather than :func:torch.cuda.is_available, which is true on a
CUDA box that was asked for accelerator="cpu", and it is not a flag: CPU and MPS keep their default layout
and their bit-exact goldens, and there is nothing to configure on the one backend where the layout pays. A
module with no trainer attached is left as built.
All task metrics gather whole-epoch records across ranks before scoring. Rotated and keypoint metrics also drop sampler padding by dataset identity, retaining the original sequential validation order and scoring protocols.
Setting up "fit" also re-arms the once-per-fit gradient-snapshot-skip verdict that
:meth:_report_native_closure logs at the first training step.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stage
|
str
|
The Trainer stage being set up. |
required |
Examples:
configure_callbacks
¶
Keep opted-in CUDA recordings scoped to one fit.
configure_gradient_clipping
¶
configure_gradient_clipping(optimizer: Optimizer, gradient_clip_val: int | float | None = None, gradient_clip_algorithm: str | None = None) -> None
Optionally record the norm reduction while retaining native clipping semantics.
Ordinary FP16 GradScaler gradients are already unscaled before this hook and can use capture. Optimizers that unscale inside their own step keep the native call, including Lightning's mixed-precision clipping rejection. Value or disabled clipping retires capture once, as does customized module or precision-plugin clipping, with the latter diagnostic naming the plugin type. Plugins inheriting the checked stock clipping methods through their MRO remain eligible.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
optimizer
|
Optimizer
|
Optimizer whose gradients are clipped. |
required |
gradient_clip_val
|
int | float | None
|
Clipping limit; |
None
|
gradient_clip_algorithm
|
str | None
|
Clipping algorithm; |
None
|
on_train_epoch_start
¶
Ramp the dual-loss branch weight for the epoch about to start (WP-035).
Sets :attr:alpha to :meth:~lucid_yolo.losses.progressive.ProgressiveLossSchedule.alpha_at evaluated at
the current 0-based epoch and the trainer's total epoch count — the linear R1 Eq. 2-3 ramp updated once per
epoch. When trainer.max_epochs is unset (None or <= 0, as for a step-bounded or
fast_dev_run run) the ramp denominator is undefined, so alpha is left at its seeded value.
Examples:
forward
¶
Run the backbone, neck, and dual head over an image batch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
images
|
Tensor
|
Input batch of shape |
required |
Returns:
| Name | Type | Description |
|---|---|---|
The |
DualHeadOutput
|
class: |
DualHeadOutput
|
|
Examples:
forward_segmentation
¶
Run the detection and mask branches over an image batch (WP-087).
:meth:forward returns the detection head's output alone, because that is what every detection consumer —
the E2E decode, the mAP metric, the export path — asks for. Segmentation needs the prototype maps and the
auxiliary logits from the same neck features, so this is the second entry point, and it is the module's only
composition of the mask side: the training step and the overfit gate both call it rather than re-running
backbone -> neck -> proto_fusion -> protonet themselves, which is how the two would drift apart.
The auxiliary semantic branch is training-only (A17) and returns None in eval mode; the prototypes are
produced in both modes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
images
|
Tensor
|
Input batch of shape |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
SegmentOutput
|
class: |
SegmentOutput
|
branches' mask coefficients), the raw prototype maps, and the auxiliary logits. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If this module's task is not |
Examples:
>>> import torch
>>> module = DetectionLitModule(
... depth=0.34, width=0.25, max_channels=256, num_classes=4, task="segment"
... ).eval()
>>> with torch.no_grad():
... out = module.forward_segmentation(torch.zeros(1, 3, 64, 64))
>>> out.prototypes.shape # twice the P3 grid (A15): 64 / 8 * 2
torch.Size([1, 32, 16, 16])
>>> out.detect.o2o_coeff.shape[-1], out.semantic is None # aux is training-only (A17)
(32, True)
forward_branch
¶
Run the backbone, neck, and one head branch over an image batch.
:meth:forward returns both branches because training reads both. Every inference decode reads exactly one:
the top-k path the one-to-one branch, the suppression path the one-to-many. Called through :meth:forward, the
other branch's stems run over all three levels and their outputs are discarded, so this is the entry point for
a caller that already knows which half it will read.
The composition is deliberately the same three calls :meth:forward makes, with only the head's own branch
selection narrowed -- the backbone and neck are shared by both branches and are not run twice or run
differently here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
images
|
Tensor
|
Input batch of shape |
required |
branch
|
BranchName
|
Which head branch to run, one of :data: |
required |
Returns:
| Type | Description |
|---|---|
BranchOutput
|
That branch's :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
forward_segmentation_branch
¶
Run one head branch and the prototype maps over an image batch.
The branch-limited twin of :meth:forward_segmentation, and the mask side's reason for existing is the same as
the detection side's: Eq. 7 reads the coefficients of the branch its decoder read, never both.
Two things are dropped rather than returned. The other branch's stems -- including its coefficient stem, which
is the widest of the optional ones -- are never run. The auxiliary semantic head is not run either: it is
training-only (A17) and already returns None in eval mode, so a caller decoding masks has nothing to read
from it. What remains is exactly the pair Eq. 7 multiplies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
images
|
Tensor
|
Input batch of shape |
required |
branch
|
BranchName
|
Which head branch to run, one of :data: |
required |
Returns:
| Type | Description |
|---|---|
BranchOutput
|
A |
Tensor
|
the raw prototype maps |
Raises:
| Type | Description |
|---|---|
ValueError
|
If this module's task is not |
Examples:
>>> import torch
>>> module = DetectionLitModule(
... depth=0.34, width=0.25, max_channels=256, num_classes=4, task="segment"
... ).eval()
>>> with torch.no_grad():
... one_to_one, prototypes = module.forward_segmentation_branch(torch.zeros(1, 3, 64, 64), "o2o")
>>> prototypes.shape # twice the P3 grid (A15): 64 / 8 * 2
torch.Size([1, 32, 16, 16])
>>> one_to_one.coeff.shape[-1]
32
training_step
¶
Run one training step under automatic optimization (D4).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batch
|
StepBatch
|
The datamodule batch |
required |
batch_idx
|
int
|
Index of the batch within the epoch (unused). |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
The scalar total loss for Lightning to backpropagate. |
optimizer_step
¶
optimizer_step(epoch: int, batch_idx: int, optimizer: Optimizer | LightningOptimizer, optimizer_closure: Callable[[], Any] | None = None) -> None
Omit snapshots that the native closure clears before backward.
Only detection network replay with accumulation one and the native pass-through profiler qualifies. The native
forward runs inside the permission; zeroing, backward and subsequent callbacks run outside it. Restoring the
closure in finally keeps a failed or reused closure from retaining the wrapper. Custom zeroing, observers,
strategies, precision plugins, optimizer wrappers and a closure whose own closure method was replaced on
the instance keep the original snapshots. Lightning minors beyond the inspected 2.4-2.6 contract also retain
them. This hook only filters on the replay flag and the closure's shape; the certificate, evaluated as the
forward starts, decides. The first step of a fit logs the verdict once, at rank-zero info level: active, or the
first check that failed.
validation_step
¶
Run one validation step: shared forward and loss, plus the mAP update.
Beyond the val/-logged loss, the one-to-one branch is decoded with the E2E
:class:~lucid_yolo.decode.topk_e2e.TopKDecoder from the same forward and accumulated into the epoch's
:class:~torchmetrics.detection.MeanAveragePrecision (WP-077), logged as val/mAP by
:meth:on_validation_epoch_end. Scoring runs in letterbox coordinates — IoU is invariant to each image's
uniform letterbox scaling, so the number tracks the original-coordinate protocol closely; the acceptance figure
remains lucid-eval (original coordinates, both paths).
A "segment" module additionally decodes the kept detections' masks and accumulates val/segm_mAP
(:meth:_update_val_segm), so a segmentation run reports the quality of the branch it exists for rather than
of its boxes alone. That needs the ground-truth masks, which only a loader built with mask_targets=True
supplies — the CLI links that flag to model.task, and a batch without them is scored on boxes only rather
than rasterised a second time here.
An "obb" module accumulates the WP-063 rotated mAP (:meth:_update_val_rotated) instead of
val/mAP, not beside it (WP-102): the axis-aligned figure reads the A44 composition's pre-rotation
rectangle, so a run whose orientations were random and one whose orientations were right log the identical
curve, and the acceptance number would otherwise first appear hours after the run.
A "keypoints" module additionally decodes the kept detections' points and accumulates val/oks_mAP
(:meth:_update_val_keypoints, WP-137), the same "beside val/mAP" relationship "segment" has:
the box branch and the point branch answer different questions, and a run whose boxes are fine but whose points
have not yet wired up should not read as healthy on the one number that was already there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batch
|
StepBatch
|
The datamodule batch |
required |
batch_idx
|
int
|
Index of the batch within the epoch (unused). |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
The scalar total validation loss. |
on_validation_epoch_end
¶
Compute and log the epoch's E2E val/mAP (progress-bar metric), then reset.
A "segment" run additionally logs val/segm_mAP, whenever any batch of the epoch carried ground-truth
masks; an "obb" run logs the WP-063 val/rotated_mAP and val/rotated_mAP50 over everything the
epoch saw and logs no val/mAP at all (WP-102): that figure reads the A44 composition's pre-rotation
rectangle, so it cannot separate a run whose orientations were right from one whose orientations were random,
and its accumulator costs a CPU pass over every detection of every batch to say so. The rotated metric is the
one an oriented run is asking for; carrying a second one that answers a different question is not a free
comparison.
A "keypoints" run additionally logs val/oks_mAP (WP-137, :meth:_log_oks_map), beside val/mAP
rather than instead of it, for the same reason val/segm_mAP sits beside val/mAP and not the other way
val/rotated_mAP does.
The val/segm_mAP guard is reduced across processes before it is read. :meth:~torchmetrics.Metric.compute
synchronises collectively, so a rank that happened to draw a shard without mask targets must still enter it —
deciding to skip on that rank alone would leave the others waiting inside the collective for a call that never
comes. The reduction is any: one rank with masks makes the metric well-defined for all of them, and on a
single process it is the identity, so the accepted single-device behaviour is unchanged.
configure_optimizers
¶
Return MuSGD, paired with the A8 warmup + linear-decay LR schedule.
The schedule (WP-072) is a per-step :class:~torch.optim.lr_scheduler.LambdaLR over
:func:~lucid_yolo.optim.schedule.warmup_decay_factor: a linear warmup across the first warmup_epochs
epochs, then a linear decay from lr down to lr * lrf at the trainer's estimated final step. The bare
constant-LR optimizer is returned instead when the schedule is explicitly disabled (lrf >= 1 and
warmup_epochs <= 0), when no trainer is attached (direct calls in tests and tools), or when the run has
no positive max_epochs to anchor the warmup fraction.
The warmup length is a fraction of the run, so a short run can ask for more warmup steps than it has
steps: warmup_epochs=3 against a two-epoch smoke budget is config-reachable and is exactly the shape the
short tiers get launched at. Left alone, every step would take the ramp branch — the LR would climb for the
whole run, peak below lr, and never decay, with nothing saying so. The count is clamped to
total_steps - 1, which recovers the peak but not the decay: a run whose requested warmup covers it has no
steps left to decay over, so the clamp buys back only the ramp reaching full lr on the final step. That is
the ceiling of what a clamp can do here, which is why it warns rather than applying silently — the recipe
cannot be honoured, and the honest signal is to say which schedule actually ran. Clamping rather than raising
keeps the launchable run launchable: a crash here would fail a run over a schedule detail on the tier where
the schedule matters least.
Returns:
| Name | Type | Description |
|---|---|---|
A |
OptimizerLRScheduler
|
class: |
OptimizerLRScheduler
|
optimizer/scheduler config dict with the step-interval LambdaLR. |
DetectionDataModule
¶
Bases: LightningDataModule
Detection datamodule assembling the Phase 1 augmentation pipeline (WP-014).
Train and val splits are built from the COCO 2017 layout under data_root (train2017/ +
annotations/instances_train2017.json and the val2017 equivalents); each split path may be overridden
explicitly (used by the offline fixture tests, which have a single Roboflow-style split). A data_root written
in the YOLO convention instead is read by :class:~lucid_yolo.data.yolo.YoloDetectionDataset — see the module
docstring for the three-step dispatch (WP-099b). Training samples pass through :class:_TrainPipeline
(mosaic/mixup/copy-paste/affine/hsv/flip + letterbox); validation is letterbox-only. No split is ever downloaded.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_root
|
Path
|
Dataset root: a COCO 2017 root (holding |
required |
batch_size
|
int
|
Samples per batch for both loaders. |
required |
num_workers
|
int | None
|
DataLoader worker processes. |
required |
variant
|
str
|
Model size letter selecting the augmentation strength policy
( |
required |
img_size
|
int
|
Square letterbox side for every emitted sample. Defaults to 640, and must be a positive multiple of
every head stride — validated at construction, for the reason |
640
|
train_images_dir
|
Path | None
|
Override for the train images directory. Defaults to whichever of
|
None
|
train_ann_file
|
Path | None
|
Override for the train annotation JSON. Resolved alongside the images directory, never independently of it. |
None
|
val_images_dir
|
Path | None
|
Override for the val images directory. Resolved as the train one is. |
None
|
val_ann_file
|
Path | None
|
Override for the val annotation JSON. Resolved alongside the val images directory. |
None
|
seed
|
int
|
Seed for the training pipeline's generator and loader shuffling. |
0
|
pin_memory
|
bool | None
|
Whether loader batches land in page-locked host memory for async host-to-device copies. |
None
|
prefetch_factor
|
int
|
Batches each worker keeps prefetched (defaults to :data: |
_PREFETCH_FACTOR
|
val_num_workers
|
int | None
|
Worker count for the validation loader only, capped by nothing — the flag an operator states
to spend the whole machine on validation. |
None
|
persistent_workers
|
bool
|
Keep loader workers alive across epochs. Defaults to |
False
|
cache_train_images
|
bool
|
Decode the training split once during :meth: |
False
|
cache_prepared_train_images
|
bool
|
Retain float32, fill-centred source images
instead of repeatedly converting bytes and centring each random mosaic.
Off by default. Takes precedence over |
False
|
device_hsv
|
bool
|
Apply training HSV and quantization on CUDA, in batches, under float32 or mixed precision. Off by
default; other backends and precisions retain worker-side colour augmentation. The workers keep their RNG
draws and transport float32 images plus the stated gains, using four times the default image IPC and
transfer bytes. The worker cap budgets float32 images only for a count chosen here ( |
False
|
fused_hsv
|
bool
|
Let the transfer hook restore a deferred-HSV batch with the fused
Triton kernel where the host supports it (:mod: |
True
|
rotated_targets
|
bool
|
Read every annotation's four-point ring as a rotated box
(:class: |
False
|
mask_targets
|
bool
|
Rasterise the segmentation mask targets in the loader workers
(:func: |
False
|
keypoint_targets
|
bool
|
Parse every annotation's COCO Both loaders carry points the whole way (WP-132). The validation path is
letterbox-only and needed nothing but
:class: |
False
|
layout
|
str | None
|
Which reader |
None
|
epoch_prefetch
|
bool
|
Queue up to |
False
|
Examples:
>>> DetectionDataModule # doctest: +SKIP
>>> # dm = DetectionDataModule(root, batch_size=2, num_workers=0, variant="n")
>>> # dm.setup("fit"); images, targets = next(iter(dm.train_dataloader()))
layout
property
¶
Return the layout this datamodule reads, probing the root once if need be.
Returns:
| Type | Description |
|---|---|
DatasetLayout
|
The resolved :class: |
DatasetLayout
|
first call so the probe's verdict cannot change mid-run. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the root satisfies neither convention; the message names
both and every path tried (:func: |
ValueError
|
If it satisfies both, or asks for something that layout cannot serve. |
Examples:
mosaic_prob
property
¶
Return the training pipeline's current mosaic probability.
Returns:
| Type | Description |
|---|---|
float
|
The probability with which each training sample is built from a |
float
|
four-image mosaic ( |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :meth: |
Examples:
prepare_data
¶
No-op: datasets are provisioned out of band, never downloaded (AGENTS.md section 3).
setup
¶
Build the train pipeline and the letterbox-only val dataset.
The layout is resolved here (:attr:layout) rather than at construction, because this
is the first moment the root is required to exist: a root that satisfies neither
convention raises before a single image is decoded, naming both conventions and every
path tried.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stage
|
str | None
|
The Lightning stage ( A call that raises installs nothing it built: the val dataset and both caches keep their previous
values, and no train pipeline is left behind, so :meth: |
None
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the root satisfies neither dataset convention. |
ValueError
|
If it satisfies both, or the resolved layout cannot serve a requested modality. |
on_after_batch_transfer
¶
on_after_batch_transfer(batch: tuple[Tensor, PackedTargets] | tuple[Tensor, list[Targets]], dataloader_idx: int) -> tuple[Tensor, list[Targets], list[Tensor] | None]
Restore the float images and list[Targets] consumer contract on-device.
Lightning calls this after moving the batch to the accelerator, so both the uint8 images and the
:class:PackedTargets tensor fields are already on-device: the images are dequantized to the attached
module's dtype (:meth:_consumer_dtype — code / 255 at that precision, :func:_dequantize_images) and
the packed targets are unpacked, all on the same device the module trains on, with no extra host-to-device
hop. A deferred training batch first applies its stated HSV gains and quantizes the result, then reaches this
same dequantization. Matching the module dtype keeps a "16-true"/"bf16-true" run's half weights and
inputs aligned; "16-mixed" leaves the module float32 and lets autocast cast. Already-float images and
already-list targets (a non-packed direct feed) pass through untouched, so the hook is safe to run over
either form. Both the train and val loaders share this single restore. On CUDA the restored images are made
channels_last, the layout the module's parameters take in :meth:DetectionLitModule.setup; masks and
every other device keep the default layout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batch
|
tuple[Tensor, PackedTargets] | tuple[Tensor, list[Targets]]
|
The transferred |
required |
dataloader_idx
|
int
|
The loader index (unused; the conversion is uniform). |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
The |
list[Targets]
|
ragged per-image targets, and the per-image mask stacks a segmentation loader rasterised in its workers |
list[Tensor] | None
|
( |
tuple[Tensor, list[Targets], list[Tensor] | None]
|
rasterises the masks itself). |
Raises:
| Type | Description |
|---|---|
TypeError
|
If a deferred-HSV batch arrives cast away from float32 images
and float64 gains (:func: |
Examples:
train_dataloader
¶
Return the shuffled training loader with the packed batch transport.
DataLoader's type parameter describes dataset items, while this collate returns a different form. The cast
records the collated boundary rather than exposing the pipeline's private deferred-sample union to consumers.
The loader collates through :func:_collate_training, the private twin of :func:collate_detection that also
accepts that union, so the public collate keeps its (image, Targets) signature.
val_dataloader
¶
Return the unshuffled validation loader (letterbox-only samples).
The loader runs on its own worker count — val_num_workers, defaulting to a pixel-scaled cap on an
auto-chosen train count (WP-073, WP-102) and to the memory budget of :meth:_val_loader_workers on a named
one (WP-103): letterbox only samples need a fraction of the train pipeline's CPU, and both worker populations
are resident at the epoch boundary where validation runs.
Annotated with the collated (images, PackedTargets) transport, as
:meth:train_dataloader is: the dataset yields (image, Targets) pairs, and
the cast records the form :func:collate_detection actually hands the trainer.
set_mosaic_prob
¶
Set the training pipeline's mosaic probability.
This is the seam CloseMosaicCallback (WP-036) drives to disable mosaic for the final training epochs: it
sets prob=0. The value lands on the main-process pipeline, which loader worker processes copy when they
start: in-process loading (num_workers=0) uses it from the next sample, and a pool started after this call
— every later epoch's pool under the default persistent_workers=False — samples with it. Persistent
workers keep the copy they started with and never see the change; with epoch_prefetch also requested,
a change of value warns once, since the next epoch's first batches are also already queued by then. Repeat
calls are harmless.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prob
|
float
|
The new mosaic probability, forwarded to :attr: |
required |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :meth: |
Examples:
Callbacks¶
EMACallback
¶
Bases: Callback
Track an exponential-moving-average shadow of the weights and eval with it.
A shadow copy of the module's floating-point parameters and buffers (batch-norm running statistics included;
integer buffers such as num_batches_tracked are left out — they carry no meaningful average) is created on
:meth:on_fit_start on the module's device. After each optimizer step (:meth:on_train_batch_end, honoring
update_every) the shadow is blended toward the live weights under :func:torch.no_grad with a
warmup-ramped decay
d_t = decay * (1 - exp(-t / tau)) shadow <- d_t * shadow + (1 - d_t) * live
where t is the number of updates performed so far. Tensors sharing a device and dtype are submitted to one
foreach interpolation: the same recurrence with fewer accelerator launches, but not bit-identical to the
per-tensor form in any dtype. It agrees within the test tolerance for fp32 and fp64, and a half-precision shadow
rounds once per step instead of twice (a difference of about 1 ulp). A tensor whose shadow and live dtypes differ
keeps the per-tensor recurrence. The ramp starts the effective decay near zero so the early shadow tracks the
fast-moving weights closely, then relaxes toward the nominal decay as training settles (blueprint D4). Around
the validation loop the callback swaps the shadow in (:meth:on_validation_start) and the live weights back out
(:meth:on_validation_end), so validation metrics are EMA metrics — the "used for eval" contract of D4. The
shadow and update counters are checkpointed via :meth:state_dict/:meth:load_state_dict, so a save/resume round
trip continues the same average rather than restarting it.
Cadence is optimizer steps, not batches. update_every and tau are both counted in optimizer steps, so
accumulate_grad_batches > 1 does not multiply the number of blends: :meth:on_train_batch_end blends only on
a batch that actually advanced trainer.global_step, which under accumulation is the last batch of each
accumulation window. That hook rather than on_before_zero_grad because Lightning calls the latter at the
start of an accumulation window, before that window's training_step — it would blend pre-step weights and
never see the state left by the final optimizer step of a run.
Callback ordering. The weight swap is deliberately hook-scoped, not run-scoped, so it does not depend on how
Lightning happens to order the callback list: the shadow is installed in :meth:on_validation_start and taken
out again in :meth:on_validation_end (and in :meth:on_exception, so a failed validation cannot leave EMA
weights wearing the live model's identity). Checkpoints written by
:class:~pytorch_lightning.callbacks.ModelCheckpoint therefore hold the raw trained weights in state_dict,
with the EMA available separately under this callback's state_dict — the split
:func:~lucid_yolo.eval.checkpoint.load_eval_module relies on when it overlays the shadow on request.
ModelCheckpoint saves from on_validation_end too, and Lightning moves it to the end of the callback list,
so within a single validation the restore runs first; the two facts are recorded together because only the second
is Lightning's to change.
The exact decay/tau constants are unpublished in the source papers; the defaults are the conventional
YOLO-lineage values and are documented here as a small, self-contained assumption (D4 names EMA explicitly, so no
ASSUMPTIONS.md row is warranted).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decay
|
float
|
Nominal per-update decay the warmup ramp relaxes toward. Must be in |
_DEFAULT_DECAY
|
tau
|
int
|
Warmup time constant (in updates, and so in optimizer steps) of the decay
ramp; larger values ramp in more slowly. Must be positive. Defaults to |
_DEFAULT_TAU
|
update_every
|
int
|
Refresh the shadow every |
_DEFAULT_UPDATE_EVERY
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from lucid_yolo.ptl.callbacks import EMACallback
>>> callback = EMACallback(decay=0.99, tau=100)
>>> round(callback._decay_at(1), 6) # ramped far below the nominal decay
0.009851
>>> callback._decay_at(1) < callback.decay
True
on_fit_start
¶
Create (or, on resume, relocate) the shadow on the module's device.
A shadow restored from a checkpoint via :meth:load_state_dict is kept — only its
device is refreshed — so resuming continues the saved average; otherwise a fresh
shadow is cloned from the current floating-point parameters and buffers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer (unused; part of the hook signature). |
required |
pl_module
|
LightningModule
|
The module whose weights are shadowed. |
required |
on_train_start
¶
Seed the optimizer-step watermark from the trainer, so a resume does not double-count.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer, read for its |
required |
pl_module
|
LightningModule
|
The module being trained (unused; part of the hook signature). |
required |
on_train_batch_end
¶
on_train_batch_end(trainer: Trainer, pl_module: LightningModule, outputs: Any, batch: Any, batch_idx: int) -> None
Blend the shadow toward the live weights once per optimizer step.
A batch that did not advance trainer.global_step — every batch of an accumulate_grad_batches window
but its last — leaves the shadow untouched, so update_every and the tau ramp are counted in optimizer
steps as documented rather than in batches. By this hook the step has already been applied, so the blend reads
post-step weights.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer, read for its |
required |
pl_module
|
LightningModule
|
The module whose weights are shadowed. |
required |
outputs
|
Any
|
The step outputs (unused; part of the hook signature). |
required |
batch
|
Any
|
The batch just processed (unused; part of the hook signature). |
required |
batch_idx
|
int
|
Index of the batch within the epoch (unused). |
required |
on_validation_start
¶
Stash the live weights and load the shadow so validation runs on the EMA.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer (unused; part of the hook signature). |
required |
pl_module
|
LightningModule
|
The module to load the shadow into. |
required |
on_validation_end
¶
Restore the live weights stashed in :meth:on_validation_start.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer (unused; part of the hook signature). |
required |
pl_module
|
LightningModule
|
The module to restore the live weights into. |
required |
on_exception
¶
Take the shadow back out of the module when a run dies mid-validation.
Without this the swap of :meth:on_validation_start is undone only by the normal completion path, so an
exception raised inside the validation loop propagates with the EMA weights still installed under the raw
model's identity — anything the failure handler then inspects, saves or resumes from would be the shadow
rather than the trained weights. Restoration is idempotent, so the ordinary path running first (or this hook
firing outside validation) is harmless.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer (unused; part of the hook signature). |
required |
pl_module
|
LightningModule
|
The module to restore the live weights into. |
required |
exception
|
BaseException
|
The exception being propagated (unused; part of the hook signature). |
required |
state_dict
¶
Return the shadow and update counters for Lightning to checkpoint.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dict with the |
dict[str, Any]
|
and the |
dict[str, Any]
|
cloned so a checkpoint captures the snapshot at call time, not a later state. |
load_state_dict
¶
Restore the shadow and update counters from a checkpoint.
A checkpoint written before the cadence was fixed to optimizer steps carries the counter under
batches_seen; it is read as the update_every phase it in fact was, so resuming such a run continues
rather than restarts. The two counts differ only when that run used accumulate_grad_batches > 1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_dict
|
dict[str, Any]
|
A mapping produced by :meth: |
required |
CloseMosaicCallback
¶
Bases: Callback
Disable mosaic augmentation for the final close_mosaic training epochs.
Mosaic (four-image compositing) runs at p=1.0 for most of training but is switched off for the last
close_mosaic epochs so the network finishes on natural, un-composited images (blueprint sec. 5.9; [R1] Tables
S3/S6). At the start of every training epoch this callback checks whether the current epoch has reached the
close-mosaic window (current_epoch >= max_epochs - close_mosaic) and, if so, sets the training pipeline's
mosaic probability to 0 via the datamodule's
:meth:~lucid_yolo.ptl.datamodule.DetectionDataModule.set_mosaic_prob seam. The flip is idempotent — re-setting
0 each subsequent epoch is harmless — and is logged exactly once, on the epoch it first takes effect (rank
zero only).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
close_mosaic
|
int
|
Number of final epochs to train with mosaic disabled. |
_DEFAULT_CLOSE_MOSAIC
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from lucid_yolo.ptl.callbacks import CloseMosaicCallback
>>> callback = CloseMosaicCallback(close_mosaic=10)
>>> callback.close_mosaic
10
on_train_epoch_start
¶
Disable mosaic once the close-mosaic window is reached (idempotent).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trainer
|
Trainer
|
The active trainer, read for |
required |
pl_module
|
LightningModule
|
The module being trained (unused; part of the hook signature). |
required |
Detection loss¶
DualBranchLoss takes one BranchPrediction(logits, boxes) per branch, followed by shared anchor points and padded targets. Its output records the attribute alpha float by default, or the supplied alpha tensor when overridden.
BranchPrediction
¶
Bases: NamedTuple
Pair one detection branch's raw class logits and decoded boxes.
Attributes:
| Name | Type | Description |
|---|---|---|
logits |
Tensor
|
Raw class logits with shape |
boxes |
Tensor
|
Decoded |
Examples:
>>> import torch
>>> prediction = BranchPrediction(torch.zeros(1, 4, 2), torch.zeros(1, 4, 4))
>>> prediction.logits.shape, prediction.boxes.shape
(torch.Size([1, 4, 2]), torch.Size([1, 4, 4]))
DualBranchLoss
¶
Compose the one-to-many and one-to-one detection-branch losses.
Holds two :class:~lucid_yolo.losses.detection_loss.DetectionBranchLoss instances (identical gains), a dense o2m
assigner (:class:~lucid_yolo.assign.stal.SmallTargetAssigner, topk = 10), and a unique o2o assigner
(:class:~lucid_yolo.assign.one_to_one.UniqueAssigner, topk = 7 reduced to one). The object is stateless apart
from its gains, assigners, and the settable :attr:alpha, so a single instance is reused across the training run.
It is coordinate-scale agnostic in the same way as the per-branch loss: predicted and ground-truth boxes only need
to share a frame (see :class:~lucid_yolo.losses.detection_loss.DetectionBranchLoss).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
box_gain
|
float
|
Weight on each branch's Complete-IoU term (A13 default |
7.5
|
cls_gain
|
float
|
Weight on each branch's classification term (A13 default |
0.5
|
l1_gain
|
float
|
Weight on each branch's L1 box term (A13 default |
6.0
|
o2m_topk
|
int
|
Candidate count for the dense o2m assignment (default |
10
|
o2o_topk
|
int
|
Candidate-set size for the o2o assignment before the unique |
7
|
alpha
|
float
|
Initial branch weight for |
0.5
|
s_min
|
float
|
Small-target surrogate threshold shared by both assigners (the smallest stride, |
8.0
|
s_ref
|
float
|
Small-target surrogate replacement size shared by both assigners
(the next stride, |
16.0
|
fused_assignment
|
bool
|
Request the optional CUDA assignment passes on both branches.
Default |
False
|
Examples:
>>> import torch
>>> from lucid_yolo.assign import make_anchor_points
>>> loss = DualBranchLoss()
>>> points, _ = make_anchor_points([(2, 2)], [8]) # 4 anchors
>>> logits = torch.zeros(1, 4, 1)
>>> boxes = torch.zeros(1, 4, 4)
>>> empty_boxes = torch.zeros(1, 0, 4)
>>> empty_labels = torch.zeros(1, 0, dtype=torch.long)
>>> empty_mask = torch.zeros(1, 0, dtype=torch.bool)
>>> out = loss(
... BranchPrediction(logits, boxes), BranchPrediction(logits, boxes),
... points, empty_boxes, empty_labels, empty_mask
... )
>>> bool(torch.isfinite(out.total)) # no ground truth: finite, cls-only
True
__call__
¶
__call__(o2m: BranchPrediction, o2o: BranchPrediction, anchor_points: Tensor, gt_boxes: Tensor, gt_labels: Tensor, gt_mask: Tensor, strides: Tensor | None = None, gt_rboxes: Tensor | None = None, *, alpha: Tensor | None = None) -> DualLossOutput
Score both branches against their own assignments and combine them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
o2m
|
BranchPrediction
|
Raw logits and decoded boxes of the one-to-many branch. |
required |
o2o
|
BranchPrediction
|
Raw logits and decoded boxes of the one-to-one branch. |
required |
anchor_points
|
Tensor
|
|
required |
gt_boxes
|
Tensor
|
|
required |
gt_labels
|
Tensor
|
|
required |
gt_mask
|
Tensor
|
|
required |
strides
|
Tensor | None
|
Optional |
None
|
gt_rboxes
|
Tensor | None
|
Optional |
None
|
alpha
|
Tensor | None
|
Optional 0-d |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
DualLossOutput
|
class: |
DualLossOutput
|
class: |
|
DualLossOutput
|
assignments (so a mask term can supervise exactly these positives instead of assigning again). |
Examples:
>>> import torch
>>> from lucid_yolo.assign import make_anchor_points
>>> loss = DualBranchLoss()
>>> loss.alpha = 0.25 # e.g. a scheduled value from WP-035
>>> points, _ = make_anchor_points([(2, 2)], [8])
>>> logits = torch.zeros(1, 4, 1, requires_grad=True)
>>> boxes = torch.zeros(1, 4, 4, requires_grad=True)
>>> gt = torch.tensor([[[0.0, 0.0, 8.0, 8.0]]])
>>> out = loss(
... BranchPrediction(logits, boxes), BranchPrediction(logits, boxes),
... points, gt, torch.tensor([[0]]), torch.tensor([[True]])
... )
>>> out.total.backward()
>>> bool(torch.isfinite(logits.grad).all())
True
DualLossOutput
dataclass
¶
Combined dual-branch loss, its two per-branch breakdowns, and both assignments.
total is the alpha-weighted combination of the two branch totals; the o2m and o2o fields carry each
branch's full :class:~lucid_yolo.losses.detection_loss.DetectionLossOutput (its pre-gain box/cls/l1
terms and gain-weighted total), and alpha is the branch weight used for this call.
The two :class:~lucid_yolo.assign.tal.AssignResult values are returned rather than kept private because any
other term supervising the same positives — the WP-087 instance-mask loss is the first — must score the ground
truth each anchor was actually assigned to here. Recomputing the assignment at the call site would create a second
selection path free to disagree with this one, and a mask scored against the wrong (but plausible) instance is
invisible in the loss value.
Attributes:
| Name | Type | Description |
|---|---|---|
total |
Tensor
|
|
o2m |
DetectionLossOutput
|
One-to-many branch loss output (dense |
o2o |
DetectionLossOutput
|
One-to-one branch loss output (unique |
alpha |
float | Tensor
|
Branch weight applied to the o2m total (o2o gets |
o2m_assign |
AssignResult
|
The dense assignment the o2m branch was scored against. |
o2o_assign |
AssignResult
|
The unique assignment the o2o branch was scored against. |