Skip to content

[RFC] kwyk pretrained TF->PyTorch weight converter with numerical parity - #385

Draft
dhritimandas wants to merge 2 commits into
neuronets:alphafrom
dhritimandas:feat/kwyk-parity-check
Draft

[RFC] kwyk pretrained TF->PyTorch weight converter with numerical parity#385
dhritimandas wants to merge 2 commits into
neuronets:alphafrom
dhritimandas:feat/kwyk-parity-check

Conversation

@dhritimandas

Copy link
Copy Markdown
Contributor

Summary

What: This adds a converter. It imports the published kwyk TensorFlow
weights into the existing PyTorch kwyk_meshnet architecture. It adds a
harness. The harness checks the converted model against the original
neuronets/kwyk container, end to end.

Why: The kwyk_meshnet architecture (FFG/VWN convolutions, matching
McClure et al. 2019) was already ported to PyTorch in an earlier change.
No PyTorch weights existed for it. This PR fills that one gap only: it
imports the three published TensorFlow checkpoints (MAP, BD, SSD) as
PyTorch state dicts, and validates that the import is numerically correct
against a live copy of the original container -- not just that the
weight-mapping code runs, but that the converted model reproduces the
original's actual output on real data.

This is opened as a draft RFC, not a mergeable-as-is change: two design
questions below need maintainer input before this is final (see "Open
questions").

The converter

nobrainer/datasets/convert_kwyk.py:

  • Reads TF variables from a pre-extracted .npz (preferred, no
    TensorFlow needed) or directly from a checkpoint (--tf-path, requires
    tensorflow).
  • Maps TF's weight-normalization parameterization (v, g) and axis
    order ([k,k,k,in,out] -> [out,in,k,k,k]) onto FFGConv3d's
    parameters, auto-detecting the TF layer index base (0 vs 1) rather than
    assuming it.
  • offline_parity_check independently recomputes each layer's kernel
    from the raw TF v/g and confirms the loaded model reproduces it --
    this gates the weight mapping, before any inference is run.
  • Ships as python -m nobrainer.datasets.convert_kwyk --npz ... --n-classes 50 --filters 96 --receptive-field 37 --out kwyk_map.pth, writing a
    .provenance.json sidecar (source checkpoint path + SHA256,
    architecture args) alongside the .pth, since the MAP and BD checkpoints
    are structurally indistinguishable (identical key sets and shapes) and
    the source path is the only durable record of which model a .pth came
    from.

Numerical parity (the part offline_parity_check cannot verify)

offline_parity_check gates the weight mapping only -- it says nothing
about whether the converted model reproduces the original through the
full pipeline (conform -> normalize -> block -> infer -> reassemble) on
real data. scripts/kwyk_reproduction/07_parity_kwyk.py runs both the
converted PyTorch model and a live neuronets/kwyk container on identical
preprocessed input and compares them directly to each other (not each to
ground truth independently, which is a different, weaker check).

Stage A (MAP model, all_50_wn -- the only one of the three
SavedModels that is run-to-run deterministic) compares raw logits on a
real T1 volume (sub-01, via nobrainer.utils.get_data()):

metric threshold observed
max|logit diff| ≤ 1e-3 6.87e-05
mean Dice (PyTorch labels vs TF labels) ≥ 0.98 1.0000 (all 49 classes)
voxel agreement ≥ 0.995 1.0000

Ran on a 64^3 crop (8 of 512 blocks), not the full 256^3 volume: the
container is linux/amd64-only and runs under QEMU emulation on the arm64
dev machine this was built on, making a full 512-block run impractically
slow for one sitting. The harness supports full-volume runs unmodified.

Stage B (SSD model, all_50_bvwn_multi_prior) compares MC-aggregate
uncertainty statistics on a 128^3 crop, N=20 samples (full designed scope,
~83 min wall clock under emulation) -- never per-sample, since the two
frameworks' RNGs are unrelated:

metric threshold observed
mean-label Dice ≥ 0.95 0.9804
entropy Pearson r ≥ 0.90 0.9963
variance Pearson r ≥ 0.90 0.8740 (misses the gate)

variance_scale_ratio = 0.816: PyTorch's reconciled variance runs ~18%
below TF's in this crop. Plausible, unconfirmed cause: MC sample variance
is a much noisier statistic than the mean at N=20, and PyTorch's
ConcreteDropout3d draws one dropout mask shared across the whole batch
rather than independent per-sample masks -- a real implementation
difference that could suppress inter-sample variance relative to TF's
per-sample sampling. Not silently loosened to force a pass; see the second
open question below.

Open questions for maintainers

  1. Where should the converter live? It currently sits in
    nobrainer/datasets/convert_kwyk.py, alongside openneuro.py and
    zarr_store.py -- installed package code, importable as a library. The
    parity harness that validates it lives in scripts/kwyk_reproduction/
    instead -- a standalone, non-packaged reproduction-pipeline directory.
    Should the converter move to sit next to the harness it's validated
    against, or does it belong with the other dataset/conversion utilities
    regardless of where its own validation tooling lives?

  2. What parity tolerance is actually acceptable? The thresholds above
    were proposed as initial gates, explicitly meant to be pinned from a
    first real observed run rather than picked in the abstract -- this PR
    is that first run. Stage A's gates all clear with wide margin. Stage
    B's variance gate does not (0.874 vs 0.90). Is 0.90 the right bar given
    N=20 sampling noise on a second-moment statistic, should it be loosened
    with N recorded as a caveat, or should the fix be a larger N (which
    costs real wall-clock time under emulation) before this is called
    validated?

Test plan

  • uv run pytest nobrainer/tests/unit/ -m "not gpu" -q -- 427 passed;
    one pre-existing unrelated failure
    (test_croissant.py::test_returns_true_on_valid).
  • offline_parity_check passes for all 8 conv layers (incl.
    classifier) on both the MAP and SSD converted checkpoints.
  • Real container run: preprocessing gate, Stage A (64^3 crop), Stage B
    (full 128^3/N=20 scope) -- see docs/kwyk_parity_report.md.
  • Full 256^3 Stage A run on faster/native (non-emulated) hardware.
  • Resolve the two open questions above before taking this out of draft.
  • CI

The kwyk architecture (McClure et al. 2019) was reimplemented in PyTorch,
but users could not obtain the trusted published weights -- the three
SavedModels ship only inside the neuronets/kwyk Docker container, and no
conversion path existed.

- nobrainer/datasets/convert_kwyk.py: TF SavedModel/.npz -> KWYKMeshNet
  state_dict converter. Strict load, offline parity check over all 8 conv
  layers (including the classifier), and a .provenance.json sidecar
  recording the source path + SHA-256 -- the MAP and BD checkpoints are
  structurally indistinguishable, so the content hash is the only durable
  record of which model a .pth came from.
- KWYKMeshNet: the output classifier is now an FFGConv3d (was plain
  nn.Conv3d), matching the TF logits/ layer, which is itself a full VWN
  conv. All five logits variables map 1:1 with no information loss; the
  previous mean-path collapse made MC uncertainty under-dispersed at the
  output (TF contributes ~11.5 of run-to-run logit variance there; the
  plain conv contributed 0). Also adds a bias flag threaded through the
  hidden layers (default False, unchanged).
- ConcreteDropout3d clamp widened [0.05, 0.95] -> [0.01, 0.99] in lockstep
  with the converter: the published SSD checkpoint stores p up to
  0.954055, so the old ceiling silently clipped 89% of trained values
  (598/672). A unit test pins the two clamp ranges together.
- Biases are imported by default (--drop-bias is an explicit, logged
  opt-out): every published checkpoint carries bias_m/bias_a for every
  conv layer, and dropping them shifts output logits by max ~20.

Every mapping claim is verified against the real container and validated
numerically against the live TF graph: the converted MAP model reproduces
the original's logits to max|diff| = 9.5e-05 (relative error ~4e-6) on
identical input; converted SSD preserves all 672 dropout probabilities to
6e-08 and reproduces output-layer MC variance. Full verification report,
including the six discrepancies found and fixed and a second independent
re-verification: docs/kwyk_mapping_verification.md.
…container

Neither offline_parity_check (weight mapping only) nor 05_compare_kwyk.py
(GT-vs-GT, no model-to-model comparison, cannot currently load its model)
verify that the converted PyTorch checkpoint reproduces the original
kwyk container end to end. This adds that check: conform inside the
container, z-score once on the host, feed identical input to both sides,
compare raw logits (Stage A, MAP model) and MC-aggregate uncertainty
(Stage B, SSD model) against a live neuronets/kwyk container.
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.

1 participant