Repository navigation
feat: GIN, a cascade of random convolutions for domain generalisation - #131
Open
Hendrik-code wants to merge 1 commit into
Open
Hendrik-code wants to merge 1 commit into
Hendrik-code wants to merge 1 commit into
Conversation
GIN pushes the image through a shallow convolutional network whose weights are
drawn afresh on every call, blends the result back towards the original at a
per-sample alpha, and rescales so the output carries the same energy as the
input. Because the network is random rather than trained, the family of
intensity mappings it realises is far wider than any fixed curve, which is the
point: it is a single-source domain-generalisation augmentation.
Ouyang, C., Chen, C., Li, S., Li, Z., Qin, C., Bai, W., & Rueckert, D.
(2022). Causality-inspired single-source domain generalization for medical
image segmentation. IEEE TMI 42(4), 1095-1106. DOI 10.1109/TMI.2022.3224067
Ported from the authors' own 3-D implementation (`models/imagefilter3d.py` in
cheng-01037/Causality-Medical-Image-Domain-Generalization) and the cleaner
nnU-Net-oriented restatement in `dg_tta/gin.py`
(multimodallearning/DG-TTA). Both are MIT licensed.
`RandomRandConvGPU` was the closest thing here and is single-layer RandConv:
one random kernel, no non-linearity, no output renormalisation. GIN is its
multi-layer non-linear generalisation, so it gets its own AugId -- sharing
RAND_CONV's would have overwritten that transform's matrix cell -- and sits
next to it in PIPELINE_ORDER.
Two deliberate departures from upstream, both argued at their call sites:
* Reflect padding rather than zeros. Every other convolution in this package
reflect-pads, and a zero-padded 3x3x3 kernel darkens the patch border, which
an nnU-Net patch has no business having: it is an interior crop, not a whole
image. `padding_mode="zeros"` restores upstream exactly, and
`test_zero_padding_changes_only_the_border` pins that the choice reaches
exactly as far into the patch as the cascade can.
* float32 as a floor for the cascade and the norm. The trainer runs the GPU
pipeline inside `autocast`, so `F.conv3d` would hand back float16; the
kernels are raw N(0, 1) with no fan-in scaling, and simulating the stack
without the forcing, `||mixed||_F` passed float16's 65504 in 23 of 150 draws
at the defaults on a 1x128^3 patch, 48 of 150 on 2x192^3, and 105 of 150 with
kernel_sizes=(1, 3, 5, 7). The failure is not NaN and that is the whole
problem: the norm comes back `inf`, `1 / (inf + 1e-5)` is exactly 0, and the
output is an all-zero patch that `torch.isfinite(...).all()` reports as fine.
Neither `_select_and_check` nor the trainer's non-finite-loss guard can see
it, and the network trains on a blank volume. `promote_types` rather than
`.float()` so a float64 input keeps its precision.
The unscaled kernels are safe because of the Frobenius renormalisation, which
is exact rather than statistical: the per-sample output RMS matches the input's
to within 4.1e-07 relative for every combination of n_layers in {1, 2, 4, 8}
and kernel_sizes in {(1,), (3,), (1,3), (1,3,5,7)}. That is why GIN needs no
`1/sqrt(k**3)` where RandConv does. The invariant is on RMS and not on the
standard deviation: the biases and the leaky_relu move energy into the DC term,
so the standard deviation still varies by about 1.3x across those settings.
`test_without_the_frobenius_norm_the_scale_does_track_the_configuration` is the
control -- turning the renormalisation off lets the scale run away by 19197x
over the same sweep.
The channels named by `apply_to_channel` are mapped jointly, by one network
with that many inputs and outputs, because cross-channel mixing is the
published method. For single-channel data, which is the usual nnU-Net case,
that is identical to a per-channel loop. It does make a duplicate entry change
the network's width rather than merely applying it twice, so `(0, 0)` is now
rejected at construction.
There is no `mix_prob`, unlike the sibling transforms: `alpha_range` already is
the blend back towards the original, and a `mix_prob` on top would blend twice.
Augmentation behaviour is unchanged. `transform_params_gpu.json` gains
`"RandomGINGPU": {"p": 0.0}` so the default config names it and a sweep only
has to flip one number; the config has no `pipeline` section so it runs
SEQUENTIAL, and `_leaf_parameters` short-circuits p == 0 without touching the
RNG. Verified by building the pipeline from the config before and after under
one seed: bitwise identical, max abs diff 0.0, against 2.886 for the same entry
at p=1.0. Only the content hash moves, 49138fe4 -> 1c7447e7.
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?
Adds
RandomGINGPU, a GPU implementation of GIN (Global Intensity Non-linearaugmentation): the image is pushed through a shallow convolutional network whose weights
are drawn afresh on every call, blended back towards the original at a per-sample
alpha,and rescaled so the output carries the same energy as the input.
Ported from the authors' own 3-D implementation (
models/imagefilter3d.pyincheng-01037/Causality-Medical-Image-Domain-Generalization)
and the cleaner nnU-Net-oriented restatement in
dg_tta/gin.py(multimodallearning/DG-TTA). Both are MIT
licensed; this is a fresh implementation in SmaugLab's idiom with attribution in the module
docstring.
New files:
smauglab/transforms/gpu/gin.py,unit_tests/test_gin.py. Wiring:AugId.GIN+ aPIPELINE_ORDERslot inregistry.py, the import intransforms/__init__.py, the module inTRANSFORM_MODULES, and the regeneratedREADME.mdmatrix row andall_augmentations.jsonblock.Why?
RandomRandConvGPUwas the closest thing we had and is single-layer RandConv: onerandom kernel, no non-linearity, no output renormalisation. GIN is its multi-layer
non-linear generalisation, and the one structural idea the transfer-augmentation set was
missing. It gets its own
AugIdrather than sharingRAND_CONV— sharing would haveoverwritten
RandomRandConvGPU's matrix cell.How was it tested?
pytestpasses locally — 472 passed, 15 skipped, 1665 subtests (the skips are thepre-existing
$SMAUGLAB_DOMAIN_BANKandbuild-package ones)pre-commit run --all-filespassesunit_tests/test_gin.py, 19 tests / 64 subtests[2, 1, 128, 128, 128]on one A40 insidetorch.autocast("cuda"), output finite, energy-matched, 3.8–5.5 ms per batchagainst 0.7 ms for single-layer RandConv
mypy smauglab/is clean. Note my localmypyis 2.1.0 andruff0.15.20, not the pinned2.4.0 / 0.16.1 — but
pre-commitinstalls its own pinned ruff and that passed, so only themypy version is unverified against CI's.
Anything reviewers should look at closely?
The float16 guard is the part I would check first. The trainer runs the GPU pipeline
inside
autocast, soF.conv3dwould hand back float16; the kernels are rawN(0, 1)withno fan-in scaling, and simulating the stack without the forcing,
||mixed||_Fpassedfloat16's 65504 in 23 of 150 draws at the defaults on a 1×128³ patch, 48 of 150 on
2×192³, and 105 of 150 with
kernel_sizes=(1, 3, 5, 7). The failure is not NaN, and thatis the whole problem: the norm comes back
inf,1 / (inf + 1e-5)is exactly0., andthe output is an all-zero patch that
torch.isfinite(...).all()reports as fine — soneither
_select_and_checknor the trainer's non-finite-loss guard can see it, and thenetwork trains on a blank volume.
apply_transformtherefore runs the cascade and the norminside an
autocast_active()-guardedenabled=Falseregion, withpromote_typesratherthan
.float()so a float64 input keeps its precision.test_a_float16_input_stays_finite_and_energy_matcheduses the configuration measured tofail in 19 of 20 draws without it, and asserts the energy match rather than just finiteness.
Two deliberate departures from upstream, both argued at their call sites:
reflect-pads, and a zero-padded 3×3×3 kernel darkens the patch border, which an nnU-Net
patch has no business having — it is an interior crop, not a whole image.
padding_mode="zeros"restores upstream exactly.No fan-in scaling on the kernels, unlike
_RandomConvBaseGPU.get_kernel's1/sqrt(k**3). That is safe here and not there, because the Frobenius renormalisation isexact rather than statistical: the per-sample output RMS matches the input's to within
4.1e-07 relative for every combination of
n_layers ∈ {1,2,4,8}andkernel_sizes ∈ {(1,), (3,), (1,3), (1,3,5,7)}. The invariant is on RMS, not on thestandard deviation — the biases and the
leaky_relumove energy into the DC term, so thestandard deviation still varies by about 1.3x across those settings, which is why this does
not copy
test_randconv_gain.py's metric.test_without_the_frobenius_norm_the_scale_does_track_the_configurationis the control:out_norm="none"lets the scale run away by 19197x over the same sweep.Joint channel mapping.
apply_to_channelis mapped by one network with that manyinputs and outputs, because cross-channel mixing is the published method. For
single-channel data — the usual nnU-Net case — that is identical to a per-channel loop. It
does mean a duplicate entry changes the network's width rather than merely applying it
twice, so
(0, 0)is rejected at construction; every sibling treats it as harmless.No
mix_prob, unlike the siblings:alpha_rangealready is the blend back towardsthe original, and a
mix_probon top would blend twice.Checklist
Augmentation behaviour is unchanged, or the change is intentional and described above
Unchanged.
transform_params_gpu.jsongains"RandomGINGPU": {"p": 0.0}so the defaultconfig names it and a sweep only has to flip one number. The config has no
pipelinesection so it runs
SEQUENTIAL, and_leaf_parametersshort-circuitsp == 0totorch.zeros(batch)without touching the RNG. Verified by building the pipeline from theconfig before and after under one seed: bitwise identical, max abs diff 0.0, against
2.886 for the same entry at
p=1.0. Only the content hash moves,49138fe4→1c7447e7.No other
transform_params_*.jsonis touched.New transforms are reachable from a config JSON
Via the regenerated
all_augmentations.jsonand viatransform_params_gpu.jsonabove.🤖 Generated with Claude Code