🏋️ 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
|
compile_step
|
bool
|
Whether to :func: |
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
|
underlying loss attribute so a scheduler can update the ramp per epoch. |
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.
val/mAP and val/segm_mAP come from :mod:torchmetrics metrics, which
synchronise across ranks themselves. The oriented and keypoint protocols do not:
:attr:_val_rotated_preds and :attr:_val_keypoint_preds are ordinary Python
lists, so on more than one process each rank would score and log its own shard as
if it were the whole split. That is not recoverable after the fact — average
precision ranks every detection of the split against every other by confidence, so
averaging per-rank AP values is a different quantity, not an approximation of the
right one. Rather than log a plausible wrong number, these tasks refuse to start
distributed; "detect" and "segment" are unaffected and stay free to scale.
Lifting this needs the buffers gathered with image identity (so sampler padding's duplicate images are dropped rather than double-counted) and scored once on one rank — the work is real, so the limit is enforced here instead of assumed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stage
|
str
|
The Trainer stage being set up ( |
required |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the task is |
Examples:
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
|
predictions (raw class logits and raw |
|
DualHeadOutput
|
branches. |
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
|
head's dense predictions (including both branches' mask |
|
SegmentOutput
|
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 |
BranchOutput
|
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. |
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
|
alone or inside a Lightning optimizer/scheduler config dict with the |
|
OptimizerLRScheduler
|
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
|
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
|
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
|
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 ( |
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]
|
at the module's dtype, the ragged per-image targets, and the per-image |
list[Tensor] | None
|
mask stacks a segmentation loader rasterised in its workers ( |
tuple[Tensor, list[Targets], list[Tensor] | None]
|
a detection loader, or on any already-restored feed, which is the signal |
tuple[Tensor, list[Targets], list[Tensor] | None]
|
the module 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 change is consulted
on the next sample the pipeline assembles. 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_checkpoint_model
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 |