Feat: training experiments#29
Merged
anna-grim merged 56 commits intoJul 16, 2026
Merged
Conversation
Add a shared, stateless transform module (asinh and generalized Anscombe) that maps raw counts to a bounded, network-friendly domain and back without a hard brightness clip, so bright neurites keep distinct, invertible values instead of being flattened by a percentile clip. Includes offset calibration helpers and a config-driven factory, with unit tests covering round-trip, monotonicity, boundedness, and the documented Anscombe inverse behavior. This is Part A of the denoising/compression normalization overhaul; it is not yet wired into training or inference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part D quick fixes ahead of the normalization overhaul: - sample_skeleton_voxel jittered the patch center with a positive-only shift (0..15), biasing the skeleton into one octant off-center; use a symmetric +/- patch_shape//4 shift so the neurite is centered. - init_datasets built ValidateDataset with default args, so a non-default training sigma_bm4d silently diverged from validation; pass it through. - forward_pass moved tensors to a hard-coded "cuda" despite Trainer.device, and the inference tensor helpers forced "cuda"; use the model's device. evaluate.py's device hard-coding is left for the Part B repair (that file is already broken against current inference and will be rewritten there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part B of the normalization overhaul. Replace the per-patch percentile
normalize-and-clip with one fixed, stateless intensity transform used
identically in training, validation, and inference, eliminating the
train/inference mismatch and the bright-tail plateau.
- transforms.py: add LinearClipTransform (frozen linear+clip baseline for
A/B) and stamp the frozen cfg onto instances via build_transform.
- data_handling.py: TrainDataset/ValidateDataset take a transform, forward()
raw and BM4D target (clipped to the physical range first), and return
(x, y); drop mn/mx threading and the normalize() helpers. init_datasets
resolves one transform from transform_cfg (optionally calibrating the
offset from a global sample) and shares it across both datasets.
- train.py: iterate (x, y); compute_cratios inverts via the transform;
save_model bundles {model, transform cfg}; load_pretrained_weights reads
either format.
- inference.py: predict/predict_patch take a transform and use
forward()/inverse() (no more hard-coded 0..5 clip); load_model returns
(model, transform) and reads new or legacy checkpoints.
- evaluate.py: repair to the new predict API (drop nonexistent stitch,
single-array return), read_patch instead of get_patch, thread transform
and device, handle both checkpoint formats.
- img_util.py: add compute_mae and compute_lmax (used by evaluate and the
Part C metrics); evaluate now uses the existing ssim3D.
Verified: 17 transform tests pass; all modules import (evaluate.py was
previously unimportable); functional smoke test covers predict,
predict_patch, both checkpoint formats, compute_cratios, and
ValidateDataset end to end on CPU.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part C of the normalization overhaul: instrument validation so we can tell whether a transform preserves bright neurites and what it costs in compression, and select checkpoints on that rather than global L1. - metrics.py (new): count-space metrics computed after transform.inverse - foreground/background MAE (foreground vs raw, background vs BM4D target), top-0.1% preservation, MIP-max error, false-bright-voxel rate - plus a robust foreground mask and a configurable checkpoint_score (cratio weight defaults to 0, so selection is fidelity-driven until the operating point is chosen). - data_handling.py: ValidateDataset also stores raw counts and a foreground mask and yields (x, y, raw, fg_mask); DataLoader._load_batch is generalized over tuple length so train stays (x, y) while validation carries metadata. - train.py: validate_step computes and logs the metrics, selects the best checkpoint by the joint score (best_score), and no-ops cleanly when there are no validation examples; Trainer takes checkpoint_weights. Verified: 24 transform+metric unit tests pass; functional smoke covers the validation 4-tuple, generalized batching, validate_step scoring/selection, and the empty-validation no-op on CPU. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part E of the normalization overhaul. Stop the BM4D teacher from erasing neurites, and stop the background from dominating the loss. - losses.py (new): SignalPreservingLoss, a foreground-weighted Charbonnier loss (fg_weight=0 reduces to a plain Charbonnier mean). Operates in the transform domain (relative/Weber precision); a count-space / Jacobian variant is deferred, tied to the operating-point decision. - data_handling.py: TrainDataset builds a high-confidence foreground mask (robust intensity threshold, unioned with segmentation labels when available) and sets target = where(fg, raw, bm4d) so raw counts survive on the foreground; __getitem__ now returns (x, y, fg_mask). ValidateDataset does the same with its intensity mask. A preserve_foreground flag on both (and init_datasets) makes the target change ablatable. - train.py: criterion is SignalPreservingLoss(fg_weight); forward_pass takes and applies the foreground mask; train/validate loops thread it through. - util.py: sample_once now coerces to a list so it accepts dict_keys/sets; this pre-existing bug broke TrainDataset.__getitem__ (hence training) on Python 3.11+ where random.sample rejects non-sequences. Ablation knobs: preserve_foreground (target construction) and fg_weight (loss weighting) are independent. Verified: 29 unit tests pass (incl. new loss tests); functional smoke covers the TrainDataset 3-tuple, the preserve_foreground toggle, and forward_pass with the new criterion on CPU. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part F of the overhaul. The bright-patch sampler required ~1000 voxels above a hard-coded intensity of 100, which biases toward somata/blobs and misses thin fibers (a fiber through a 64^3 patch is only a few hundred voxels). - sample_bright_voxel now counts foreground with the same robust mask used for targets and metrics (median + k*sigma, adaptive per patch) instead of a fixed >100 cutoff, and accepts a patch once it has min_foreground_voxels (default 50, was effectively 1000). Lowering the bar also cuts the brute-force read cost, since a qualifying patch is usually found in the first prefetch round. - sample_segmentation_voxel's object-size requirement is parameterized as min_segmentation_volume (default 200, was hard-coded 1600) so thinner segmented fibers qualify. - Both thresholds thread through init_datasets. Not implemented (documented as follow-ups): the 4-way mix with hard-negative patches (needs an artifact detector / labels we don't have) and a whole-volume foreground-coordinate cache (infeasible to scan whole-brain volumes; skeletons already provide an efficient cached foreground source). Verified: functional smoke confirms the robust sampler finds foreground-rich patches on an in-memory volume; 29 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part G of the overhaul (sequenced last; changes weights, so it invalidates existing checkpoints). - Residual output: UNet returns input + outc(features) when residual=True (new default), so the network learns the denoising correction rather than reconstructing the whole signal. This pairs well with the Part E foreground-preserving target: the correction is ~0 on the foreground (preserve) and nonzero on the background (denoise). Gated by a flag so it is ablatable. - DoubleConv uses GroupNorm(min(8, C), C) instead of BatchNorm3d, removing sensitivity to rare bright patches in a batch and to batch size. Checkpoint note: GroupNorm changes the state_dict keys (no running stats) and the residual output changes output semantics, so pre-Part-G checkpoints are not loadable/compatible - expected for this stage. Verified: forward preserves shape; GroupNorm present with no BatchNorm3d; the residual flag passes the input through when the output head is zeroed; the Part C/E validation and training smokes still pass; 29 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Support a per-brain (per-acquisition) background offset so that
background-subtracted training data and raw inference data are normalized
into the same background-at-zero space. This dissolves the train/inference
regime mismatch (and the mixed subtracted/raw training set): without it, a
raw background of ~100 maps to ~0.22 in asinh space while the network was
trained with background at ~0.
The offset is applied as a subtraction at the dataset (raw - offset[brain])
with the shared transform at offset 0, which keeps the Trainer fully
offset-agnostic (no per-example threading) and puts every brain in one
"corrected" space.
- data_handling.py: TrainDataset/ValidateDataset and init_datasets take an
offsets dict {brain_id: offset} and subtract it from each raw patch before
bm4d/transform. Default None => no subtraction (backward compatible).
- transforms.py: estimate_offset gains ignore_zeros (default True) so
zero-padding does not drag the estimate to 0; add with_offset() to clone a
transform with a new offset.
- inference.py: build_volume_transform(base_transform, img) estimates the
per-volume offset from a raw image (nonzero low percentile) and returns a
transform carrying it -- the inference counterpart of the training offset.
Offsets are precomputed by code/estimate_background_offsets.py. When using
them, set the transform offset to 0 (the per-brain value replaces it).
Verified: 30 unit tests pass; functional smoke shows per-brain offset maps
background to ~0 (vs ~0.22 without) and build_volume_transform recovers the
volume background, ignoring padding and bright signal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the per-brain offset handling into evaluate.py so raw (non-background- subtracted) volumes are normalized to the same background-at-zero space the model was trained on. - SupervisedEvaluator: estimates each block's background offset via build_volume_transform (block-level, since blocks are standalone volumes). - UnsupervisedEvaluator: applies a per-brain offset via with_offset using an offsets dict (the same per-brain offsets used in training), estimated once per brain rather than per patch to avoid content-dependent offsets. - Both gated by a raw_input flag (default True); set raw_input=False when evaluating already-background-subtracted data. For raw data, pass UnsupervisedEvaluator the training offsets dict; without it the per-brain offset defaults to 0 (no correction). Verified: evaluate imports and compiles; the new raw_input/offsets params are present; 30 unit tests pass. (End-to-end eval needs cloud data + GPU, so the wiring is verified structurally on top of the already-smoke-tested build_volume_transform / with_offset.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_load_segmentation passed the segmentation prefix straight into the
tensorstore gcs kvstore "path" while hard-coding bucket="allen-nd-goog".
When the prefix is a full gs://allen-nd-goog/... URL (as in the segmentation
prefixes JSON), tensorstore concatenates bucket + path and looks for
gs://allen-nd-goog/gs://allen-nd-goog/.../info, which does not exist:
ValueError: NOT_FOUND: Error opening "neuroglancer_precomputed" driver
Add _parse_gcs_path to split a path into (bucket, key), tolerating either a
full gs://bucket/key URL (prefix stripped, bucket taken from the URL) or a
bucket-relative key (default bucket). Pre-existing bug, unrelated to the
normalization overhaul.
Verified: the exact failing path resolves to bucket=allen-nd-goog and a
prefix-free key; relative paths and other buckets handled; 30 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Training was CPU/IO-bound (per-patch BM4D + cloud reads), leaving the GPU idle. Two changes decouple that work from the training step. Patch cache: - Factor TrainDataset.__getitem__ into _sample_counts() (the expensive cloud read + BM4D + foreground mask, in offset-subtracted counts) and a shared build_training_example() (cheap transform + target construction). - Add CachedPatchDataset, which reads precomputed (raw, teacher, fg) patches from memory-mapped .npy pools and applies only the cheap step, so training becomes GPU-bound. Built by code/precompute_patches.py. DataLoader: - Rewrite as a prefetching loader: a background thread fills a bounded queue so batch prep overlaps GPU compute. The process pool is created once per epoch (not per batch), and the dataset is pickled to workers once at pool startup via an initializer instead of per task. num_workers=0 runs in-thread (ideal for the cheap cached dataset); None/>0 uses the pool (for the cloud dataset where BM4D dominates). - Trainer gains num_workers / prefetch and passes them through. code/ drivers (untracked): precompute_patches.py builds the cache; train_bm4dnet.py gains a cache_dir switch (num_workers=0 when cached). Verified: 30 unit tests pass; smoke covers build_training_example, CachedPatchDataset, and the prefetch DataLoader in both in-thread and persistent-pool modes; Part C validate_step and Part E getitem smokes still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each ts.open handle got its own 1GB data + 1GB remote cache pool, so with one dataset per worker (num_workers=CPU count) the un-shared per-brain caches accumulated toward an aggregate ceiling far above RAM. Random 64^3 patch sampling has low cache hit rate anyway, so set total_bytes_limit to 0 (no caching) at both ts.open sites and drop the now-moot recheck_cached_data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cache-backed runs still paid a full cloud+BM4D startup because the validation set was built live: init_datasets sampled voxels and ran a serial BM4D per example while the GPU sat idle (~16 min for 60 examples plus SWC downloads). Add a validation cache mirroring the training cache: - Extract ValidateDataset.sample_counts (the cloud read + BM4D + intensity-only foreground mask) and reuse it from ingest_example. - Add CachedValidateDataset: fixed set iterated in order, __getitem__ returns (x, y, raw, fg_mask) matching ValidateDataset for the count-space metrics. - Add scripts/precompute_val_patches.py: draws validation voxels as init_datasets does and stamps transform.json into the cache. - train_bm4dnet.py: when both cache_dir and val_cache_dir are set, run fully offline (no cloud reads or BM4D at startup); otherwise unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With preserve_foreground=True the foreground target is the raw (noisy) input, so fg_weight=20 heavily rewarded reproducing the input verbatim. The residual U-Net collapsed to a near-identity map (best checkpoint changed the input by <2 counts) and did essentially no denoising, so its output compressed no better than raw (cratio ~3.5 vs a BM4D target of ~15). Drop fg_weight to 2 so background denoising dominates the loss. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkpoint_weights=None scored checkpoints on fidelity only (cratio weight 0). Because the foreground target is the raw input, the identity map minimizes fg_mae, so fidelity-only selection actively picked the least-denoising checkpoint and was blind to compression. Set a nonzero cratio weight (using the operating point already documented here) so selection rewards compressibility. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CosineAnnealingLR(T_max=25) with max_epochs=400 returned the LR to its peak every 50 epochs (a warm restart), which repeatedly kicked the model out of convergence -- train/val loss spiked at epochs 52/104/154/197/238 with growing magnitude. Set T_max=max_epochs so the LR decays once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Training used float16 autocast with no loss scaling, so small gradients could underflow to zero and overflows went uncorrected -- contributing to the training instability. Wrap the backward/step in a GradScaler (enabled with AMP, a no-op otherwise) so the fp16 path is numerically stable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
60 validation patches made the reported median cratio (and therefore the compression-weighted checkpoint score) noisy: the per-patch metrics are heavy-tailed, so the bootstrap SE of the median cratio at N=60 is large relative to the gaps between good checkpoints. Bump the pool to 500, where that sampling error drops ~3-4x; returns diminish past ~1000 and disk stays cheap (~1.3 MB/patch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The count-space validation metrics are CPU-bound (~50 ms/patch), so a 500-patch validation set run every epoch would add hours over a full run and rival training time. Add a val_every knob (default 1, set to 5 in the train script) so validation and checkpoint selection run periodically instead of every epoch; the final epoch is always validated. This lets the larger, low-noise validation set stay cheap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sample_segmentation_voxel and sample_bright_voxel picked the best candidate in thread-completion order (as_completed), so ties -- and thus the chosen voxel -- varied run to run even with a fixed RNG. Consume the concurrent read results in submission order instead; reads still overlap, but selection is now deterministic, which is a prerequisite for a reproducible (seeded) patch cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brain/voxel sampling used the unseeded global RNGs, so each precompute run produced a different pool. Add a base `seed` (default 42, None to disable) threaded to each worker and applied per task via a SeedSequence keyed on (seed, stream, task_index). Because executor.map places result i at index i regardless of which worker runs it, the cache is now bit-reproducible and independent of num_workers. A distinct per-script stream id (0 train, 1 val) ensures the training and validation caches never sample the same (brain, voxel) at a given index when built with the same base seed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
precompute_patches.py and precompute_val_patches.py were ~90% identical:
the pool scaffolding, memmap write loop, seeding helper, and -- critically
-- the entire shared config block (brains, transform_cfg, offsets,
sampling params) were copy-pasted. That duplicated config was a latent
correctness trap: the train and val caches must use the identical mapping
(the training run applies the val cache's transform.json to the train
cache), enforced only by keeping two blocks in sync.
Replace both with scripts/precompute.py --split {train,val}: one config
block, one scaffolding, branching only on the sampler (TrainDataset mask
= intensity + segmentation vs ValidateDataset intensity-only), the RNG
stream id, and the per-split cache_dir / n_patches. Sampling draws are
bit-identical to the separate scripts at the same seed.
Also apply two behaviors uniformly that previously differed: stamp
transform.json for both splits, and refuse offset calibration for both
(each worker would otherwise calibrate on its own sample, mixing
inconsistent offsets into the cache). Update references in
train_bm4dnet.py and data_handling.py docstrings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- easier to quickly visualize
predict() allocated its overlap-add accumulators at numpy's default float64 and then formed the averaged result with two more full-volume temporaries. On a 1024^3 volume that is ~38 GiB of buffers, which OOM'd a 30 GB host. Use float32 accumulators and average in place (freeing the transformed input first), roughly halving peak memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The environment now runs zarr 3.2.1, where the zarr v2 store classes used
here were removed. Update the readers and OME-Zarr writer:
- _read_zarr: build the store from the path via
zarr.open(url, storage_options=...) (LocalStore / FsspecStore) instead
of DirectoryStore / zarr.storage.FSStore / s3fs.S3Map.
- _read_n5: zarr 3 dropped built-in N5 support; read via tensorstore's
n5 driver (the backend already used for neuroglancer volumes).
- write_ome_zarr: open_group from the path, and pass the compressor as a
zarr v3 BloscCodec via storage_options={"compressors": [...]}.
- Drop now-unused s3fs and numcodecs.Blosc imports.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Zarr v3-native single-array writer supporting local and cloud (s3://, gs://) destinations, used to persist denoised output. Stores the volume 5D so img_util.read round-trips it, with a zstd/shuffle BloscCodec matching the cratio codec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The foreground mask was a robust intensity threshold unioned with the segmentation labels (training) or intensity-only (validation). Because every brain is segmented, the intensity term only added bright voxels the segmenter did not label -- noise, autofluorescence, off-target label -- which were then preserved as raw counts (preserve_foreground) and rewarded by the intensity-only validation metric. The model was being taught to reproduce bright noise instead of denoising it. Define foreground from ground-truth annotations only: the segmentation labels unioned with the traced skeleton, each dilated. Raw intensity is dropped except as a fallback for brains with neither annotation. The skeleton union protects neurites the segmentation misses -- exactly the ground-truth locations skeleton sampling targets -- so those patches are not denoised away. - metrics.py: add make_segmentation_mask and make_skeleton_mask. - data_handling.py: TrainDataset.foreground_mask -> annotation_mask (seg U skeleton) + skeleton_mask; new skeleton_radius param (default 2); the validation path receives the finished mask from TrainDataset instead of recomputing from intensity. - precompute.py: val cache uses the annotation mask; skeleton_radius in config. Note: the patch caches bake the mask in, so both must be rebuilt (precompute.py --split train and --split val) for this to take effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some blocky regions of the raw exaSPIM image are bright salt-and-pepper noise from an upstream processing artifact. The FFN segments them, so they entered the foreground mask as large "neurite" blobs -- and because the target keeps raw counts on the foreground (and the loss up-weights it), they trained the denoiser to preserve noise, defeating the compression goal. They are brighter than real signal, so an intensity threshold cannot catch them. Detect them by spatial coherence instead: real neurites are PSF-blurred and stay correlated across the ~2-3 voxel PSF, while the artifact decorrelates immediately. A segment is flagged when it fails both a lag-2 autocorrelation test and a high-frequency-energy test -- lag 2, not 1, because the brightest artifacts still correlate at lag 1 but not at lag 2. Requiring both tests keeps thin-but-smooth faint neurites (low autocorrelation from thinness, but low high-frequency energy). When a patch contains such a segment, the whole patch is rejected and resampled: the artifact corrupts the raw input, not just the label, so the patch is a poor training example even with the label removed. The check runs before BM4D, so rejected patches are cheap, and it also counters the foreground-biased sampler's tendency to over-select these large blobs. Opt-in via reject_incoherent_patches (enabled in precompute.py); the traced skeleton is never gated. End-to-end on brain 784802 the foreground sampler drew 41/60 (68%) artifact-contaminated patches with the gate off and 0/60 with it on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- convert Neuroglancer XYZ coordinates to source-level voxel indices - validate crop bounds and report transformed crop coordinates - propagate source scale, translation, and units to denoised outputs - center-align coordinate transforms across multiscale levels - add regression coverage for metadata and negative coordinates
Training caches contain counts with their per-brain pedestal already subtracted, so models are trained with the frozen base transform and its original normalization denominator. The previous inference helper instead rebuilt asinh and Anscombe transforms with a new internal offset. That subtracted the pedestal correctly, but also changed their normalization denominator, creating a small coordinate mismatch between training and inference. Introduce OffsetTransform as an exact composition around the frozen transform: forward subtracts the runtime pedestal before calling the trained transform, while inverse converts predictions to floating-point corrected counts before restoring the pedestal and applying final physical clipping and uint16 rounding. Add inverse_float to each transform family so this inverse composition does not quantize or clip before the offset is restored. Keep the linear transform's existing shifted-bound implementation because it is already algebraically equivalent. Teach build_transform to reconstruct composed offset transforms and make repeated with_offset calls replace the runtime wrapper. Add regression coverage for exact asinh and Anscombe forward equivalence, pedestal-restoring inverse behavior, config reconstruction, precomputed inference offsets, and the abstract inverse_float contract.
Write offset-subtracted raw patches and clipped BM4D teachers as float32 instead of float16. Float16 increasingly coarsens count values across the uint16 sensor range, reaching multi-count quantization through the bright tail before the intensity transform or model sees an example. Float32 exactly represents the source integer counts while also retaining negative offset-subtracted samples and fractional BM4D estimates. Remove the float16 saturation helper, use one shared count dtype for both memory-mapped arrays, and record count_dtype in each cache config for reproducibility. Update cache-layout documentation and extend the precompute configuration test to cover the persisted and allocated dtype. Existing caches remain readable for backward compatibility but must be rebuilt to receive the precision improvement.
-src/aind_exaspim_image_compression/machine_learning/data_handling.py: index-addressable cache dataset and deterministic (seed, epoch) shuffling with partial-batch retention. - src/aind_exaspim_image_compression/machine_learning/train.py: training shuffle wiring, set_epoch, and persisted seed. - scripts/train_bm4dnet.py: 20 epochs, validation every epoch, seed 42, and removed per-epoch sample count. - tests/test_full_cache_training.py: focused cache, ordering, reproducibility, trainer, and partial-batch tests.
- enabling AMP can decrease compression ratios during inference - all current models were trained using AMP. We need to be able to test training without.
Member
Author
|
Py310 tests fail due to the new >=3.11 requirement for zarr>=3. The actions could be fixed in a separate PR. |
anna-grim
approved these changes
Jul 16, 2026
anna-grim
left a comment
Collaborator
There was a problem hiding this comment.
The code looks great and is a huge improvement to the repo. I really don't see any obvious issues or bugs, so i'll just go ahead and merge this branch
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.
Summary
This PR changes the project from a live, per-patch training pipeline into a two-stage system with reproducible cached inputs and cache ordering:
The normalization contract is now explicit and shared by dataset generation, training, validation, and inference. Bright intensities are no longer flattened by a per-patch percentile clip, and raw-volume background offsets are handled without changing the mapping learned during training.
mainfeat-training-experimentsDataset.__getitem__End-to-end data flow
Dataset preparation and target construction
Patch generation is now an explicit offline stage
scripts/precompute.pyreplaces live data preparation with a single--split {train,val}workflow. Both splits use the same source, sampling, offset, BM4D, mask, and transform configuration; only their sampling stream and downstream dataset interface differ.Each cache contains:
raw.npyfloat32,(N, D, H, W)teacher.npyfloat32,(N, D, H, W)fg.npyuint8,(N, D, H, W)transform.jsonconfig.jsonThe count arrays were deliberately changed from
float16tofloat32.float16increasingly quantizes values across the uint16 sensor range and can lose multiple counts in the bright tail before either the transform or the model sees the sample.float32exactly represents source uint16 counts and also preserves negative offset-corrected values and fractional BM4D estimates. The tradeoff is a larger cache: the current 30,000-patch training configuration is documented as roughly 71 GB.Training now requires both caches and validates that all four required data/transform files exist and that train and validation use identical transform configurations. There is no live cloud/BM4D fallback in
scripts/train_bm4dnet.py. This makes a training run self-contained and prevents a subtle train/validation mapping mismatch.Foreground masks now come from annotations
The branch's first foreground-mask implementation combined an intensity mask with segmentation labels. The final mask is instead built from annotations:
The union protects traced neurites that the segmentation misses while avoiding the interim behavior of treating every unusually bright structure as signal. That intensity-based mask preserved bright noise, autofluorescence, and off-target label as though they were neurites. A robust median/MAD intensity mask remains only as a fallback for brains with neither segmentation nor SWC annotations.
This does not make the final mask immune to annotation errors. It trusts all positive FFN labels unless the patch-level coherence gate detects the specific spatially incoherent artifact described below; smooth mislabeled structures can still enter the foreground mask.
Validation uses the same completed annotation mask as training, rather than recomputing an intensity-only mask. This is important because the training target, foreground loss term, and validation metrics must agree on what "signal" means.
SWCs are assumed to be densely sampled in voxel space. Loading now warns when a parent-child link spans more than one voxel in Chebyshev distance, while correctly accepting one-step 3D diagonals. The code does not interpolate long edges silently; it rasterizes the supplied nodes.
Foreground-preserving targets are an ablation, not the current default run
The shared target builder supports:
when
preserve_foreground=True. The rationale is that BM4D can erase thin neurites, so the teacher should supervise denoising on the background without asking the model to reproduce that foreground attenuation.This mechanism remains available independently from foreground loss weighting, but the current branch-tip experiment sets:
Therefore, the configured run currently trains against BM4D everywhere with a uniform Charbonnier objective. This is an experiment-driven operating-point choice, not removal of the new infrastructure. Earlier runs found that a raw foreground target combined with strong foreground weighting rewarded an almost identity mapping: the output changed the input by fewer than a few counts and compressed approximately like raw data rather than like BM4D. The branch first reduced the weight and ultimately disabled both knobs in the current entrypoint. Because raw, teacher, and mask remain separate in the caches, either target or loss-weighting ablation can be changed without rebuilding the patch pool.
Sampling is more representative of thin fibers
Foreground-biased sampling remains a mixture of skeleton, segmentation, and image-driven candidates, but several biases were corrected:
>100count threshold.Spatially incoherent artifacts are rejected before BM4D
Some bright salt-and-pepper processing artifacts are labeled as objects by the FFN segmenter. They cannot be removed merely by trusting segmentation or by an intensity threshold: they are often brighter than real signal.
The precompute configuration enables
reject_incoherent_patches. Each labeled segment with enough voxels is tested for both:A segment is considered an artifact only when it fails both tests, protecting thin but spatially smooth neurites from false rejection. The whole patch is resampled because the raw input itself is corrupted; simply deleting the mask would still train on a bad input. The check runs before BM4D, so rejected examples avoid the most expensive preprocessing step. A bounded retry count prevents fixed-size cache generation from stalling indefinitely; after the configured 50 failed attempts, the last patch is accepted even if contaminated.
Normalization and background transforms
One frozen transform replaces per-patch percentile normalization
On
main, each training patch computed its own low/high percentiles, normalized against them, and clipped the normalized bright tail. Inference had separate normalization and a hard-coded clip. This caused two core problems:machine_learning/transforms.pyintroduces fixed, stateless, configuration-driven transforms that are shared unchanged by cache datasets, validation, checkpoint metrics, and inference.AsinhTransformAnscombeTransformLinearClipTransformThe transform is normalized against the physical sensor maximum (65535 for uint16) rather than a patch statistic. Small negative normalized values for below-background counts are intentional, preserving noise-floor symmetry. The final inverse alone clips to the physical range and rounds to uint16.
Per-brain pedestal correction aligns acquisitions
Training data can mix background-subtracted and raw acquisitions whose background pedestals differ. Under a fixed nonlinear transform, a raw background around 100 counts does not map to the same network value as a subtracted background near zero.
The PR adds
scripts/estimate_background_offsets.pyto estimate a low, nonzero percentile from a coarse multiscale level for every brain. Zeros are ignored so padded regions do not pull the estimate to zero.During cache generation, the selected per-brain value is subtracted from raw counts before BM4D and before the shared base transform. The base transform therefore stays fixed at offset zero for all cached examples.
At inference,
OffsetTransformcomposes the runtime pedestal around the frozen base transform:Production inference should supply an offset precomputed from the full image tile at a lower-resolution level.
Transform identity is persisted and enforced
transform.json.This turns normalization from ephemeral per-example metadata into part of the model's versioned contract.
Training objective, architecture, and loop
Loss function
Uniform
nn.L1Lossis replaced bySignalPreservingLoss, a smooth Charbonnier penalty with voxel weight1 + fg_weight * fg_mask. It is less sensitive to outliers than L2 while retaining an L1-like objective, and it can upweight sparse neurites that would otherwise be numerically dominated by background voxels.U-Net behavior
The overall 3D U-Net topology remains, with two material changes:
prediction = input + learned_correction. Denoising is naturally a correction problem, and the desired correction is near zero where signal should be preserved.BatchNorm3dis replaced byGroupNorm. GroupNorm is independent of batch statistics and is less sensitive to small batches and rare bright patches. The group count usesgcd(8, channels)so custom integer width multipliers always divide the channel count.width_multiplieris now validated as a positive integer instead of silently truncating fractional channel counts. The model exposes a constructor config containing width, upsampling mode, and residual mode so inference can recreate the exact architecture from a checkpoint.These changes intentionally break compatibility with pre-overhaul weights: GroupNorm changes state-dict keys, residual output changes output semantics, and the learned coordinate system changed with normalization.
Full-cache epoch semantics and reproducibility
CachedPatchDatasetis index-addressable and its length is the actual cache size. A training epoch now visits every cached item exactly once. The loader uses a deterministic permutation from(seed, epoch), so:This replaces the former
n_examples_per_epochsequence of fresh random draws. Epoch counts and learning-rate schedules are therefore not directly comparable between the branches.Optimization and numerical stability
CosineAnnealingLR.T_maxnow equalsmax_epochs, producing one decay across the run. The former fixedT_max=25returned the learning rate to its peak every 50 epochs and correlated with repeated, growing loss spikes.GradScaler, preventing small gradients from underflowing and adapting to overflow.Trainer.deviceinstead of forcing CUDA in helper paths. AMP autocast and its scaler remain CUDA-specific, so a non-CUDA run must setuse_amp=False.val_everydecouples CPU-heavy validation from epoch cost, while always validating the final epoch. The current full-cache experiment chooses every epoch, but the general trainer still supports a longer interval.Run and checkpoint provenance
Each session records a
config.jsonwith cache locations, transform, target policy, model constructor configuration, batch size, learning rate, epoch count, loader settings, validation cadence, seed, foreground weight, and checkpoint weights.Checkpoints now contain:
Every evaluated checkpoint after epoch index 0 is saved, with its selection score in the filename, so operating points can be compared offline. Index 0 has already completed one training pass, but the still-early model was empirically susceptible to nearly constant, trivially compressible output and a spuriously good compression-aware score. The
is_bestflag is now informational rather than deciding whether a checkpoint exists.resume_pathrestores compatible model weights, but it is not a full training resume: optimizer state, scheduler state, epoch number, scaler state, and the previous best score are not serialized.Training previews changed from full 3D TIFFs to percentile-stretched PNG maximum-intensity projections, which are much faster to inspect.
Validation, evaluation, and checkpoint selection
Validation is performed in count space
The model still runs and trains in transform space, but validation inverses its predictions before computing the new metrics. This makes comparisons meaningful across different transforms and expresses errors in sensor counts.
Per-example metrics include:
mip_max_error)Metrics are averaged over examples, while compression ratio is summarized by the median because its per-patch distribution is heavy-tailed.
Checkpoint selection changes from lowest global validation L1 to a lower-is-better joint score:
Offline evaluators were repaired and share transform/offset handling
evaluate.pywas updated to the current inference API and recognizes both current dictionary checkpoints and the legacy bare-state-dict format. For this module, the caller still has to construct the correct model; it does not recreatemodel_config. Bare loading is therefore only a format fallback and is not semantically faithful formaincheckpoints trained with BatchNorm, direct output, and percentile normalization.SupervisedEvaluatorapplies an estimated offset per standalone raw block and reports compression ratio and 3D SSIM against the raw/noisy block. Despite the historical class name, that SSIM is not against clean ground truth.UnsupervisedEvaluatorapplies the training offset once per brain rather than estimating it per patch, then reports raw/BM4D/model compression, SSIM against raw and BM4D, MAE, and maximum absolute error.raw_inputswitch for data that has already been background subtracted.These evaluator results are not numerically interchangeable with training validation:
evaluate.pyuses Blosc/Zstd compression level 6 rather than 5, and its unsupervised BM4D reference uses sigma 10 rather than the current cache sigma 24.The custom 3D SSIM implementation now converts uint16 inputs to floating point before powers and products. Previously, integer overflow could corrupt local variance and covariance. MAE and maximum-error helpers likewise perform safe floating-point subtraction.
A standalone crop evaluation script was added
scripts/evaluate_bm4dnet.pyprovides an end-to-end crop evaluation path:(x, y, z)coordinates to source-level(z, y, x)voxels and validate crop bounds;Performance and scalability
CPU and I/O work is moved out of the GPU loop
Cloud reads and per-patch BM4D were the dominant training cost on
mainand left the GPU idle. Precomputing both train and validation sets pays that cost once. Cache-backed training then performs memory-mapped reads, the inexpensive transform/target assembly, and GPU work. The cache entrypoint usesnum_workers=0intentionally because process overhead is not worthwhile for this cheap path.The general custom loader was also rewritten for live/expensive datasets. A bounded background thread prefetches batches; optional worker processes are created once per iterator/epoch rather than once per batch, and the dataset is installed once in each worker instead of being pickled with every task.
TensorStore caching is disabled for random patch access
TensorStore handles previously received separate 1 GB local and remote cache pools per worker and per brain, allowing aggregate memory to grow far beyond host RAM. Both limits are now zero. Random 64-cubed sampling has low cache reuse, so bounded memory was judged more valuable than those caches.
Full-volume inference uses lower-precision, in-place accumulation
Overlap-add accumulators are now
float32rather than NumPy's defaultfloat64, and averaging happens in place after the transformed input is freed. This roughly halves peak host memory and removes extra full-volume temporaries, addressing out-of-memory failures for 1024-cubed volumes on a 30 GB host.Image I/O, dependencies, and developer tooling
Zarr 3 migration
The supported environment moves to Python 3.11+,
zarr>=3.0.8, andome-zarr>=0.12.0. Image utilities now use Zarr 3 path-based local/cloud stores andBloscCodec:zarr.open;write_zarrwrites a single 5D local or cloud array;write_ome_zarrsupports explicit scale, translation, and spatial unit;The N5 migration is structurally covered but is explicitly noted in code as not exercised against current live N5 data.
.gitignorealso excludes CodeOcean/workspace artifacts, local data and scratch directories, generated PNG previews, and related environment metadata.Added operational scripts
estimate_background_offsets.pyprecompute.pytrain_bm4dnet.pyevaluate_bm4dnet.pyvisualize_patches.pyvisualize_swc_masks.pyThe visualization tools are important QA for a design in which the mask is baked into caches: they make it possible to inspect whether annotations cover real neurites without annexing noise before spending a training run.
visualize_patches.pyalways renders the raw-on-foreground composite in its "target" panel. With the currentpreserve_foreground=Falseexperiment, the actual training target is the separate BM4D teacher panel.Reproducibility and test coverage
Cache generation seeds every task from a
SeedSequencekeyed by base seed, split-specific stream, and task index. Train and validation have different streams, so a fixed software/data environment is designed to reproduce sample choices and ordering independently of worker scheduling/count. Bitwise BM4D identity across platforms or library versions is not established. The intended cache-only loader path (num_workers=0) also has reproducible epoch ordering. This does not make the model run bit-reproducible: model initialization and PyTorch/CUDA kernels are not seeded or forced into deterministic modes. Full precompute configuration, including the count dtype, is written for provenance.What remains conceptually unchanged
The main architectural shift is therefore not a replacement of BM4D or the U-Net. It is the introduction of a reproducible cached-data, count-aware contract around them: curated cached examples, one stable intensity coordinate system, annotation-aware validation, and checkpoint selection that acknowledges the fidelity/compression tradeoff.