Skip to content

Feat: training experiments#29

Merged
anna-grim merged 56 commits into
AllenNeuralDynamics:mainfrom
carshadi:feat-training-experiments
Jul 16, 2026
Merged

Feat: training experiments#29
anna-grim merged 56 commits into
AllenNeuralDynamics:mainfrom
carshadi:feat-training-experiments

Conversation

@carshadi

Copy link
Copy Markdown
Member

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:

  1. An offline precompute stage reads cloud data, samples patches, runs BM4D, builds annotation-derived foreground masks, and writes count-space patch caches.
  2. Training consumes every cached patch once per epoch, applies one frozen intensity mapping, and selects checkpoints using both signal fidelity and compressibility.

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.

Area main feat-training-experiments
Dataset generation Cloud reads and BM4D inside Dataset.__getitem__ Required offline train/validation caches containing raw counts, BM4D teacher, and foreground mask
Foreground definition Used mainly to bias patch sampling Segmentation plus traced skeleton defines both the optional preserved target and count-space validation regions
Normalization Per-patch percentiles and a hard upper clip; separate inference behavior One frozen, serialized transform used across train/validation/inference, with invertible HDR options
Background pedestal No per-acquisition alignment Per-brain offsets are subtracted before caching; inference composes the runtime offset around the frozen transform
Training target/loss BM4D everywhere with uniform L1 Optional raw-on-foreground target plus foreground-weighted Charbonnier loss; both are disabled in the current experiment configuration
Model Direct-output U-Net with BatchNorm Residual-output U-Net with GroupNorm and serialized constructor configuration
Epoch A configured number of newly sampled examples A deterministic shuffled pass over the complete training cache, including the final partial batch
Validation Global L1 and median compression ratio; best L1 checkpoint only Count-space signal/background/tail metrics plus compression-aware scoring; evaluated checkpoints after the first epoch are retained
Inference/output Independent percentile normalization, hard clipping, high overlap-add memory use Checkpoint-supplied transform, production offset support, lower-memory overlap-add, and metadata-preserving OME-Zarr output

End-to-end data flow

PRECOMPUTE
raw OME-Zarr + FFN segmentation + SWC traces + per-brain offsets
    -> foreground-biased candidate sampling
    -> reject spatially incoherent labeled artifacts
    -> subtract the brain pedestal
    -> run BM4D and build the annotation mask
    -> raw.npy + teacher.npy + fg.npy + transform.json + config.json

TRAIN / VALIDATE
memory-mapped caches
    -> shared frozen intensity transform
    -> target = BM4D, or raw-on-foreground/BM4D-on-background ablation
    -> residual 3D U-Net + Charbonnier objective
    -> invert predictions to count space
    -> fidelity metrics + compression-aware checkpoint score

INFERENCE
raw volume + precomputed full-tile pedestal
    -> subtract pedestal around the checkpoint's frozen transform
    -> tiled U-Net prediction and overlap-add
    -> inverse transform and restore pedestal
    -> uint16 volume, MIPs, compression results, and optional OME-Zarr

Dataset preparation and target construction

Patch generation is now an explicit offline stage

scripts/precompute.py replaces 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:

File Type Meaning
raw.npy float32, (N, D, H, W) Raw counts after subtracting the per-brain background pedestal
teacher.npy float32, (N, D, H, W) BM4D output clipped to the physical count range
fg.npy uint8, (N, D, H, W) Foreground mask
transform.json JSON Exact frozen transform to use later
config.json JSON Generation configuration: inputs, sampling thresholds, mask settings, offsets, BM4D sigma, seed, worker count, and cache dtype

The count arrays were deliberately changed from float16 to float32. float16 increasingly 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. float32 exactly 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:

  • all positive FFN segmentation labels, with label dilation opt-in rather than automatic; and
  • rasterized SWC nodes, dilated by a configurable neurite radius (currently two voxels).

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:

target = np.where(fg_mask, raw, bm4d_teacher)

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:

preserve_foreground = False
fg_weight = 0

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:

  • Skeleton jitter is now symmetric around the traced node instead of shifting only in the positive octant.
  • The segmentation acceptance threshold is configurable.
  • The image fallback uses a per-patch robust median/MAD mask rather than a fixed >100 count 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:

  • low lag-2 spatial autocorrelation; and
  • high high-frequency energy relative to a Gaussian-smoothed patch.

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:

  1. The same physical count could map to a different network value depending on patch contents.
  2. Bright neurites above the clip collapsed onto a non-invertible plateau.

machine_learning/transforms.py introduces fixed, stateless, configuration-driven transforms that are shared unchanged by cache datasets, validation, checkpoint metrics, and inference.

Transform Role Bright-tail behavior
AsinhTransform Current configured transform; approximately linear near the noise floor and logarithmic in the tail Monotonic and invertible up to the physical sensor clamp; no brightness plateau
AnscombeTransform Generalized variance-stabilizing alternative for Poisson-Gaussian noise Square-root-like compression; exact algebraic or asymptotically unbiased inverse
LinearClipTransform Frozen baseline for A/B comparisons with the old approach Retains an explicit hard clip and is intentionally not the recommended HDR mapping

The 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.py to 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, OffsetTransform composes the runtime pedestal around the frozen base transform:

forward(raw) = trained_transform.forward(raw - runtime_offset)
inverse(y)   = trained_transform.inverse_float(y) + runtime_offset

Production inference should supply an offset precomputed from the full image tile at a lower-resolution level.

Transform identity is persisted and enforced

  • Both caches stamp the resolved transform.json.
  • Training refuses train/validation caches with different transforms.
  • Checkpoints store the transform configuration next to model weights.
  • Inference reconstructs the transform from the checkpoint.
  • Loading pretrained weights rejects a checkpoint transform that does not match either attached dataset.

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.L1Loss is replaced by SignalPreservingLoss, a smooth Charbonnier penalty with voxel weight 1 + 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:

  • The default output is residual: prediction = input + learned_correction. Denoising is naturally a correction problem, and the desired correction is near zero where signal should be preserved.
  • BatchNorm3d is replaced by GroupNorm. GroupNorm is independent of batch statistics and is less sensitive to small batches and rare bright patches. The group count uses gcd(8, channels) so custom integer width multipliers always divide the channel count.

width_multiplier is 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

CachedPatchDataset is 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:

  • rerunning with the same cache and seed gives the same order;
  • each epoch gets a different order;
  • validation stays ordered; and
  • the last partial batch is retained.

This replaces the former n_examples_per_epoch sequence of fresh random draws. Epoch counts and learning-rate schedules are therefore not directly comparable between the branches.

Optimization and numerical stability

  • AdamW remains the optimizer.
  • CosineAnnealingLR.T_max now equals max_epochs, producing one decay across the run. The former fixed T_max=25 returned the learning rate to its peak every 50 epochs and correlated with repeated, growing loss spikes.
  • Float16 autocast now has a GradScaler, preventing small gradients from underflowing and adapting to overflow.
  • Tensor movement respects Trainer.device instead of forcing CUDA in helper paths. AMP autocast and its scaler remain CUDA-specific, so a non-CUDA run must set use_amp=False.
  • val_every decouples 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.json with 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:

model          model state_dict
model_config   width/trilinear/residual constructor arguments
transform      frozen intensity-transform configuration

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_best flag is now informational rather than deciding whether a checkpoint exists.

resume_path restores 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:

Metric Reference and intent
Foreground MAE Prediction versus raw counts inside the annotation mask; measures signal preservation
Background MAE Prediction versus the training target outside the mask; measures teacher-like cleanup
Top-0.1% error/preservation Compares the bright tail to raw input; catches systematic neurite attenuation
Global-maximum error (mip_max_error) Absolute difference between the volumes' scalar maxima; does not compare MIPs spatially and can miss a lost structure when another peak remains
False-bright rate Fraction of background predicted above a brightness threshold
Compression ratio Zstd/Blosc compressibility of the inverse-transformed uint16 prediction

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:

score = w_fg  * foreground_mae
      + w_bg  * background_mae
      + w_top * top_0.1_percent_error
      - w_cr  * compression_ratio

Offline evaluators were repaired and share transform/offset handling

evaluate.py was 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 recreate model_config. Bare loading is therefore only a format fallback and is not semantically faithful for main checkpoints trained with BatchNorm, direct output, and percentile normalization.

  • SupervisedEvaluator applies 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.
  • UnsupervisedEvaluator applies 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.
  • Both have a raw_input switch for data that has already been background subtracted.

These evaluator results are not numerically interchangeable with training validation: evaluate.py uses 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.py provides an end-to-end crop evaluation path:

  • choose an explicit checkpoint or parse the lowest score from checkpoint filenames, including negative scores;
  • load both model architecture and transform from the checkpoint;
  • read a lazy local/S3/GCS Zarr array and crop without loading the whole tile;
  • convert Neuroglancer (x, y, z) coordinates to source-level (z, y, x) voxels and validate crop bounds;
  • apply a precomputed full-tile background offset, with crop-local estimation only as a fallback;
  • report raw and denoised compression ratios and their gain;
  • save orthogonal raw/denoised MIPs; and
  • optionally write the denoised crop as OME-Zarr while preserving the source scale, units, and crop-adjusted translation.

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 main and 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 uses num_workers=0 intentionally 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 float32 rather than NumPy's default float64, 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, and ome-zarr>=0.12.0. Image utilities now use Zarr 3 path-based local/cloud stores and BloscCodec:

  • local, S3, and GCS Zarr reads use zarr.open;
  • OME-Zarr writing uses Zarr 3 codec/chunk options;
  • write_zarr writes a single 5D local or cloud array;
  • write_ome_zarr supports explicit scale, translation, and spatial unit;
  • N5 uses TensorStore because Zarr 3 removed its built-in N5 store.

The N5 migration is structurally covered but is explicitly noted in code as not exercised against current live N5 data.

.gitignore also excludes CodeOcean/workspace artifacts, local data and scratch directories, generated PNG previews, and related environment metadata.

Added operational scripts

Script Purpose
estimate_background_offsets.py Build and inspect per-brain pedestal JSON from coarse, nonzero data
precompute.py Reproducibly build either train or validation cache from one shared configuration
train_bm4dnet.py Validate the cache contract, persist the run config, and train fully offline
evaluate_bm4dnet.py Evaluate a checkpoint on a real crop, measure compression, and optionally write metadata-correct OME-Zarr
visualize_patches.py Compare cached raw, BM4D teacher, optional preserved target, mask, and overlay
visualize_swc_masks.py Rebuild and inspect the SWC-only contribution to foreground masks

The 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.py always renders the raw-on-foreground composite in its "target" panel. With the current preserve_foreground=False experiment, the actual training target is the separate BM4D teacher panel.

Reproducibility and test coverage

Cache generation seeds every task from a SeedSequence keyed 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

  • BM4D is still the teacher/reference denoiser; it is now moved offline and can be selectively overridden on annotated foreground.
  • The core model remains a patch-based 3D U-Net operating on 64-cubed patches.
  • Compression is still measured with shuffled Zstd/Blosc and is a first-class project outcome.
  • Final predictions are returned in physical uint16 count space.

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.

carshadi and others added 30 commits July 8, 2026 18:35
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>
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>
carshadi and others added 26 commits July 9, 2026 17:41
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.
@carshadi

Copy link
Copy Markdown
Member Author

Py310 tests fail due to the new >=3.11 requirement for zarr>=3. The actions could be fixed in a separate PR.

@carshadi
carshadi requested a review from anna-grim July 15, 2026 21:12

@anna-grim anna-grim left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@anna-grim
anna-grim merged commit f352c0c into AllenNeuralDynamics:main Jul 16, 2026
0 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants