Repository navigation
feat: restore a jittered intensity profile around the whole GPU pipeline - #139
Open
Hendrik-code wants to merge 4 commits into
Open
Hendrik-code wants to merge 4 commits into
Hendrik-code wants to merge 4 commits into
Conversation
…een them ZscoreNormalizationGPU was the only normalization a GPU config could reach. The other schemes a medical imaging pipeline uses -- min-max, the CT-style percentile clip, a robust median/IQR scaling, divide-by-max, divide-by-mean -- existed only in nnU-Net's preprocessing, which runs once per dataset. All five land in a new gpu/normalization.py behind a shared base that owns the clone, the channel guard, the statistics mask, both degeneracy guards and the region selection, so each scheme is an affine map plus an optional clip. At their defaults MinMaxNormalizationGPU reproduces nnU-Net's RescaleTo01Normalization and PercentileNormalizationGPU, given a clip_range, mean and std, reproduces CTNormalization; test_normalization.py pins both against the installed nnU-Net. The group is new: AugType.NORM, because its members are alternatives rather than additions -- a min-max rescale on top of a z-score discards the z-score. The builder therefore puts them in a RandomChooseXTransformsGPU with num_transforms=1 as the last stage in every pipeline mode, which makes each scheme's p its weight (P(scheme) = p / n) and leaves the remainder as no normalization at all. A config naming a single normalization is untouched, so no shipped pipeline changes shape. ZscoreNormalizationGPU moves from GE to NORM, which is what puts it in the same choice as the rest. In random_order mode that takes it out of the shuffled GE bucket and makes it a trailing stage; every shipped config sets its p to 0.0, so nothing fires differently, but the GE bucket no longer spends a draw on it and random_order runs will not reproduce earlier volumes bit for bit under one seed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to hm/pipeline-stats-restore # Conflicts: # smauglab/configs/all_augmentations.json
…tore it after `retain_stats` restores a channel's mean and standard deviation one transform at a time, which pays for N restores to end up where the last one puts it, knows only two statistics -- so a transform that manufactures extreme voxels drags the standard deviation and the restore with it -- and restores the input's profile exactly, which is the opposite of what a domain-generalising pipeline wants. This is the same idea for the whole pipeline. `pipeline.retain_stats` brackets the augmentation stack: the snapshot runs after the geometry and records the per-sample, per-channel mean, standard deviation, 1st and 99th percentile; the restore runs before the trailing crop and normalization. Four statistics over-determine an affine map, so each pass draws *two* of them and solves for the map that puts exactly those two on target, leaving the other two where the augmentations left them. Writing each statistic as `t = a * u + b * c`, with `c = 0` for the offset-invariant standard deviation, makes all six pairs one 2x2 solve. The drawn pair is itself an augmentation: it spans "keep the moments", which is what `retain_stats` did, and "keep the robust dynamic range", which the outliers an edge filter manufactures cannot move. `std_scale` and `percentile_scale` then jitter the targets; the mean is never jittered, being the anchor every pair shares. Crop moves after the restore because it occludes a random box with the volume's own minimum rather than changing intensities, and the normalization stage stays last because deciding the scale the network sees is its whole job. With no `pipeline.retain_stats` block nothing is emitted and the RNG stream is untouched; with one, the restore spends a fixed five uniforms per channel whichever pair comes out, so a jitter sweep is comparable against its own baseline at a seed. `retain_stats` itself is untouched. The new shipped config turns it off on every transform, because the two do not compose, and leaves all 33 existing configs byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this change?
pipeline.retain_statsbrackets the augmentation stack with one snapshot and one restoreof the image's intensity profile, in place of the per-transform
retain_statsflag:The snapshot records the per-sample, per-channel mean, standard deviation, 1st and 99th
percentile after the geometry. The restore runs before the trailing crop and
normalization.
Two of the four, drawn per batch. An affine map
y = ax + bhas two degrees offreedom, so four targets over-determine it. Each pass therefore draws two of the four
and solves for the map that puts exactly those two on target, leaving the other two where
the augmentations left them. Writing each statistic as
t = a * u + b * c, withc = 1for the three that move with an offset and
c = 0for the offset-invariant standarddeviation, makes all six pairs one 2x2 solve:
s'/s_xm - a*m_x(m - a')/(m_x - a_x)m - a*m_x(b' - m)/(b_x - m_x)m - a*m_xs'/s_xa' - a*a_xs'/s_xb' - a*b_x(b' - a')/(b_x - a_x)a' - a*a_xThe drawn pair is itself an augmentation, and the pool spans the two useful extremes:
(mean, std) is what per-transform
retain_statsdid, and (p1, p99) is the robust dynamicrange, which the outliers
RandomScharrGPUorRandomRandConvGPUmanufacture cannotmove -- those outliers are exactly what make the standard deviation a bad target.
statsis the pool, so["mean", "std"]reproduces the old behaviour at pipeline level.Jitter.
std_scalemultiplies the standard-deviation target andpercentile_scalemultiplies each percentile's distance from the mean, independently, so the span can widen,
narrow or go asymmetric. The mean target is never jittered: it is the anchor every pair
shares, and moving it too would put two unrelated augmentations behind one knob.
An absent block is off and emits nothing; a present but empty one is on with the defaults,
which restores the drawn pair exactly.
Why?
retain_statsrestores once per transform, so a chain of them pays for N restores to endup where the last one puts it; it knows only two statistics, so an edge filter's outliers
drag the standard deviation and the restore with it; and it restores the input's profile
exactly, which teaches the network that its input scale is reliable. That is the
opposite of what the rest of this pipeline is for. #135 says of its own randomized
normalization that "there is no compensating re-normalization at the end"; this is that
compensation, and it is placed before the normalization stage rather than after it.
How was it tested?
pytestpasses locallypre-commit run --all-filespassesunit_tests/test_pipeline_stats.py, 38 tests / 42 subtests: each of the six pairs landsits two statistics (max |d| 0 to 6e-8 on a
[2, 1, 24, 24, 24]batch) while the other twodemonstrably move; parity with
_restore_statswhen the pool is fixed to (mean, std);the jitter stays inside its configured range over 25 seeds and is wider than 0.1; the draw
count is exactly five uniforms per channel and does not change with the ranges or the size
of the pool; a constant patch and an unlabelled mask are left alone;
stats_from="seg"really measures the anatomy only; the emitted order in all three pipeline modes and under
pipeline.order: config; that the only transform the bracket moves is the crop; the mask,in-place and both-kornia-routes contracts; and the config surface, including that the two
paper configs do not carry the block.
Full suite,
seg11(nnunetv22.4.2): 9 failed, 621 passed, 18 skipped. Measured onthis branch's merge base in a worktree: 9 failed, 583 passed -- the same nine
test_trainers.pysubtests, which are the documented>=2.5pin, so this adds 38 testsand no failures.
ruff check/ruff format --checkclean,mypyclean on the changedfiles locally (2.1.0 here; CI's 2.4.0 is the real gate),
smauglab matrix --checkexit 0.End to end on one A40, through
smauglab/configs/transform_params_gpu_stats-restore.jsonunder
torch.autocast("cuda", float16): 50 batches of(2, 1, 64, 64, 64)held shape,device, finiteness and label integrity;
record_applicationsshows the bracket around theaugmentations that fired; on a 4-channel batch with the pool fixed to (mean, std) the
restore lands within 3e-7 per channel while p1/p99 move; and a config that names a crop
emits
... StatsRestoreGPU, RandomCropTransformGPU, ZscoreNormalizationGPUand runs finite.Anything reviewers should look at closely?
AugType.GEObut it is not anintensity augmentation: it occludes a random box with the volume's own minimum without
changing the shape, so a profile measured before it would be restored against the
occlusion. It is active (
p: 0.25) intransform_params_gpu.json,transform_params_gpu_default01-23_CropTransform.jsonandtransform_params_gpu_palette-em.json; none of them enables the bracket, and with theblock absent nothing moves.
test_the_only_transform_the_bracket_moves_is_the_croppins that the rest of the order is untouched.
bracket a single normalization keeps its pipeline position verbatim (feat: add the main GPU normalization schemes and a random choice between them #135's rule); with
it, the restore has to come first, so it is staged. In
random_ordermode the snapshotdeliberately lands before the hoisted
RandomLowResTransformGPU, which is a generalenhancement that merely runs early -- blurring moves the standard deviation and belongs
inside the bracket.
gpu/stats.py), which the restore consumes andclears. The restore raises rather than guessing if it runs without a snapshot or if the
batch size changed between the two. Both are pinned.
whatever the ranges -- so a jitter sweep is comparable against its own baseline at a
fixed seed, which is invariant 2 of
gpu/regions.py. The pair is drawn by scaling oneuniform into the table, the fixed-width trick
UNIT_DRAW_WIDTHuses, rather than bytorch.randint, so adding a statistic tostatsdoes not reshape the stream.unbiased=False), matchingZscoreNormalizationGPU,nnU-Net and
gpu/normalization.py-- not the sample one_channel_statsuses. On a 24^3patch that is a factor of 1.00004, which is why the parity test against
_restore_statshas a tolerance.
retain_statsis untouched, as requested. The new config sets it tofalseon all15 transforms that accept it, because the two do not compose. No existing config is
modified: all 33 are byte-identical to this branch's merge base.
hm/gpu-normalization-schemes) into Finish in_seg / out_seg: where an augmentation applies, at what granularity, and how sharply it stops #138's branch, to reuseAugType.NORM,_normalization_stage,_masked_quantilesand_resolve_maskratherthan write second copies. The only merge conflict was
smauglab/configs/all_augmentations.json, resolved by regenerating it withsmauglab matrix --write. So the diff shown here is a 4-deep stack: Give every GPU transfer augmentation a configurable mix range #137 -> Finish in_seg / out_seg: where an augmentation applies, at what granularity, and how sharply it stops #138 ->merged feat: add the main GPU normalization schemes and a random choice between them #135 -> this.
Checklist
Augmentation behaviour is unchanged, or the change is intentional and described above
Unchanged with the block absent, which is every existing config.
New transforms are reachable from a config JSON
Not transforms:
StatsSnapshotGPUandStatsRestoreGPUare pipeline structure, emittedby
build_gpu_pipelinelike the 2D/3D convertersbuild_cpu_pipelineputs aroundSpatialTransform, and carry no registry entry. The feature is reached throughpipeline.retain_stats, whichsmauglab/config.pyvalidates key by key and value byvalue, and
smauglab/configs/transform_params_gpu_stats-restore.jsonships it.🤖 Generated with Claude Code