Skip to content

feat: GPU device seam + synthetic benchmark harness - #45

Merged
nceglia merged 11 commits into
mainfrom
feat/gpu-device-seam
Aug 5, 2026
Merged

feat: GPU device seam + synthetic benchmark harness#45
nceglia merged 11 commits into
mainfrom
feat/gpu-device-seam

Conversation

@nceglia

@nceglia nceglia commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Threads device= through to the metrics engine (CU-01) and adds the benchmark
harness that has been used to calibrate the NMI estimator against known truth.

Contents

GPU seam. device= was in the frozen _contract.pyi but joint_draws
hard-coded device=None, so no public metric could reach it. Now wired through.
Measured: metrics ~6× faster on an A100.

benchmarks/run_grid.py. Sweeps coupling strength, sample size, K and seed
against a known population truth. Each cell carries three reference points —
population truth, a label oracle (plug-in NMI over the realized true labels), and
a clustering baseline. Presets: smoke, reduced, full, published,
published_quick. Overrides for --k-infer, --temperature, --n-cells so a
single cell can be driven to convergence without paying for the whole sweep.

simulate_from_fit_params() in tcri/datasets/_simulate.py. Generates from a
fitted parameter set rather than a synthetic Dirichlet ω, so benchmark numbers are
comparable to published ones instead of to a toy problem. Reproduces the published
ground-truth anchors exactly (0.520 / 0.316 / 0.182).

Both posterior summaries. Every row now carries E[NMI(J)] (what the package
reports) alongside NMI(E[J]) computed from the same draws, plus their difference.
The gap needs no ground truth, so the same diagnostic ports to real data.

Why the harness matters

It found a coupling-dependent upward bias in the NMI estimate that survives to
convergence and reproduces on three independently fitted parameter sets. Written up
in docs/nmi_temperature_bias.md. The finding is synthetic-only and the fix is
undecided — nothing in this PR changes estimator behavior.

Tests

166 passed, 3 skipped on the pinned venv.

🤖 Generated with Claude Code

nceglia and others added 9 commits August 3, 2026 16:16
The `_compute/_xp` device seam has existed since PR5 (resolve_device /
torch_device, CPU + torch-CUDA), and `joint_distribution` accepted `device=` --
but NO metric exposed it, and `joint_draws` hard-coded `device=None` when calling
the engine. So the GPU path was documented and completely unreachable: every
metric ran on CPU no matter what the caller asked for.

Threaded `device=` from the four public metrics down to `_joint_draws`:
  tl.mutual_information / clonotypic_entropy / phenotypic_entropy / phenotypic_flux
    -> _entropy_metric / _flux_once
      -> joint_draws
        -> _engine_blocks -> _joint_draws

Verified by spying on `_joint_draws`: all four now deliver the value (flux twice,
once per covariate). Results are unchanged -- device is a placement detail, not a
numerical one -- pinned by test_device_does_not_change_results.

The API contract caught this correctly: adding a public parameter is frozen
interface drift, and test_contract_conformance failed on all four signatures until
`_contract.pyi` was updated deliberately. It also caught a parameter-ORDER
inconsistency -- `device` had landed before `random_state` in flux but after it in
the other three -- so the ordering is now uniform (`device` last everywhere).

This unblocks GPU work: without it there was no code path to profile.

Full suite 166 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grid runner reproducing Supplementary Note 1's benchmark design: sweep fuzziness
x N x K-at-inference x seeds, compare TCRi's normalized MI against the closed-form
truth from tcri.datasets.simulate_tcri, plus a KMeans/GMM baseline.

Two traps the harness handles explicitly:
  - NORMALIZATION. eq 6 uses the MEAN denominator; tcri defaults to 'min'.
    Comparing a min-normalized estimate to a mean-normalized truth silently
    inflates it, so --normalize-mode is explicit and defaults to 'average' (eq 6).
  - WHICH ORACLE. true_* is the population value, empirical_* is what a perfect
    estimator returns on the realized sample; they differ by the plug-in bias at
    small N. MAE is reported against both.

Presets: smoke (2 cells) / reduced (24) / full (1500, the note's grid).
--profile attaches the torch profiler to one training run.

Local smoke: TCRi AE 0.034-0.037 vs KMeans 0.116-0.189 -- the baseline-dominance
claim reproduces. HDI covered the realized value 2/2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/contract/ss-small-team-handbook.md (1998 lines) was an untracked local file
that my 'git add -A' picked up into the metrics-contract commit. It is unrelated
to the package and was never meant to be committed. Removed from the tree and
added to .gitignore so it cannot be swept up again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
simulate_from_fit_params() reproduces sc_simulator.simulate_dataset from a fitted
(pi, omega, gamma_params, V), plus temperature_scale() for the omega^(1/T) axis the
published benchmark sweeps.

Needed because the synthetic generator CANNOT stand in for the empirical one. The
benchmark's true-NMI anchors are 0.520/0.316/0.182 at T=0.1/0.5/1.0; the best
symmetric-Dirichlet fit (omega_concentration=0.763) gives 0.633/0.320/0.150, and its
sharpening ratio T=0.1->T=1.0 is 4.22x versus the true 2.86x. The mismatch is in the
SHAPE of omega's response to temperature, so no reparameterization fixes it. The
empirical fit matches all three exactly.

Because gamma_params/V are in the pickle, the expression generator is reproducible
too, so estimator difficulty matches -- not only the ground truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reference sampler does np.dot(U_i, params['V'].T), i.e. V is (n_genes,
n_factors). I had assumed (L, D), which raised a matmul dimension error on the real
pickle (984 vs 5). Detect the orientation from L rather than assuming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds --fit-params plus a temperature axis, and the 'published'/'published_quick'
presets matching the figures' axes (K in {8,10,12} at inference, T in {0.1,0.5,1.0},
N in {250..5000}, fuzziness 0.0-0.9, 10 seeds).

Guards the trap: a preset that sweeps temperature REQUIRES --fit-params, because the
synthetic Dirichlet omega cannot reproduce the published true-NMI anchors (its
sharpening response is 4.22x vs the true 2.86x). Verified end to end -- the generator
reproduces 0.520/0.316/0.182 at T=0.1/0.5/1.0 exactly, with 47 clones/984 genes/K=10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ded by K

The presets pin k_infer=10. Sweeping different fitted omegas (K=8/10/12) without
this would conflate 'different fixture' with 'wrong K at inference'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll overrides

The metric reports E_s[NMI(J_s)]. NMI is nonlinear in the joint, so that is not the
NMI of the posterior. Read the same draws both ways and record the gap; it needs no
ground truth, so it also works on real data.

--temperature/--n-cells let a single grid cell be driven to convergence without
paying for the whole sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Synthetic-only finding, mechanism identified and magnitude bounded, fix undecided.
Written up so the open questions (posterior summary convention, eq-2 concentration)
are on record before anyone acts on the benchmark numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

nceglia and others added 2 commits August 4, 2026 16:16
Per-entry Dirichlet concentration is local_scale/P. Below 1 the posterior draws are
corner-seeking, which is the proposed source of the upward NMI bias. This exposes the
knob so the prediction can be tested: raising it should collapse the Jensen gap and
the error floor together.

Moves both the guide's posterior and (via uns) the metric's draw, so a result here is
not attributable to one side without a follow-up that pins the metric separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nceglia
nceglia merged commit 4f61a7e into main Aug 5, 2026
2 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.

1 participant