diff --git a/.gitignore b/.gitignore index ccdf6e3..d6c0452 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ example/*.pdf .DS_Store + +# local-only dev harnesses (real-data tests; NOT CI) +dev/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..911bcc1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# tcri — contributor rules + +Single-cell TCR+RNA information-theory metrics on scvi-tools / pyro / scanpy. + +## The three contracts + +This repo is governed by three frozen contracts. All are machine-checked; a failing +conformance test means **stop and decide**, not "adjust the contract until it passes." + +| | freezes | manifest | prose | test | +|---|---|---|---|---| +| **API contract** | the public *interface* | `tcri/_contract.pyi` | `docs/contract/tcri_api_and_responsibilities.md` | `tests/test_contract_conformance.py` | +| **Model contract** | the generative *mathematics* | `tcri/model/_model_contract.py` | `docs/contract/MODEL_CONTRACT.md` | `tests/test_model_contract_conformance.py` | +| **Metrics contract** | what the *metrics compute* | `tcri/tools/_metrics_contract.py` | `docs/contract/METRICS_CONTRACT.md` | `tests/test_metrics_contract_conformance.py` | + +### Model integrity (read before touching `tcri/model/`) + +The model implements **Supplementary Note 1** (`tcri_supplementary_methods_04_30_26.pdf`) +— the source of truth. Changing its mathematics means: adding/removing a stochastic +site, changing a distribution family or plate, altering the ELBO or the phenotype +surrogate, or changing what a prior is scaled by (α on eq 1, β on eq 2). + +**Update the model contract FIRST, then the code.** Cite the note equation and state +what changes in the joint distribution. Then make the code agree. + +**Never loosen the manifest to make a conformance failure go away.** That silently +rewrites the model the package claims to implement. A failure is either an intended +model change (update the contract deliberately, as a reviewed model change) or a +regression (fix the code). + +If you are an AI agent and a model change appears necessary, **surface the contract +implication to the user** rather than editing the manifest to fit your change. + +Accepted departures from the note live in `SANCTIONED_DEVIATIONS` +(`_model_contract.py`) with a rationale, mirrored in `MODEL_CONTRACT.md`. Anything +not listed there that departs from the note is a defect. + +`docs/contract/METHODS_CONFORMANCE.md` is the eq-by-eq code map + deviation history. + +### Metric integrity (read before touching `tcri/tools/`) + +The entropies and mutual information are frozen by the **metrics contract**. Changing +a definition means changing what every published number means. Same rule: update +`_metrics_contract.py` + `METRICS_CONTRACT.md` first, then the code. + +The conformance test pins numeric identities, the keystone being +`I(c;φ) = H(c) − Σ_φ P(φ)·H[P(c|φ)]` — it ties the entropy and MI families together so +neither can be redefined alone. + +**Note 1's eqs 3–4 are mistranscribed** (they weight by the marginal, making them +cross-entropies, and eq 4's label is wrong). The code is correct — proven by the +decomposition above, which the literal equations violate by producing a *negative* +mutual information. These are recorded in `SOURCE_ERRATA`; do **not** "fix" the code to +match them. + +## Working agreement + +- **`docs/contract/REFACTOR_AGENDA.md` is the living tracker** — read it before + starting, write a diary entry after each PR, and run the Standing Audit in it. +- **Removal is a hard bar.** Delete dead code rather than keeping it "just in case"; + git has it. Tick the Removal Ledger. +- **Never read the `example/` notebooks.** They are disposable *outputs* of the + refactor, never an input — no caller census, no "is-it-used" checks. +- Run tests with the pinned venv: `MPLBACKEND=Agg .venv/bin/python -m pytest tests/ -q`. diff --git a/docs/contract/METHODS_CONFORMANCE.md b/docs/contract/METHODS_CONFORMANCE.md new file mode 100644 index 0000000..5f76437 --- /dev/null +++ b/docs/contract/METHODS_CONFORMANCE.md @@ -0,0 +1,116 @@ +# Methods Conformance — code ↔ Supplementary Note 1 + +> **This is the eq-by-eq map + deviation history.** The *enforced* contract is +> `docs/contract/MODEL_CONTRACT.md` (prose) + `tcri/model/_model_contract.py` +> (manifest), checked by `tests/test_model_contract_conformance.py`. Model math +> changes require updating that contract **first**. + +Maps the TCRi generative model in **Supplementary Note 1: Methods for Information +theoretic metrics for single cell RNA and T-cell receptor sequencing** +(`tcri_supplementary_methods_04_30_26.pdf`) to the implementation, and records +every known deviation. The PDF is the source of truth; this file is the living +conformance record (update it whenever the model changes). + +Code: `tcri/model/_module.py` (`TCRIModule.model`/`.guide`), `tcri/model/_priors.py` +(`VampPrior`, `MixtureDirichlet`), `tcri/model/_classifier.py` +(`PhenotypeClassifier`), `tcri/model/_model.py` (`TCRIModel`, `.predict`). + +## Symbols + +| Note | Meaning | Code | +|---|---|---| +| `ω_c` | clonotype-level phenotype dist | `p_c` (sample site `"p_c"`) | +| `ϕ_m` | covariate-level phenotype dist | `p_ct` (sample site `"p_ct"`); `get_p_ct()` | +| `z_i` | continuous latent embedding | `z` (sample site `"latent"`) | +| `z^ϕ_i` | **discrete phenotype latent** | not sampled — replaced by the surrogate (below) | +| `x_i` | gene expression | `x` (sample site `"obs"`) | +| `f_cls` | classifier `R^L → R^P` (η_cls) | `self.classifier` | +| `π` | gating weight | `gate_prob` (default **0.5**) | +| `α` | global Dirichlet scale | `global_scale` | +| `β` | local Dirichlet scale | `local_scale` | +| `γ` | surrogate KL weight | `phenotype_kl_weight` (default 1.0) | +| `g(i)` | covariate-group of cell `i` | `ct_array[i]` (clone×covariate index) | +| `h(m)` | clonotype of group `m` | `ct_to_c[m]` | + +## Generative model + +| Eq | Note | Code (`_module.py::model`) | Status | +|---|---|---|---| +| 1 | `ω_c ~ (1/B_c) Σ_b Dir(α ψ_b)` | plate `"clonotypes"` → `MixtureDirichlet(weights, global_scale * mixture_concentration)`, sampled `"p_c"`. `ψ_b` = archetype centroids (`build_archetypes`). | ✅ α = `global_scale` (**[G]** fixed) | +| 2 | `ϕ_m \| ω_h(m) ~ Dir(β ω_h(m))` | plate `"ct_plate"` → `conc_ct = clamp(local_scale * p_c[ct_to_c])`, sampled `"p_ct"` | ✅ β = `local_scale` | +| 3 | `z_i ~ (1/B_z) Σ_k q(z\|u_k)` | `VampPrior.get_mixture()` (mixture of encoder-posteriors at learnable pseudo-inputs), sampled `"latent"` | ✅ | +| 4 | `l_i = f_cls(z_i)`, `ℓ_i = π l_i + (1-π) log ϕ_g(i)`; `z^ϕ_i ~ Cat(softmax(ℓ_i))` | `cls_logits = classifier(z)`; `ell = gate_prob*cls_logits + (1-gate_prob)*log_phi`; discrete `z^ϕ` **not** sampled — see surrogate | ◐ via surrogate (below) | +| 5 | `x_i ~ ZINB(g'_i, r_i, μ_i)` | `DecoderSCVI` → `ZeroInflatedNegativeBinomial(gate, total_count, logits)`, sampled `"obs"` | ✅ (scaled — **[E]**) | + +## Variational family (eq 6) — `_module.py::guide` + +- `q(ω_c) = Dir(λ_c)` — `q_p_c_raw` param → `conc_c_guide = clamp(global_scale * q_p_c_sharp)`. α = `global_scale`. +- `q(ϕ_m) = Dir(λ'_m)` — `q_p_ct_raw` param → `conc_ct_guide = clamp(local_scale * q_p_ct_sharp)`. +- `q(z_i\|x_i) = N(μ_i, σ_i²)` — `encoder(x, batch)` → `Normal(z_loc, z_scale)`, sampled `"latent"`. +- `q(z^ϕ_i\|z_i, ϕ) = Cat(softmax(ℓ_i))` — represented by the surrogate, not an explicit categorical sample. + +## ELBO (eq 7) and the surrogate ("Inference Details") + +Eq 7 is the standard SVI ELBO (`Trace_ELBO`; `TraceEnum_ELBO` when `use_enumeration`), +`E[log p(x|z)] + E[log p(Ω,Φ,z,z^ϕ)] − E[log q]`, maximized by Adam. + +The note replaces the discrete `z^ϕ` terms with a surrogate: + +> `L_new = L# + γ Σ_i KL(probs_i ‖ ϕ_g(i))`, `probs_i = softmax(ℓ_i)` + +where `L#` is eq 7 with the `z^ϕ` terms removed and `γ>0`. The KL is a **penalty** +(the note "penalizes misalignment"), i.e. the objective is to *minimize* it. Pyro's SVI +**maximizes** the ELBO / log-joint, so the penalty enters the factor with a **minus +sign** — `−γ·KL`. (Reading the note's `+γ·ΣKL` as something to maximize would push +`probs` *away* from `ϕ`; the sign below is the one that realizes the note's intent.) +Implemented in `model()`'s `"data"` plate: + +```python +phi = p_ct[ct_idx].detach() # ϕ_g(i), detached alignment target +ell = gate_prob*cls_logits + (1-gate_prob)*log_phi # ℓ_i (eq 4) +probs = softmax(ell) +pheno_kl = (probs * (log(probs) - log_phi)).sum(-1) # KL(probs ‖ ϕ) +pyro.factor("phenotype_alignment", -phenotype_kl_weight * pheno_kl) +``` + +- `ct_idx = ct_array[indices]` uses **global** cell indices (threaded in via + `_get_fn_args_from_batch`), never the local pyro plate index — indexing with the + local index scrambles the per-cell target across shuffled minibatches. +- Optimum of the surrogate is `f_cls → log ϕ + const` (distinct per clone), i.e. the + classifier learns to predict the clonotype-informed phenotype from expression. +- `predict()` applies the same `ℓ_i` rule with `z_loc` (encoder mean, dropout off). + +## In-silico perturbation (eqs 8–12) — **[F] not implemented** + +`I_j = Σ_p |ϕ̄_p^(0) − ϕ̃_p^(j)|` (L1 shift after zeroing gene `j`). Additive feature; +no code path yet. + +## Deviations + +| id | deviation | severity | status | +|---|---|---|---| +| A | classifier had no ELBO gradient (missing factor) | HIGH | **fixed** — `pyro.factor("phenotype_alignment", …)` | +| A2 | surrogate target indexed by local plate idx → scrambled labels → f_cls collapse | HIGH | **fixed** — global `indices` threaded into `model()`/`guide()`; the `indices=None` path now `assert`s instead of silently falling back | +| B | `gate_prob` default was `None`; note sets π=0.5 | LOW | **fixed** — default `0.5` (typed `Optional[float]`) | +| C | `classifier_dropout` constructed but not passed to `PhenotypeClassifier` | LOW | **fixed** — plumbed | +| D | `class_weights`/`log_class_weights` — not in the note; was dead (computed + plumbed through 3 signatures, never read) | LOW | **fixed** — removed (with `phenotype_weights`) from `_model`/`_module`/`_training` | +| H | dead per-cell `encoder(x)` forward in `model()` (result discarded; the VampPrior carries its own encoder) | INFO | **fixed** — removed | +| G | α (`global_scale`) not applied to the clonotype prior (eq 1) in `model()`; concentration = normalized archetype centroid (sum≈1, U-shaped), so the prior was far more diffuse than `Dir(α·ψ_b)` and scaled inconsistently with the guide `q(ω_c)` | MED | **fixed** — `expanded_conc = global_scale * centroids` (eq 1); classifier recovery unchanged (1.000), suite green | +| E | `reconstruction_loss_scale` down-weights ZINB vs eq-7 full weight | MED | **resolved** — default raised 1e-3 → 1e-2; real-data library ratio 1.40 → 0.99 (recovery/latent unchanged). The original ~6× over-generation was mostly the phantom optimizer shrinking the decoder. | +| F | in-silico perturbation (eqs 8–12) not implemented | — | deferred — additive feature | + +**Training-only deviations from eq 7 (intentional, documented here):** +- **KL warmup + z-only scope.** `UnifiedTrainingPlan` ramps `kl_weight` over `n_steps_kl_warmup`, and it scales only the `latent` (z) KL — the two Dirichlet KLs (`p_c`, `p_ct`) are unscaled. A standard annealing schedule; symmetric (no correctness bug) but not part of eq 7's full-weight KL. +- **`num_particles`** on `UnifiedTrainingPlan` is honored only on the enumeration path (`TraceEnum_ELBO`); the default `Trace_ELBO` uses 1 MC particle regardless. + +A/A2/B/C/D/H were fixed in the model PR that introduced this file. **G and E are now +resolved too** — α is applied to the eq-1 prior, and `reconstruction_loss_scale` was +re-measured and recalibrated to `1e-2` (real-data library ratio 1.40 → 0.99). Both +change fitted results, so runs are not comparable across them. **F** (in-silico +perturbation) remains out of scope for this release. + +A further training-only deviation was found and removed: a **second torch Adam over all +module parameters**, installed by overriding scvi's deliberate no-op `configure_optimizers` +shim. It stepped after `SVI.step()` had zeroed the gradients, so weight decay degenerated +to a scale-free `~lr·sign(p)` shrink (networks held ~2.4× small), and `train(lr=)` never +reached Pyro's optimizer. See `optimizer_weight_decay` in the model contract. diff --git a/docs/contract/METRICS_CONTRACT.md b/docs/contract/METRICS_CONTRACT.md new file mode 100644 index 0000000..7a613b8 --- /dev/null +++ b/docs/contract/METRICS_CONTRACT.md @@ -0,0 +1,106 @@ +# Metrics Contract — what the numbers mean + +Freezes the **information-theoretic metrics**: the two entropies and mutual +information over a clone × phenotype joint. + +| | freezes | manifest | prose | test | +|---|---|---|---|---| +| API contract | the public *interface* | `tcri/_contract.pyi` | `tcri_api_and_responsibilities.md` | `test_contract_conformance.py` | +| Model contract | the *generative mathematics* | `tcri/model/_model_contract.py` | `MODEL_CONTRACT.md` | `test_model_contract_conformance.py` | +| **Metrics contract** | **what the metrics compute** | `tcri/tools/_metrics_contract.py` | this file | `test_metrics_contract_conformance.py` | + +**Why separate from the model contract.** The two are verified by different means. The +model contract *traces* `model()`/`guide()` and inspects sample sites, plates and +distribution families. Metrics are pure functions of a joint table, so they are pinned +by **numeric identities** — uniform → log₂(k), independent → MI 0, and the +entropy/MI decomposition. Folding them together would force one mechanism to do a job +it is bad at. + +Source of truth: **Supplementary Note 1**, "Entropy" section (eqs 2–4) — with the +errata below. + +**Governance: update this file and the manifest FIRST, then the code.** A failing +conformance test means the *meaning of a published number* changed. Never relax an +identity to make it pass. + +## Definitions (all in **bits**, log base 2) + +### `clonotypic_entropy` — one value per **phenotype** + +``` +H[P(c|φ)] = − Σ_c P(c|φ) log₂ P(c|φ) +``` + +How spread a phenotype is across clones. **Support-only**: clones with zero mass in +that column are dropped *before* renormalizing — no epsilon clip, which would fabricate +uniform mass on absent clones and inflate H toward 1. Normalizer `log₂(#supported +clones)`, or `log₂(n_clones_ref)` when supplied. Empty column → **NaN**. + +### `phenotypic_entropy` — one value per **clone** + +``` +H[P(φ|c)] = − Σ_φ P(φ|c) log₂ P(φ|c) +``` + +Plasticity vs commitment of a clone. All P phenotypes are in the sum with `0·log0 := 0`. +Normalizer `log₂(P)`. A clone with zero mass → **NaN**, never reindexed to zeros (which +would report a spurious `H=1` for a clone that was never observed). + +### `mutual_information` — one value per joint + +``` +I(c;φ) = Σ_{c,φ} P(c,φ) log₂( P(c,φ) / (P(c)·P(φ)) ) +``` + +Default `normalize_mode="min"` → `I / min(H(c), H(φ))`, the coefficient of constraint. +`"average"` → `I / (½(H(c)+H(φ)))`. **`min` is the default because the `average` +denominator scales with `log₂(C)` and is therefore not comparable across groups with +different clone counts.** + +## Enforced identities + +| identity | what it catches | +|---|---| +| uniform over k → `log₂(k)`, normalized `1.0` | a wrong log base or normalizer | +| all mass on one outcome → `0` | sign/normalization errors | +| zero-mass clone/phenotype → **NaN** | the spurious-`H=1` reindexing regression | +| support-only normalization | an epsilon clip creeping back in | +| independent joint → `I = 0` | a broken MI | +| `I(c;φ) = I(φ;c)`, `I ≥ 0` | transpose/sign errors | +| permutation joint → normalized `I = 1` | a wrong denominator | +| **`I(c;φ) = H(c) − Σ_φ P(φ)·H[P(c|φ)]`** | **redefining either family alone** | + +That last one is the keystone: it ties entropy and MI together, so you cannot change +one without breaking it. + +## Errata in Supplementary Note 1 (the code is correct) + +The note's eqs 3–4, read literally, do **not** match the implementation — and the note +is the one that is wrong. Recorded here so nobody "fixes" the code to match a typo. + +1. **Eq 3** reads `H(p(c|φ)) = − Σ_c p(c) log p(c|φ)` — it weights by the **marginal** + `p(c)` while taking the log of the **conditional**. That is a cross-entropy, not an + entropy. +2. **Eq 4** is labelled `H(p(c))` but its right-hand side sums over φ and uses `p(φ|c)`, + so the label is wrong; it also weights by the marginal. +3. The **prose** introduces both as "the entropy of the marginal distributions", but the + equations are conditionals. + +**Why the code is right.** Mutual information must satisfy +`I(c;φ) = H(c) − E_φ[H(c|φ)]`. On a test joint with true MI **0.288703**: + +- the implemented conditional entropy reproduces it **exactly** (0.288703); +- the note's literal formula yields **−0.345883** — a *negative* mutual information, + which is impossible. + +The literal equations are inconsistent with the note's own MI, so they cannot be what +was intended. `test_note_literal_formula_would_break_the_decomposition` pins this. + +## Sanctioned extensions (the note does not specify these) + +- **bits / log₂** — the note writes an unspecified `log`. +- **`normalized=True`** — divide by the maximum-entropy value so results land in [0,1]. +- **`n_clones_ref`** — fix the clonotypic normalizer across groups; without it each + group normalizes by its own supported-clone count and the values are not comparable. +- **`n_samples>0`** — return mean/sd/HDI over posterior draws. The plug-in entropy is + ≥ the posterior mean (Jensen), so the two are reported as distinct quantities. diff --git a/docs/contract/MODEL_CONTRACT.md b/docs/contract/MODEL_CONTRACT.md new file mode 100644 index 0000000..43c3b26 --- /dev/null +++ b/docs/contract/MODEL_CONTRACT.md @@ -0,0 +1,191 @@ +# TCRI Model Contract (FROZEN) + +**The model this package implements is Supplementary Note 1** (`tcri_supplementary_methods_04_30_26.pdf`). +This document is the prose contract; `tcri/model/_model_contract.py` is its +machine-checkable form; `tests/test_model_contract_conformance.py` enforces it. + +Sibling of the API contract: `tcri/_contract.pyi` freezes the public *interface*, +this freezes the *mathematics*. `docs/contract/METHODS_CONFORMANCE.md` is the +eq-by-eq code map and deviation history. + +--- + +## THE RULE + +> **Changing the model's mathematics requires updating this contract *first*.** + +Concretely — adding/removing a stochastic site, changing a distribution family or +plate, altering the ELBO or the surrogate, or changing what a prior is scaled by: + +1. **Update the contract first** — this file *and* `_model_contract.py`, citing the + note equation and stating what changes in the joint distribution. +2. **Then change the code** so `test_model_contract_conformance` passes again. +3. **If the note itself is superseded**, say so explicitly here (with the new + reference). The note is the source of truth; the contract tracks it. + +**Never** make a conformance failure disappear by loosening the manifest to match +whatever the code now does. That silently rewrites the model the package claims to +implement, which is exactly what this guardrail exists to prevent. A failure means +*stop and decide*: is this an intended model change (update the contract) or a +regression (fix the code)? + +This applies to human and AI contributors alike. If you are an agent and a model +change seems necessary, surface the contract implication to the user rather than +editing the manifest to fit. + +--- + +## The generative model + +`p(Ω, Φ, z, x) = Π_c p(ω_c) · Π_m p(ϕ_m|ω_h(m)) · Π_i p(z_i)·p(z^ϕ_i|z_i,ϕ_g(i))·p(x_i|z_i)` + +| eq | site | distribution | plate | meaning | +|---|---|---|---|---| +| 1 | `p_c` | `MixtureDirichlet` | `clonotypes` (c=1..C) | `ω_c ~ (1/B_c) Σ_b Dir(α·ψ_b)` — clonotype-level phenotype distribution over archetypes `ψ_b` | +| 2 | `p_ct` | `Dirichlet` | `ct_plate` (m=1..M) | `ϕ_m \| ω_h(m) ~ Dir(β·ω_h(m))` — covariate-level, hierarchical under its clonotype | +| 3 | `latent` | `MixtureSameFamily` (VampPrior) | `data` (i=1..N) | `z_i ~ (1/B_z) Σ_k q(z\|u_k)` over learnable pseudo-inputs | +| 4 | *(surrogate)* | — | `data` | `ℓ_i = π·f_cls(z_i) + (1−π)·log ϕ_g(i)`; `z^ϕ_i ~ Cat(softmax(ℓ_i))` | +| 5 | `obs` | `ZeroInflatedNegativeBinomial` | `data` | `x_i ~ ZINB(g'_i, r_i, μ_i)` from the scVI decoder | + +**Scales are semantics, not tuning.** α (`global_scale`) scales eq 1's concentration +and β (`local_scale`) scales eq 2's. Dropping either changes the prior's *shape* — +with concentration entries < 1 a Dirichlet becomes U-shaped (mass at the simplex +corners), the opposite of a prior peaked at the archetype — and desynchronizes the +prior from the guide. Both are asserted by the conformance test. + +## The variational family (eq 6) + +`q(Ω,Φ,z|x) = Π_c Dir(ω_c|λ_c) · Π_m Dir(ϕ_m|λ'_m) · Π_i q(z_i|x_i;η_enc) · Π_i q(z^ϕ_i|z_i,ϕ;η_cls)` + +| site | distribution | learnable | +|---|---|---| +| `p_c` | `Dirichlet` | `q_p_c_raw` (λ_c), scaled by α | +| `p_ct` | `Dirichlet` | `q_p_ct_raw` (λ'_m), scaled by β | +| `latent` | `Normal(μ_i, diag(σ_i²))` | encoder `η_enc` | + +`z^ϕ` is **not** sampled — see the surrogate below. A categorical `q(z^ϕ)` site +reappearing in the guide changes the objective and is rejected by the test. + +## The objective + +The ELBO (eq 7) is `E_q[log p(x|z)] + E_q[log p(Ω,Φ,z,z^ϕ)] − E_q[log q]`, maximized +by SVI (Adam, reparameterized continuous latents). + +**The surrogate** ("Inference Details") replaces the discrete `z^ϕ` terms: + +> `L_new = L# + γ·Σ_i KL(probs_i ‖ ϕ_g(i))`, `probs_i = softmax(ℓ_i)` + +The KL is a **penalty** on misalignment (the note "penalizes misalignment"), so it is +*minimized*. Pyro's SVI **maximizes** the log-joint, therefore the factor is registered +with a **minus sign**: + +```python +pyro.factor("phenotype_alignment", -phenotype_kl_weight * kl) # −γ·KL ≤ 0 +``` + +A positive factor would push `probs` *away* from `ϕ`. The test asserts the factor's +log-value is ≤ 0 and non-zero. + +**This term is the only thing that trains `f_cls`.** Without it the classifier's +logits never enter the ELBO and it receives no gradient (recovery sits at chance). + +**The alignment target must use global cell indices.** `ϕ_g(i) = p_ct[ct_array[indices]]` +where `indices` are *global* cell ids threaded in via `_get_fn_args_from_batch`. The +pyro data-plate index is local (`0..batch_size−1`); using it scrambles each cell's +target across shuffled minibatches and collapses `f_cls` to a constant. + +## Gating (π) + +`gate_prob` = π ∈ (0,1), default **0.5** per the note. Endpoints are contract-tested: +π=1 ⇒ `predict()` is the pure classifier; π=0 ⇒ the pure clonotype prior; +π=`None` ⇒ the additive rule `f_cls + log ϕ`. + +--- + +## Sanctioned deviations + +Accepted departures from the note. **Anything not listed here that departs from the +note is a defect.** Keys match `SANCTIONED_DEVIATIONS` in `_model_contract.py` (the +test asserts they stay in sync). + +| key | departure | rationale | +|---|---|---| +| `E_reconstruction_loss_scale` | eq 7 weights `E[log p(x\|z)]` at 1; the `obs` site is scaled by `reconstruction_loss_scale` (default **`1e-2`**) | β-VAE-style reweighting, **re-measured and recalibrated** — see below. | + +### On `reconstruction_loss_scale` (deviation [E], resolved) + +Re-measured after the phantom optimizer was removed. Posterior-predictive library +ratio (simulated ÷ observed; 1.00 is calibrated): + +| scale | real yost (2259×1000) | synthetic (3000×60) | +|---|---|---| +| `1e-3` (old default) | **1.40** | 1.00 | +| **`1e-2` (new default)** | **0.99** | 1.00 | +| `1e-1` | 1.00 | 1.00 | +| `1.0` (eq-7 full weight) | — | **0.91** (over-corrects) | + +Dropout fraction matches observed at every setting (0.870 vs ~0.873 on real data). +Classifier recovery (1.000) and latent separation (7.19) are **unchanged** across +`1e-3`→`1e-1`, so the recalibration costs nothing. + +The originally-reported **~6× over-generation was mostly the phantom second +optimizer** shrinking the decoder (see `optimizer_weight_decay`); removing it took the +ratio 6× → 1.40, and this default closes the remainder. Note the synthetic data could +not detect this — only the real 1000-gene, 87%-dropout data discriminates. + +Three inconsistent defaults (`_model.train`=1e-3, `_module`=1e-3, `_training`=1e-2) +were unified to `1e-2`. +| `kl_warmup_z_only` | `kl_weight` anneals only the `latent` KL; the Dirichlet KLs are unscaled | Standard annealing; training-only, not part of eq 7. | +| `num_particles_enumeration_only` | `num_particles` applies only on the `TraceEnum_ELBO` path | Default `Trace_ELBO` uses 1 MC particle. | +| `F_perturbation_not_implemented` | in-silico perturbation (eqs 8–12) absent | Additive feature; explicitly out of scope for this release. | +| `optimizer_weight_decay` | the SVI optimizer applies Adam weight decay (default `1e-4`) to the network parameters | The note fixes the *objective* (eq 7 + the surrogate), not the optimizer. Applied inside Pyro's optimizer so it acts on the ELBO gradients. See the note below. | + +### On the optimizer (history worth keeping) + +Weight decay was previously applied by a **second** `torch.optim.Adam` over every +module parameter, installed by overriding `UnifiedTrainingPlan.configure_optimizers`. +That override replaced scvi's *deliberate no-op shim* — scvi returns +`Adam([self._dummy_param])` purely to advance Lightning's step counter — and it ran +**after** `SVI.step()` had already stepped and **zeroed the gradients**. + +Stepping Adam on zero gradients is not a no-op. The weight-decay term becomes the +entire gradient (`g = wd·p`), and Adam's normalization `g/√(g²)` then strips its +magnitude, so the update degenerates to **≈ `lr·sign(p)`** — a *scale-free* shrink of +roughly `lr` per step rather than proportional L2. Measured in isolation: a weight of +0.1 is driven to 5e-5 within 1000 steps, where true L2 (`(1−lr·wd)^n`) would leave it +at 0.9998. In the fitted model SVI pushed back, but the equilibrium sat at **~2.4× +smaller network weights** (encoder 0.107 vs 0.240). + +It also meant `train(lr=...)` never reached the optimizer that fits the model — Pyro +always used scvi's hard-coded `1e-3`, and `lr` only set the shrink rate. + +The override is removed; `lr`/`weight_decay`/`betas`/`eps` now go to Pyro's optimizer +via `optim_kwargs`, which is where the original intent belongs. `lr` is consequently a +**live** knob for the first time (verified: it moves recovery, weight scale, and the +final ELBO), so fits are not comparable across this change. + +## What the conformance test checks + +`tests/test_model_contract_conformance.py` traces the live `model()`/`guide()`: + +- every declared site exists with the right distribution family, plate, event-dim, + observed-flag — **and no undeclared site exists** (an extra site changes the joint); +- the guide's variational family + learnable params (λ_c, λ'_m), and that `z^ϕ` is not sampled; +- α scales eq 1's concentration; +- **eq 2 is hierarchical**: `p_ct`'s concentration is asserted *elementwise* to equal + `clamp(β·(ω_c[ct_to_c] + eps))` against the sampled `p_c` **from the same trace** — + pinning the scale, the source tensor, and the index map h(m) together; +- the surrogate factor is a *negative*, non-zero KL; +- **the alignment target is verified behaviorally**: on a minibatch whose global + indices differ from the local plate positions, the traced factor must equal the + surrogate recomputed under the *global* map and must differ from the local one; +- π endpoints reduce `predict()` to classifier / prior; +- every sanctioned deviation is documented here. + +**Assertions are behavioral, not textual.** Two earlier drafts of this test were +defeated in an adversarial audit: a scalar "concentration totals ≈ β" check passed +even with the clonotype→covariate hierarchy severed (every simplex row totals 1, so +any tensor under any permutation satisfies it), and a source-grep for +`ct_array[indices]` was defeated by routing the same wrong lookup through +`index_select`. Prefer assertions computed from a live trace over ones that read +source text or scalar summaries. diff --git a/docs/contract/REFACTOR_AGENDA.md b/docs/contract/REFACTOR_AGENDA.md index 09825c5..a59897e 100644 --- a/docs/contract/REFACTOR_AGENDA.md +++ b/docs/contract/REFACTOR_AGENDA.md @@ -33,31 +33,31 @@ tracker + running diary for the whole refactor. The detailed spec lives in `tcri |---|---|---|---|---|---| | 0 | Contract freeze + CI scaffolding | ✅ | none | — | conformance green | | 1 | Shared helpers + `_keys` | ✅ | low | 0 | existing tests green | -| 2 | Safe deletions | ☐ | very low | 1 | import-graph clean | -| 3 | Model module split | ☐ | low | 1 | model/pyro tests green | -| 4 | Model→AnnData streamline | ☐ | HIGH | 1,3 | session round-trip | -| 5 | Engine consolidation | ☐ | HIGH | 4 | joint identities | -| 6 | Metric-API consolidation | ☐ | HIGH | 5 | metric tests | -| 7 | Plotting split + pl twins | ☐ | medium | 6,1 | twins render | -| 8 | `diag/` seeding | ☐ | low-med | 4,5 | PPC columns | -| 9 | PGM→docs; utils finalize | ☐ | low | 1,8 | import green sans daft | -| 10 | Notebook rewrite (fresh) | ☐ | low | 4–8 | nbmake tutorial | -| 11 | Public API + scverse CI | ☐ | low-med | all | ecosystem checklist | +| 2 | Safe deletions | ✅ | very low | 1 | import-graph clean | +| 3 | Model module split | ✅ | low | 1 | model/pyro tests green | +| 4 | Model→AnnData streamline | ✅ | HIGH | 1,3 | session round-trip | +| 5 | Engine consolidation | ✅ | HIGH | 4 | joint identities | +| 6 | Metric-API consolidation | ✅ | HIGH | 5 | metric tests | +| 7 | Plotting split + pl twins | ✅ | medium | 6,1 | twins render | +| 8 | `diag/` seeding | ✅ | low-med | 4,5 | PPC columns | +| 9 | PGM→docs; utils finalize | ✅ | low | 1,8 | import green sans daft | +| 10 | Public API + scverse CI | ☐ | low-med | all | ecosystem checklist | ## Removal Ledger (the hard bar — every one MUST end deleted) Tick only when the symbol is gone from source AND `__all__`/imports AND `import tcri` is green. -**Phase 2 (dead / out-of-scope):** -- [ ] `pp.get_latent_embedding` · [ ] `pp.group_small_clones` · [ ] `pp.register_probability_columns` -- [ ] `pp.remove_meaningless_genes` · [ ] `pp.gene_entropy` · [ ] `pp.classify_phenotypes` -- [ ] `pl.polar_plot` · [ ] `pl.probability_distribution` · [ ] `pl.bayesian_mutual_information` -- [ ] `metrics._ent` · [ ] `tl.clone_fraction` · [ ] `metrics.dkl` (→ `_distance.kl_divergence`) -- [ ] `ut.probabilities` (+ its `_plotting.py:18` import, same PR) · [ ] `SankeyNode.hex_to_rgb` +**Phase 2 (dead / out-of-scope):** ✅ ALL DELETED (PR2) — 14 symbols, 384 lines, `import tcri` green. +- [x] `pp.get_latent_embedding` · [x] `pp.group_small_clones` · [x] `pp.register_probability_columns` +- [x] `pp.remove_meaningless_genes` · [x] `pp.gene_entropy` · [x] `pp.classify_phenotypes` +- [x] `pl.polar_plot` · [x] `pl.probability_distribution` · [x] `pl.bayesian_mutual_information` +- [x] `metrics._ent` · [x] `tl.clone_fraction` · [x] `metrics.dkl` (→ `_distance.kl_divergence`) +- [x] `ut.probabilities` (+ its `_plotting.py` import, same PR) · [x] `SankeyNode.hex_to_rgb` -**Phase 4 (folded into `to_anndata` / session):** -- [ ] `pp.register_model` (→ `model.to_anndata`) · [ ] `pp.register_phenotype_key` · [ ] `pp.register_clonotype_key` -- [ ] `pp._compute_logits_and_prior` · [ ] `ut.write_adata_safely` · [ ] `ut._pop_nonserializables` -- [ ] uns keys `tcri_manager`, `tcri_clone_key`, `tcri_phenotype_key`, obsm `X_tcri_phenotypes` +**Phase 4 (folded into `to_anndata` / session):** ✅ functions folded + manager stash retired (PR4). +- [x] `pp.register_model` (→ `model.to_anndata`) · [x] `pp.register_phenotype_key` · [x] `pp.register_clonotype_key` +- [x] `pp._compute_logits_and_prior` · [x] `ut.write_adata_safely` · [x] `ut._pop_nonserializables` +- [x] uns key `tcri_manager` (retired at `setup_anndata`; `test_model_setup` asserts it's gone) +- [x] `tcri_clone_key` / `tcri_phenotype_key` / obsm `X_tcri_phenotypes` — **DONE**. The deferral said "still read by not-yet-refactored `metrics`/`plotting`", but those were rewritten in PR6/PR7; the last reader was a raw literal in `pp.clone_size`, now migrated to `uns[METADATA][CLONE_COL]` with a clear error. `to_anndata` no longer writes the shims and the three `LEGACY_*` constants are deleted (`LEGACY_MANAGER` stays — `save_tcri_session` still pops it defensively). **Phase 5/6 (consolidated away — delete WITH replacement, never before):** - [ ] `pp.joint_distribution_posterior` (→ unified `joint_distribution`) · [ ] `metrics._mi_from_joint` (→ `_mutual_information`) @@ -75,7 +75,7 @@ Tick only when the symbol is gone from source AND `__all__`/imports AND `import - [ ] `ut.build_nested_tcri_pgm` (→ `docs/`) · [ ] `ut.draw_tcri_pgm_nested` (→ `docs/`) · [ ] `daft` runtime dep **Phase 3/9 (model/utils cleanup):** -- [ ] `_ascii_hist` (+ all `graph=`/ASCII paths) · [ ] `ml.plot_loss` (→ `diag.loss`) · [ ] `ml.plot_archetypes` (→ `diag.archetypes`) +- [x] `_ascii_hist` (dead: zero callers) · [x] `ml.plot_loss` (→ `diag.loss`) · [x] `ml.plot_archetypes` (→ `diag.archetypes`) --- @@ -106,18 +106,106 @@ Template per PR: **Goal · Status · What happened · Issues & fixes · Added - **Usability:** internal only this step. - **`K.*` migration (done):** replaced **85** canonical key literals with `K.*` across preprocessing/metrics/plotting/utils via a verified script (model=0; only legacy keys there); added `test_no_canonical_key_literals` guard — none remain. `dkl` reassigned (dead `metrics.dkl`→Phase 2; `flux` inner→Phase 6). Legacy keys left as literals until their removal phases. **PR1 COMPLETE — 35 passed / 1 skipped.** -## PR 2 — Safe deletions · ☐ todo -_(diary to be filled — this is a REMOVAL PR; the ledger Phase-2 block must be fully ticked)_ - -## PR 3 — Model module split · ☐ todo -## PR 4 — Model→AnnData streamline · ☐ todo -## PR 5 — Engine consolidation · ☐ todo -## PR 6 — Metric-API consolidation · ☐ todo -## PR 7 — Plotting split + pl twins · ☐ todo -## PR 8 — diag/ seeding · ☐ todo -## PR 9 — PGM→docs; utils finalize · ☐ todo -## PR 10 — Notebook rewrite · ☐ todo -## PR 11 — Public API + scverse CI · ☐ todo +## PR 2 — Safe deletions · ✅ done (branch `refactor/pr2-safe-deletions`) +- **Goal:** delete the Phase-2 dead / out-of-scope symbols outright (they go to the trash, not to examples). Zero behavior change to the kept surface; `import tcri` stays green. +- **What happened:** verified **every** target has zero in-package call-sites (grep for calls/imports, plus a precise `NAME(` call-site check for the three ambiguous ones — `_ent`, module-level `dkl`, `probabilities` — all 0), then deleted via AST-span (top-level `FunctionDef` line ranges; the `SankeyNode.hex_to_rgb` method inside its `ClassDef`; the dead `from ..utils._utils import probabilities` line). **14 symbols, 384 lines removed** across metrics/plotting/sankey/preprocessing/utils. `import tcri` green; **full suite 35 passed / 1 skipped** (unchanged — nothing referenced them). +- **Why safe (not deferred):** the "dkl" refs that remain are the **string** distance-metric name in `flux` + the `_distance` registry, not the deleted module-level `dkl` function (`flux` keeps its own inner `dkl_func` → Phase 6). The `polar_plot`/`probability_distribution` "callers" were self-referential (own docstring examples / the self-recursion bug). `probabilities` was imported into plotting but never called. +- **Added:** n/a (pure removal PR). +- **Removed (hard bar):** ✅ all 14 Phase-2 ledger items ticked — `pp.get_latent_embedding`, `pp.group_small_clones`, `pp.register_probability_columns`, `pp.remove_meaningless_genes`, `pp.gene_entropy`, `pp.classify_phenotypes`, `pl.polar_plot`, `pl.probability_distribution`, `pl.bayesian_mutual_information`, `metrics._ent`, `tl.clone_fraction`, `metrics.dkl`, `ut.probabilities` (+ import), `SankeyNode.hex_to_rgb`. Confirmed gone: no `def` remains, no explicit re-export/`__all__` names them. +- **Test opportunities:** none new (removal); the existing suite is the regression gate and stayed green. +- **Streamline:** shrinks preprocessing (−127) and plotting (−225) meaningfully ahead of the Phase 3/4/7 splits. Removed the one import this PR orphaned (`cosine_similarity` — sole user was the deleted `classify_phenotypes`); other module-top imports left for the file-split phases (conservative; the PR1 audit already handled the utils ones). +- **Usability:** removes broken/dead public entry points (`probability_distribution` self-recursion, `bayesian_mutual_information` bad kwarg, `polar_plot` undefined-name) from the surface so nobody trips on them. + +## PR 3 — Model module split · ✅ done (branch `refactor/pr3-model-split`) +- **Goal:** split the 1074-line `model/_model.py` into scvi-style sibling files (`_model` + `_module` + `_priors` + `_classifier` + `_training`), rename `c2p_mat → clone_phenotype_prior`. Mechanical; **no behavior change**. +- **What happened:** verified up front that **no code outside `model/` references any moved internal** (only `TCRIModel` is imported externally). Extracted the 7 top-level defs via `ast.get_source_segment` (formatting-preserving) into the target files along the clean dependency DAG `_classifier`/`_priors` (leaf) → `_module` → `_training` → `_model`. Each module declares an explicit `__all__` (a Phase-3 deliverable — plan §Phase 3), so `tcri.model.*` is now pinned to exactly the public API the frozen contract promises — `{TCRIModel}` — and the incidental third-party re-export leaks the old `import *` exposed (`pyro`/`dist`/`Encoder`/`KMeans`/… 17 names, none tcri-defined, none referenced anywhere) are no longer surfaced. Applied the `c2p_mat → clone_phenotype_prior` rename with a word-boundary regex (13 sites; left the unrelated `c2p_torch` local and the module buffer `clone_phen_prior` untouched). Dropped 3 provably-dead top-level imports surfaced by the per-file import rebuild (`setup_anndata_dsp`, `cosine_similarity`, the `torch.distributions` `Categorical/Dirichlet/MixtureSameFamily` trio — all uses were `dist.`-prefixed pyro). File sizes: `_model` 462, `_module` 326, `_training` 154, `_priors` 147, `_classifier` 21. +- **Issues & fixes:** (1) my first smoke silently imported a **stale `site-packages/tcri`** copy (a script's dir, not the repo, leads `sys.path`) and failed in `setup_anndata` on an old-copy/scvi mismatch — a red herring; forcing the repo copy onto the path, the smoke passes. (The stale install is an env-hygiene note, not a code issue — pytest already uses the repo copy, which is why the suite validates the split.) (2) The train path was **entirely uncovered** by the suite (`trained_model` fixture defined but unused) — so the split was only import-verified. Fixed by adding a real end-to-end smoke test. +- **Added:** ✅ explicit `__all__` in all 5 model modules (`_model`=`{TCRIModel}`; siblings export their own class(es)). ✅ `tests/test_model_smoke.py` — construct → train (2 epochs) → `get_latent_representation` / `get_p_ct` / `get_cell_phenotype_probs` (asserts shapes + prob normalization), plus asserts the `clone_phenotype_prior` rename landed and `build_archetypes` returns centers **and** labels. Runs in ~1s inside the suite. +- **Removed (hard bar):** n/a — PR3 is a structural split, not a removal PR. `ml.plot_loss`/`ml.plot_archetypes` stay on `TCRIModel` until `diag/` exists (Phase 8); no Phase-2 style deletions here. +- **Test opportunities:** ✅ closed the biggest gap (model construct/train/query now covered). The rewritten `test_session_round_trip` (Phase 4) will extend this to save/load. +- **Streamline:** the split makes Phase 4 (model→AnnData) and Phase 5 (engine) tractable — the pyro module, priors, classifier, and training plan are now editable in isolation. +- **Usability:** each file now has a docstring stating its role; the model file reads as a clean `BaseModelClass` API surface. +- **Deferred (logged):** **M5** (`build_archetypes` default `K=4` vs `TCRIModel` `K=10`) — behavior-neutral today (the model always passes `K=10` explicitly), reconciled with persisted `labels` when `diag.archetypes` lands (Phase 8). Not touched here to keep the split purely mechanical. Also deferred (auditor's own recommendation): the stale `c2p_mat` descriptors in the contract **generator** (`build_tcri_contract.py:81,267`) + regenerating the contract HTML — bundled with the Phase-8 `diag.archetypes`/M5 pass (they describe that future function). The inventory rename-table row (`c2p_mat → clone_phenotype_prior`) is correct and stays. +- **Audit (workflow — 3 lenses × adversarial verify, 8 agents):** 2 lenses PASS, plan-contract FIX. 5 findings, **all confirmed, all LOW/MED** — no behavior/correctness defect (behavior lens confirmed byte-identical class bodies + zero F821 undefined-names + suite green). Fixed here: the **MED** — explicit `__all__` per module (plan §Phase 3) was omitted — now added to all 5 files, which also resolves the two LOW "surface not byte-for-byte" findings (surface is now the explicit `{TCRIModel}`; diary wording corrected; the 3 `# noqa: F401` re-exports removed as no longer needed). Suite 36 passed / 1 skipped. +## PR 4 — Model→AnnData streamline · ✅ done (branch `refactor/pr4-model-anndata`) +- **Goal:** kill the `uns['tcri_manager']` hack; fold `register_model → model.to_anndata` (writing the full canonical set incl. new `GATE_PROB`/`CLASSIFIER_TEMPERATURE`); rename `get_cell_phenotype_probs → predict` (labelled DataFrame); rewrite the round-trip gate. Behavior change. +- **Env (prereq):** built a fresh py3.12 venv on the latest scverse stack (anndata 0.13.1, scanpy 1.12.2, **scvi-tools 1.5.0**, torch 2.13, numpy 2.4, **pandas 3.0.3**), pinned in `requirements.txt`; all runs use `.venv`. One pandas-3.0 compat fix (legacy `tcri_boxplot` `groupby.median()` positional `numeric_only`). Suite green in the new env. +- **What happened:** `setup_anndata` → keyword-only, returns `None`, no manager stash (registration only). `predict` → labelled `DataFrame` (obs_names × phenotypes), `eval()` → deterministic. `to_anndata` → metadata + categories, `P_CT`/`CT_TO_COV`/`CT_TO_C`/per-cell `CT_ARRAY`/`COV_ARRAY`, `LOCAL_SCALE`, **`GATE_PROB`**, **`CLASSIFIER_TEMPERATURE`**, `X_TCRI`/`X_LOGITS`/`X_LOGPOSTERIOR`, `X_PROBABILITIES` (from `predict`) + argmax labels. Deleted the register cluster + `write_adata_safely`/`_pop_nonserializables` (inlined h5ad write). Onboarded the 6 `TCRIModel` methods into the contract (`IMPLEMENTED`) — conformance now enforces the model surface. +- **Issues & fixes:** the rewritten round-trip test passed in isolation but failed in the full suite — the **process-global pyro param store** (§5.2) is clobbered by other model-training tests, so the session-scoped `trained_model`'s store isn't its own at save-time. Fixed with a function-scoped `fresh_trained_model` fixture that trains inside the test and owns the store. +- **Correction to the PR3 note:** my "train path was uncovered" claim was wrong — the fixture is `trained_model` (I grepped the wrong name `fitted_model`), and `test_session_round_trip` consumes it, so train WAS covered. The PR3 smoke is still a faster/targeted guard; the framing was off. +- **Added:** ✅ rewritten `test_session_round_trip.py` (canonical write-set · setup-obs invariant · reloaded model reproduces p_ct/latent/predict) ✅ `dev/real_data_to_anndata.py` — **LOCAL-only, gitignored, not CI** — builds the 50-largest patient-specific clones of yost (`trb_unique = trb+patient`; 7682 cells / 10 patients) and runs setup→train→to_anndata (canonical keys OK; prior-driven recovery 0.90). +- **Removed (hard bar):** ✅ `register_model`, `register_phenotype_key`, `register_clonotype_key`, `_compute_logits_and_prior`, `write_adata_safely`, `_pop_nonserializables`; `uns['tcri_manager']` stash. Legacy `tcri_clone_key`/`tcri_phenotype_key`/`X_tcri_phenotypes` deferred to Phase 6/7 (live readers). +- **Test opportunities:** the **Model knob-test matrix** below (new deliverable). **Two dead knobs surfaced** — see "Model correctness debt." +- **Streamline / Usability:** `predict` returns a labelled DataFrame; `to_anndata` is one call replacing the heavy `register_model`; save/load is plain h5ad. + +--- + +# MODEL KNOB-TEST MATRIX · ✅ COMPLETE (`tests/test_model_knobs.py`, 32 tests) + +**Two layers, and the split is the point.** + +- **WIRING** — does the value actually reach the object it configures? This layer was + added after `lr` sat marked "hooked up" for months while never reaching Pyro's + optimizer. The model still converged, so *every behavioral test passed*. A silently + ignored knob is invisible to convergence testing; assert the plumbing directly. +- **BEHAVIOR** — the mathematically-correct input→output assertion. + +| Knob | Wiring test | Behavior test | Status | +|---|---|---|---| +| `n_latent` | `module.n_latent` | latent width == n_latent | ✅ | +| `n_pseudo_obs` | `vamp_prior.pseudo_inputs.shape[0]` | — | ✅ | +| `K` | `mixture_concentration.shape[0]` | `centers.shape[0] == K` | ✅ | +| `n_hidden`,`n_layers` | encoder param count scales | — | ✅ | +| `global_scale` (α) | `module.global_scale` | scales the eq-1 prior concentration ([G] fix) | ✅ | +| `local_scale` (β) | `module.local_scale` | p_ct draw variance == p(1−p)/(β+1) | ✅ | +| `prior_temperature` | `module.prior_temperature` | T>1 raises `clone_phen_prior` row-entropy | ✅ | +| `guide_temperature` | `module.guide_temperature` | T<1 lowers `get_p_ct()` row-entropy | ✅ | +| `gate_prob` (π) | `module.gate_prob` | π=0 ⇒ softmax(log φ); π=1 ⇒ softmax(f_cls) | ✅ | +| `classifier_temperature` | `module.classifier_temperature` | logits(T=2) == logits(T=1)/2 | ✅ | +| `classifier_dropout` | `classifier.mlp[2].p` | — | ✅ (was ⛔ not-plumbed) | +| `classifier_hidden`,`classifier_n_layers` | layer widths / Linear count | — | ✅ (was ⛔ untrained) | +| `kl_weight_max` | `module.kl_weight_max` | ramp ceiling | ✅ | +| `guide_init_scale` | `module.guide_init_scale` | — | ✅ | +| `phenotype_kl_weight` (γ) | `module.phenotype_kl_weight` | classifier recovery (model contract) | ✅ | +| `lr`,`weight_decay`,`betas`,`eps` | **`plan.optim.pt_optim_args`** | recovery/ELBO move with lr | ✅ **(was DEAD)** | +| `n_steps_kl_warmup` | `plan.n_steps_kl_warmup` | `kl_weight` ramps 0→max, monotonic | ✅ | +| `reconstruction_loss_scale` | `module.reconstruction_loss_scale` | — (deviation [E]) | ✅ wiring | +| `max_epochs`,`patience` | `trainer.max_epochs`, EarlyStopping `.patience` | — | ✅ | +| `batch_size` | dataloader batch shape | `predict` invariant (float32 tol) | ✅ | +| `use_enumeration` | selects `TraceEnum_ELBO` vs `Trace_ELBO` | — | ✅ | +| `phenotype_weights` | — | — | **REMOVED** (was dead; deleted in Phase 1a) | + +**Findings from the run:** no new dead knobs. The two initial failures were both +*test* bugs, not code bugs — scvi installs `LoudEarlyStopping` (not `EarlyStopping`), +and `predict` differs by ~1.2e-07 across batch sizes (float32 kernel paths, not a +logic error). Previously-⛔ classifier knobs are unblocked now that the classifier +trains. `n_steps_kl_warmup`'s step-vs-epoch semantics remain open as **DUX-2**. + +## PR 5 — Engine consolidation · ✅ done (branch `refactor/pr5-engine`) +- **Goal:** build the unified `joint_distribution` engine (`tools/` + `_compute/`) that every metric will consume — the substrate. **Additive** this PR: the old `joint_distribution`/`joint_distribution_posterior` stay until Phase 6 migrates the metrics onto the new engine. HIGH risk (math-heavy). +- **What happened:** created `_compute/_xp.py` (torch-first device seam — CPU / torch-CUDA, lazy GPU import, `asnumpy` boundary; grafiti parity but torch-first since the draws are torch), `_compute/_joint.py::_joint_draws` (the `[S, n_clones, P]` core), `_compute/_reduce.py` (batched entropy/MI, **bits/log2 default** per the user, float64 accumulators), `tools/_joint.py` (the DataFrame wrapper), re-exported `tcri.joint_distribution`. Onboarded `tl.joint_distribution` into the contract (added `device`). +- **Engine invariants implemented (§7.1):** temper the base **once** (`T==1` is the *exact* identity — no eps round-trip, so both closed-form identities are exact/near-exact); **draw over all ct rows once then slice per covariate** (shared-draw invariant → draw-count == `n_samples` regardless of #covariates); `n_samples=0` deterministic; `use_logits=True` folds per-cell logits with `log(base)` **gate-aware** and scatter-adds per clone (matching `predict`); `weighted` scales clone rows by the **ct-keyed** cell count (fixes the old clone-indexed `Counter` bug); torch-seeded Dirichlet (`random_state`). +- **Verified (the gate — `tests/test_tools/test_joint.py`, all green):** `use_logits=False,n=0,T=1 == uns[P_CT]` restricted (**dev 0.0**); `use_logits=True,n=0,T=1 == predict` per-clone aggregation (**dev 4e-8**; compared to the *frozen* `X_PROBABILITIES` so it's store-independent); `n=0` bit-identical; `n>0` seeded-reproducible + Dirichlet mean→base; weighting == clone cell counts; `covariate=None` slice == per-covariate call (shared draw); provenance JSON-serializable. Plus `test_reduce` (entropy of uniform == `log2(P)` bits; MI independent==0, coupled==1 bit), gate-aware combine (direct), T≠1 temper, and the subset/`local_scale` guards. Suite **66 passed**. +- **Added:** ✅ `_compute/{_xp,_joint,_reduce}.py` ✅ `tools/{__init__,_joint}.py` ✅ `tcri.joint_distribution` re-export ✅ `tests/test_tools/{test_joint,test_reduce}.py`. +- **Removed (hard bar):** n/a — additive PR; the old engines are deleted in Phase 6 (with the metric migration), per the ledger. +- **Deferred (deliberate scope boundary, logged):** **`groupby`** — raises `NotImplementedError` for now. Its correct semantics (full-space cell/clone restriction, the clone-determined guard, and sharing the draw across groups) are substantial and land with the Phase-6 metric consumers + `_metric_boxplot` (Phase 7) that actually exercise it. `_compute/_reduce` is created but not yet consumed (Phase-6 metrics use it). The `+1e-8`-vs-clamp draw bug is **fixed** in the new engine (`clamp(local_scale·base, 1e-3)`); the old engines keep the bug until they're deleted. +- **Decision baked in (user):** all entropy/MI default to **bits (log2)**, consistent with `_distance`. +## PR 6–9 — Metrics · Plotting · diag · PGM · ✅ done (branch `refactor/pr6-9`, one PR at the end) +_(committed together on a single branch per the `/goal` directive — one multi-commit PR at the end.)_ +- **PR 6 (metric-API consolidation):** the 4 metrics + `compare_groups` rewritten onto the PR5 engine in `tools/` (bits/log2; `normalize_mode='min'` default; support-only/NaN fixes). `groupby` done at the metric level via the engine's `clones=` restriction (**with a clone-disjointness guard** — added after the audit — that raises if a clone spans groups, instead of silently contaminating). `tl` repointed `metrics`→`tools`; 5 metrics onboarded into the contract. **Removed:** `mi_compare`, `delta_*`, `flux_table`, `clonotypic_entropy_base`, `clonality`, `_mi_from_joint` (old), the old `metrics/` package, and the old `preprocessing` `joint_distribution`/`joint_distribution_posterior` engines. +- **PR 7 (plotting):** split the 1437-line `_plotting.py` into `_base`/`_colors`/`_entropy`/`_mutual_information`/`_flux` (explicit `__all__`); the 4 tl↔pl twins are **cache renderers** over the tidy `tl` results (no slice-and-call). `resolve_palette` mutates in place. Dropped the non-core plots + `tcri_boxplot`/`set_color_palette`. +- **PR 8 (diag):** `diagnostics/_ppc.py` (`joint_distribution_ppc`, `phenotype_calibration`, `reconstruction_ppc`, `permutation_null` — all → DataFrame) + `_training.py` (`loss`, `archetypes`, relocated off `TCRIModel`; `plot_loss`/`plot_archetypes` deleted). 6 onboarded into the contract. +- **PR 9 (PGM→docs):** moved `build_nested_tcri_pgm`/`draw_tcri_pgm_nested` out to `docs/model_pgm.py`; dropped `daft` from runtime deps (`import tcri` no longer imports daft). +- **Gate:** full suite **82 passed** (default pytest config) in the pinned venv; contract conformance green (all metrics + diag enforced). +- **Deferred (logged):** engine `groupby` param (metrics use the metric-level path); `_provenance` sidecar + GPU guardrails (PR5); the §7.2–§7.6 api-doc code blocks still lag the frozen `.pyi` (reconcile in the docs pass); the classifier-training fix (its own PR); flux Sankey (twin renders a box for now). +## Model PR — classifier training + methods conformance · ✅ done (branch `model/classifier-fix`) +_(the deferred "classifier-training fix, its own PR" above — plus a full audit of the model vs the Supplementary Methods note.)_ +- **The classifier now trains.** Two coupled bugs: (1) `cls_logits` never entered the ELBO → added `pyro.factor("phenotype_alignment", −γ·KL(probs‖φ))` in `model()` (the note's "Inference Details" surrogate, `γ=phenotype_kl_weight`); (2) the alignment target `φ=p_ct[ct_idx]` was indexed by the **local** pyro plate index → scrambled labels across shuffled minibatches → `f_cls` collapsed to a constant. Fixed by threading **global** cell `indices` through `_get_fn_args_from_batch → model()/guide()`; the `indices=None` path now `assert`s rather than silently falling back. Pure-classifier (gate=1.0) recovery on the perfect dataset: **0.200 (chance) → 1.000**. +- **Note conformance (new `docs/contract/METHODS_CONFORMANCE.md`).** Eq-by-eq code↔note map + deviation table. Fixed alongside: `gate_prob` default `None→0.5` (π), `classifier_dropout` plumbed into `PhenotypeClassifier`, dead `class_weights`/`phenotype_weights` removed (3 signatures), dead `encoder(x)` forward in `model()` removed. +- **Tests:** new `tests/test_model_classifier.py` (perfect-recovery guard at gate 1.0 + 0.5, asserts f_cls weights actually move — with a module-local param-store isolation fixture); round-trip now guards `phenotype_kl_weight`/`gate_prob`/`classifier_dropout`. Full suite **89 passed**. +- **[G] fixed (author-approved):** α (`global_scale`) now applied to the eq-1 clonotype prior (`expanded_conc = global_scale * centroids`), removing the prior/guide scale mismatch; classifier recovery unchanged (1.000), suite green. +- **Deferred:** **[E]** `reconstruction_loss_scale=1e-3` vs eq-7 full weight (over-generation symptom) — author deferred; may be an intentional β-VAE reweighting, and raising it needs a retrain + R/NR revalidation. Tracked as a follow-up investigation. **[F]** in-silico perturbation (eqs 8–12) not implemented (additive). +## PR 10 — Public API + scverse CI · ☐ todo - **Logged test (from grafiti parity):** once `pl.__all__` exists, add a conformance assertion `set(pl.__all__) == {pl entries in _contract.pyi}` — catches *extra/missing* plot functions (whole-surface), not just signature drift on onboarded ones. (tcri's namespaced `.pyi` checks drift incrementally via `IMPLEMENTED`; this closes the whole-surface gap grafiti gets from its markdown+`__all__` channel.) --- @@ -125,6 +213,16 @@ _(diary to be filled — this is a REMOVAL PR; the ledger Phase-2 block must be # AUDIT LOG _(dated entries; what was audited, findings, actions)_ +- **(GOAL RUN — metrics contract · knob test · [E] · legacy-key removal; self-audited):** five ordered items, all landed on `model/contract`. **1) Entropy verification → HALTED as instructed.** The code differs from Supplementary Note 1 eqs 3–4, and the *note* is wrong: both equations weight by the **marginal** while taking the log of the **conditional** (a cross-entropy), and eq 4's left-hand side is mislabelled `H(p(c))` while its right side sums over φ. Proof the code is right: MI must satisfy `I(c;φ)=H(c)−E_φ[H(c|φ)]`; on a test joint with true MI **0.288703** the implemented conditional entropy reproduces it exactly while the literal formula gives **−0.345883** — a negative MI. Author confirmed: keep the code, record the erratum. **2) Metrics contract (separate, by my call).** `_metrics_contract.py` + `METRICS_CONTRACT.md` + 12 identity tests; separate from the model contract because that one is verified by *tracing* `model()`/`guide()` while metrics are pure functions pinned by *numeric identities*. Keystone identity is the decomposition above. Fixed a real wart found en route: `np.where(p>0, p*log2(p), 0)` still evaluates `log2(0)` (numpy computes both branches) — masked before the log. **3) Knob test completed with a new WIRING layer** — 32 tests. The layer exists because `lr` sat marked "hooked up" for months while dead; convergence tests cannot see a silently-ignored knob. **No new dead knobs found**; both initial failures were *test* bugs (scvi installs `LoudEarlyStopping`, not `EarlyStopping`; `predict` differs ~1.2e-07 across batch sizes = float32 kernel paths, not logic). **4) [E] re-measured** now that the phantom optimizer is gone: real-yost library ratio **1.40 at 1e-3 → 0.99 at 1e-2**; recovery (1.000) and latent separation (7.19) unchanged, so the default was raised. The synthetic reads 1.00 everywhere and **could not** have detected this — only the real 1000-gene/87%-dropout data discriminates. Three inconsistent defaults (1e-3/1e-3/1e-2) unified. **5) Deferred Phase-4 legacy keys removed** — the deferral ("still read by metrics/plotting") was stale after PR6/PR7; the last reader was a raw literal in `pp.clone_size`, now on `uns[METADATA][CLONE_COL]` with a real error message. `_ascii_hist` deleted (zero callers). Ledger ticked. **SELF-AUDIT (mutation test of the new contract):** 5 mutations — note's-literal weighting, bits→nats, zero-mass→0, epsilon-clip, MI min→average — **all caught**. Note the first attempt at the note's-literal mutation was a **no-op** (`col[supp].sum() == col.sum()`, so it changed nothing) and appeared to "escape"; re-done with the true marginal it fails correctly. Mutations must be verified to actually change behavior before their result is trusted. Suite **152 passed**; all three conformance tests green (27 / 12 / 12). + +- **(MODEL PR — methods-conformance audit vs Supplementary Note 1 — WORKFLOW, 6 lenses × 2 adversarial verifiers, 53 agents):** **22 findings survived adversarial verification, 1 refuted.** Verdict: the classifier fix (ELBO factor + global-index alignment target) is **correct and faithful to the note's surrogate**. Caught a real **HIGH the local run missed**: the new `test_model_classifier.py` leaked the process-global Pyro param store → the *full* suite was RED (1 failed / 88 passed) though the file passed in isolation — fixed with a **module-local** autouse `clear_param_store` fixture (a conftest autouse would wipe the session-scoped `trained_model`). **Fixed now:** dead `class_weights`/`phenotype_weights` removed ([D]); `indices=None` silent fallback → `assert` (re-hardens A2); dead `encoder(x)` forward in `model()` removed; `gate_prob: Optional[float]`; surrogate-KL **sign** clarified in the doc (code's `−γ·KL` realizes the note's `+γ` *penalty* intent under SVI-maximization); KL-warmup z-only scope + `num_particles` enumeration-only scope documented; round-trip now guards the new scalars. Suite **89 passed**. **Confirmed CONFORMANT:** ZINB (eq 5), β (eq 2), VampPrior (eq 3), gated ℓ rule in `predict`. **Deferred (author sign-off, change fitted results):** [E] `reconstruction_loss_scale=1e-3` vs eq-7 full weight; [G] α not applied to the eq-1 clonotype prior (prior/guide scale mismatch). [F] perturbation (eqs 8–12) additive/not implemented. - **(PR0 ✅):** agenda + removal ledger established; standing-audit checklist defined. Contract frozen (27 fns) + conformance guardrail live. Full suite 26 passed / 1 skipped, zero regressions. - **(PR1 ◐):** shared-helper foundation created (`_keys`/`_console`/`_stats`/`_distance`) + 8 unit tests. Caught & fixed an `hdi` off-by-one before it shipped. **Adoption pending** (dedup, stats-move, `K.*` migration) — no ledger items ticked yet; foundation is additive, suite green. Logged: key-literal test (PR1), `pl.__all__` whole-surface test (PR11). - **(PR0+PR1 multi-agent audit — 3 lenses):** verdict FIX. Caught a real regression — the `K.*` find/replace over-reached into **10** display/warning/docstring strings (`register_model`/`load_tcri_session` printed `"K.X_LOGITS"` etc.). **Fixed:** restored readable key text in all 10 (AST-span, delimiter-safe); made the key-literal guard **AST-based** (checks real subscripts/`.get`, ignores prose); removed 3 dead `utils` imports the audit flagged. Suite 35 passed. Two non-blocking items deferred to `REFACTOR_NOTES` (contract↔api-doc reconciliation; helper-name canonicalization) — noted in the PR body. +- **(PR2 multi-agent audit — 3 lenses):** PASS on all three (doc↔code · deletion safety · plan/contract). Independently re-derived: all 14 deletions have zero call-sites, all on-plan Phase-2/DROP, none in `_contract.pyi`. 3 LOW items fixed before push (orphaned `cosine_similarity` import; a −129→−127 count; a stale plan line calling `classify_phenotypes` a Phase-4 fold). +- **(PR7–9 + live-plan audit — WORKFLOW, 2 lenses × adversarial verify, 10 agents):** **8 confirmed (all MED/LOW).** Fixed: **the `pl.*` surface is now contract-enforced** (reconciled `_contract.pyi` `resolve_palette` + onboarded all 5 pl.* into `IMPLEMENTED`); **`reconstruction_ppc` now mirrors the module's generative distribution exactly** (`module.eps` + `nb_logits` clamp `[-10,10]`) so the PPC samples from what the model defines; api-doc §9.1 param names reconciled; the live plan now states its **underpowered-subset caveat** and uses an explicit `pre→post` flux direction. **Deferred (LOW):** `joint_distribution_ppc` per-covariate aggregate lives in `df.attrs`; a few surface pieces (`save/load` round-trip, `group_singletons`) not hit by the live plan (covered by CI tests). Suite **87 passed**. +- **(LIVE R/NR TEST — real yost data, `dev/live_test_rnr.py`):** **16/16 steps OK** on a 2259-cell / 76-clone / 10-patient subset (R+NR, pre+post, 6 clusters). Train → `to_anndata` → all 6 `diag` → all 4 metrics + `compare_groups` (R vs NR) → all 4 `pl` twins (figures render). Only failure was a **test-harness** pandas bug (`reset_index`), fixed. Diagnostics surfaced two genuine **model-quality** findings (ZINB decoder over-generates counts ~6×; classifier untrained → calibration reflects the prior) — both already tracked as model debt, **not refactor defects**. Detail in `REFACTOR_NOTES`. +- **(PR6 audit — WORKFLOW, 2 lenses × adversarial verify, 12 agents):** **7 confirmed (2 MED correctness/infra, 5 doc/schema).** Fixed: the metric `groupby` now **validates clone-disjointness** and raises on a clone spanning groups (was silent cross-group contamination) + `synthetic_adata` clones made patient-disjoint; test `__init__.py` added (prepend-mode basename collision); `groupby` on a precomputed joint raises the §7.9 `ValueError`; `compare_groups` unified column schema (keeps `p_lt`); added `n_clones_ref` to `clonotypic_entropy` (+ contract). Remaining doc-only: the §7.2–§7.6 code blocks lag the frozen `.pyi` (reconcile in the docs pass). PR7–9 audit runs next (combined). +- **(PR5 audit — WORKFLOW, 3 lenses × adversarial verify, 14 agents; correctness lens math-focused):** **11 findings confirmed, 0 refuted — 2 MED, 9 LOW.** Both MED were real safety gaps, **fixed here:** (C4) restored the subset/filtered-AnnData length guard the old engine had — a sliced AnnData now errors instead of silently misaligning full-space `uns` vs subset `obsm`; (C8) `n_samples>0` now **raises** on a missing `uns[LOCAL_SCALE]` instead of silently defaulting to 1.0. Also fixed: made `clones=` ordering consistent across the single- and MultiIndex paths, and added the missing tests — the **gate-aware combine** (direct `_joint_draws` unit test; the fixture is gate=None), the T≠1 temper, and both new guards (66 passed). Reconciled stale api-doc §4.1/§4.2 (`get_xp`→`torch_device`; the real `_joint_draws(decomposed args)->(blocks,n_draws)`). **Deferred (LOW, logged):** the `_provenance` sidecar column (§7.7, with the Phase-6 cache) and GPU guardrails 5/7/8 (§7.4, with the GPU path). The correctness lens confirmed the core math (exact `P_CT` identity, `predict` parity dev 4e-8, clamped-Dirichlet draw, ct-keyed weighting, shared-draw invariant, bits/log2). +- **(PR4 audit — WORKFLOW, 4 lenses × adversarial verify, 23 agents):** the standard three (doc↔code · correctness · plan/contract) **plus a dedicated knob-test-plan lens**. **19 findings confirmed, 0 refuted — all LOW/MED, zero HIGH.** The correctness lens independently **confirmed the streamline is behavior-preserving** (44 passed) and reproduced both dead-knob findings (classifier untrained; `class_weights` dead). **Fixed here:** requirements count (36→44), a value-equality assertion for `LOCAL_SCALE`/`GATE_PROB`/`CLASSIFIER_TEMPERATURE` in the round-trip, `pyro.clear_param_store()` in the `trained_model` fixture, api-doc `to_anndata` signature reconciled to the contract, and **7 knob-matrix corrections** — the Dirichlet variance is `∝ 1/(scale+1)` (not `1/scale`); `classifier_dropout` split out as a *separate* not-plumbed knob; gate=1 *formula* is testable now (only recovery blocked); `guide_temperature` needs a post-train test; scale-variance tests co-located; `kl_weight_max`/`reconstruction_loss_scale` and `use_enumeration` justifications sharpened. **Deferred (LOW/MED, logged in `REFACTOR_NOTES`):** the `group_singletons`-ordering guard, `predict` order hardening, round-trip exclusivity (Phase 6/7), and the stale `build_tcri_depgraph.py`. +- **(PR3 audit — WORKFLOW, 3 lenses × adversarial verify, 8 agents):** behavior + doc-code lenses PASS, plan-contract FIX. **5 findings, all confirmed, all LOW/MED — zero behavior/correctness defect.** Behavior lens verified class bodies are byte-identical to the pre-split monolith (modulo the sanctioned rename), zero F821 undefined-names across all 5 files (every import header complete), and suite/smoke green. **Fixed:** the MED — explicit `__all__` per module (plan §Phase 3, line 279) was omitted → added to all 5 files (also resolves the two LOW surface-wording findings; surface now pinned to `{TCRIModel}`). **Deferred (auditor-recommended):** stale `c2p_mat` in the contract *generator* → Phase 8 with `diag.archetypes`. Suite 36 passed / 1 skipped. diff --git a/docs/contract/tcri_api_and_responsibilities.md b/docs/contract/tcri_api_and_responsibilities.md index 1146bab..a4333aa 100644 --- a/docs/contract/tcri_api_and_responsibilities.md +++ b/docs/contract/tcri_api_and_responsibilities.md @@ -139,8 +139,8 @@ tcri/ _stats.py # stars, AUROC+permutation, bootstrap, MWU, prob_direction, hdi, summarize _distance.py # kl_divergence, l1_distance, js_divergence, phenotype_distance dispatcher _compute/ # NEW private numeric+device seam (grafiti-mirrored) - _xp.py # resolve_device, get_xp, asnumpy (torch-first, cupy optional, CPU default) - _joint.py # _joint_draws(...) -> ndarray[n_samples, n_clones, P] (scatter-add core) + _xp.py # resolve_device, torch_device, asnumpy (torch-first, cupy optional later, CPU default) + _joint.py # _joint_draws(p_ct, ct_to_cov, ct_to_c, ct_array, cov_array, *, ...) -> (blocks, n_draws) _reduce.py # batched entropy / mutual-information / distance reductions over the stack model/ # ml _model.py # TCRIModel @@ -289,12 +289,12 @@ The engine's numeric core is written **once** as a batched, device-routable func | Signature | Responsibility | |---|---| | `resolve_device(device)` | `None`/`"cpu"`→`"cpu"`; `"mps"`→`"cpu"`; `"cuda"`/`"gpu"`/`"auto"`→GPU **iff** the backend imports AND a device is present (`getDeviceCount()>0`), else CPU. Explicit `"cuda"` warns on fallback; `"auto"`/`"gpu"` silent; unknown warns. | -| `get_xp(device)` | Return the array module — torch(-cuda) preferred (already a hard dep → zero new deps), cupy optional, numpy default. GPU libs imported **lazily inside** the function. | +| `torch_device(device)` | Return the resolved `torch.device` (`resolve_device` maps the ladder to cpu/cuda). torch-first (already a hard dep → zero new deps); cupy optional later. GPU libs imported **lazily inside** the function. | | `asnumpy(x)` | Host-boundary shim: `cupy.asnumpy(x)` / `x.cpu().numpy()` / `np.asarray(x)`. Every accelerated function returns a plain numpy array. | ### 4.2 `_joint.py` / `_reduce.py` — the batched core -- **`_joint_draws(adata, *, covariate, clones, n_samples, use_logits, temperature, gate_prob, random_state, device) -> np.ndarray`** — returns the `[max(n_samples,1), n_clones, P]` joint stack. Precomputes clone integer codes **once**; draws all `n_samples` Dirichlet samples in one batched kernel from `clamp(s·m̃, 1e-3)`; softmaxes the (optionally gated) per-cell combination batched on the leading axis; reduces per clone with a **constant-index scatter-add** (`np.add.at` / `torch.index_add_` / `cupy.bincount`) instead of a per-draw `pandas.groupby` — the dominant win. Validates finiteness / nonnegativity / per-row sum $\approx1$ **on device** before returning; `float64` accumulators for CPU/GPU parity; `asnumpy` at the boundary; chunked over cells/draws to bound device memory. +- **`_joint_draws(p_ct, ct_to_cov, ct_to_c, ct_array, cov_array, *, local_scale, n_samples, temperature, use_logits, covariate_idx, logits, gate_prob, weighted, random_state, device) -> (blocks, n_draws)`** — the adata-unpacking lives in the `tools/_joint` wrapper; this core takes **decomposed uns arrays** and returns a **list of per-covariate `(cov_idx, clone_idx, J[S, n_rows, P])` blocks** plus the draw count (`covariate=None` stacks variable-length per-covariate blocks). Draws all `n_samples` Dirichlet samples over **all ct rows in one batched kernel** from `clamp(s·m̃, 1e-3)` (the shared-draw invariant), then slices per covariate; softmaxes the (optionally gated) per-cell combination batched on the leading axis; reduces per clone with a **scatter-add** (`torch.index_add_`) instead of a per-draw `pandas.groupby` — the dominant win. `float64` accumulators for CPU/GPU parity; `asnumpy` at the boundary. **Phase-6 (with the GPU path / `_reduce` wiring):** on-device per-row-sum $\approx1$ validation + chunking over cells/draws (§7.4 guardrails 5/7/8). - **`_reduce.py`** — batched `entropy`, `mutual_information`, `distance` as `xlogx`/outer-product reductions over the whole stack (no per-draw scipy call, no per-clone `.loc`), plus the `summarize`/`hdi` reduction over the sample axis. ### 4.3 GPU guardrails (replicated uniformly from grafiti) @@ -317,7 +317,7 @@ Lazy GPU imports (never at module top — `import tcri` never touches a GPU lib; | `get_latent_representation(self, adata=None, *, indices=None, batch_size=None) -> np.ndarray` | Batched encode to the `(n_cells, n_latent)` posterior-mean latent. | | `predict(self, adata=None, *, batch_size=256, eps=1e-8) -> pd.DataFrame` | **(renamed from `get_cell_phenotype_probs`)** Per-cell phenotype-probability `DataFrame` (index = `adata.obs_names`, columns = phenotypes). Combines classifier logits with $\log p_{ct}$ (gate or additive), matching training (scvi/CellAssign idiom). **Reference the `use_logits=True` joint must reproduce at $T=1$** (§0.9, §7.1). Uses an **order-preserving loader** (shuffle=False / sequential sampler) and the registered `indices` field so ct-lookup and barcode labels cannot drift. | | `get_p_ct(self, *, guide_temperature=1.0) -> np.ndarray` | Return the learned `(ct_count, P)` posterior mean $m=\text{normalize}(q\_p\_ct\_raw)$. At the default `guide_temperature=1.0` this equals `uns[K.P_CT]` exactly. | -| `to_anndata(self, adata=None, *, latent_key="X_tcri", logits_key="X_tcri_logits", predictions_key="X_tcri_probabilities", label_key="tcri_phenotype") -> AnnData` | **(replaces the heavy `register_model`)** Thin writer of the **canonical minimum**: metadata + categories (from registry); `X_tcri` latent; **`obsm[K.X_LOGITS]` per-cell logits** (restored — the `use_logits=True` engine path hard-requires them); `predict()` probs + argmax hard labels; `p_ct` (+ `ct_to_cov`, `ct_to_c`, per-cell ct/cov arrays); **`local_scale`**, **`gate_prob`**, **`classifier_temperature`**. No manager stash; no other writes. | +| `to_anndata(self, adata=None, *, batch_size=256, compute_umap=False) -> AnnData` | **(replaces the heavy `register_model`; signature matches the frozen `_contract.pyi` as of PR4 — the canonical key names come from `_keys`, NOT per-call arguments)** Thin writer of the **canonical minimum**: metadata + categories (from registry); `X_tcri` latent; **`obsm[K.X_LOGITS]` per-cell logits** (restored — the `use_logits=True` engine path hard-requires them); `predict()` probs + argmax hard labels; `p_ct` (+ `ct_to_cov`, `ct_to_c`, per-cell ct/cov arrays); **`local_scale`**, **`gate_prob`**, **`classifier_temperature`**. No manager stash; no other writes. | > Relocated off the model: `plot_archetypes`→`diag.archetypes`; `plot_loss`→`diag.loss`. `boost_phenotype_prior`, `use_gate` remain internal. @@ -392,9 +392,10 @@ Called only by `TCRIModel.to_anndata`; folds in the old `register_phenotype_key` joint_distribution( adata, *, covariate=None, # None → ALL covariate values in one pass (shared draw) - groupby=None, + groupby=None, # NOTE: deferred to Phase 6 in code (raises NotImplementedError until then) n_samples=0, use_logits=True, # was posterior=; alias cell_informed=; classifier-mixing switch + weighted=False, # per-clonotype (False) vs cell-weighted (True); ct-keyed clones=None, temperature=1.0, random_state=None, @@ -716,8 +717,8 @@ Read-only checks on the finalized model. PPCs return `DataFrame`s; the two reloc |---|---| | `joint_distribution_ppc(adata, *, covariate=None, distance_metric="l1", temperature=1.0) -> pandas.DataFrame` | **(fixed `compare_joint_distribution`)** Model vs empirical per-clone phenotype frequencies. $P_{\text{model}}(\phi\mid c,m)=\texttt{joint\_distribution}(adata, covariate=m)[c]$; $P_{\text{emp}}(\phi\mid c,m)=\frac{\#\{i\in c,m:\text{pheno}_i=\phi\}}{\#\{i\in c,m\}}$; per-clone $\delta_c=\text{L1}$ or $\text{KL}(P_{\text{emp}}\Vert P_{\text{model}})$, plus per-covariate aggregate. **Model-free (adata only).** **Bug fix:** reads `clonotype_col`/`phenotype_col` from `uns[K.METADATA]` instead of the undefined global `model` (repairs the `NameError`). | | `phenotype_calibration(adata, *, n_bins=10) -> pandas.DataFrame` | Reliability of `predict()` probabilities: bin cells by predicted max-prob; per bin compare mean predicted prob to empirical accuracy; $\text{ECE}=\sum_b\frac{n_b}{N}|\text{acc}_b-\text{conf}_b|$. **adata only.** Returns `(bin, mean_pred, emp_freq, count)` + scalar `ECE`. | -| `reconstruction_ppc(model, adata=None, *, n_samples=100, seed=0) -> pandas.DataFrame` | ZINB reconstruction PPC: simulate from the fitted decoder ($\mu,\theta,\pi_{\text{dropout}}$), compare library size / per-gene dropout / mean–variance vs observed. **`model` REQUIRED** (live decoder lives on the module, not in `adata`). Returns statistic × {observed, simulated, discrepancy}. | -| `permutation_null(adata, *, metric="mutual_information", covariate=None, groupby=None, n_permutations=1000, seed=0) -> pandas.DataFrame` | Permute phenotype labels within each covariate $R$ times, recompute the metric to form a null; $p=\text{mean}(\text{null}\ge\text{obs})$, $z=\frac{\text{obs}-\overline{\text{null}}}{\text{sd(null)}}$. **adata only.** One shared draw stack (§7.8). Returns per stratum: `observed, null_mean, null_sd, z, p`. | +| `reconstruction_ppc(model, adata=None, *, n_sims=100, random_state=0) -> pandas.DataFrame` | ZINB reconstruction PPC: simulate from the fitted decoder ($\mu,\theta,\pi_{\text{dropout}}$), compare library size / per-gene dropout / mean–variance vs observed. **`model` REQUIRED** (live decoder lives on the module, not in `adata`). Returns statistic × {observed, simulated, discrepancy}. | +| `permutation_null(adata, *, metric="mutual_information", covariate=None, groupby=None, n_perm=1000, random_state=None) -> pandas.DataFrame` | Permute phenotype labels within each covariate $R$ times, recompute the metric to form a null; $p=\text{mean}(\text{null}\ge\text{obs})$, $z=\frac{\text{obs}-\overline{\text{null}}}{\text{sd(null)}}$. **adata only.** One shared draw stack (§7.8). Returns per stratum: `observed, null_mean, null_sd, z, p`. | `__all__ = ["joint_distribution_ppc", "phenotype_calibration", "reconstruction_ppc", "permutation_null"]` diff --git a/docs/contract/tcri_implementation_plan.md b/docs/contract/tcri_implementation_plan.md index cfc7747..12cdc12 100644 --- a/docs/contract/tcri_implementation_plan.md +++ b/docs/contract/tcri_implementation_plan.md @@ -117,7 +117,8 @@ Freeze the map **before** Phase 5 (the first breaking PR). Renames are breaking; | `mi_compare`, `delta_entropy_table`, `flux_table`, `delta_clonotypic_entropy`, `phenotypic_entropy_delta` | **deleted** — expressed via `groupby=` + `tl.compare_groups` | Phase 6 | | `tl.phenotypic_entropies` / `tl.clonotypic_entropies` (plural batch forms) | **deleted** — subsumed by `groupby=` on the singular metric | Phase 6 | | `get_cell_phenotype_probs` | `predict` (scvi/CellAssign idiom; order‑preserving loader, indexed by `obs_names`) | Phase 4 | -| `register_model` (+ `classify_phenotypes`, `register_*_key`) | `model.to_anndata` (thin) | Phase 4 | +| `register_model` (+ `register_*_key`) | `model.to_anndata` (thin) | Phase 4 | +| `classify_phenotypes` | — (DROP; superseded by `REDO_LIST.md`) | Phase 2 ✅ deleted | | `register_clonotype_key` / `register_phenotype_key` | folded (private) into `to_anndata` | Phase 4 | | `pl.clonotypic_entropy_by_phenotype` | `pl.clonotypic_entropy` | Phase 7 | | `plot_pheno_sankey` | `pl.phenotypic_flux` (sankey) | Phase 7 | diff --git a/docs/model_pgm.py b/docs/model_pgm.py new file mode 100644 index 0000000..493ac37 --- /dev/null +++ b/docs/model_pgm.py @@ -0,0 +1,176 @@ +"""Model plate-diagram (PGM) generators — moved OUT of the tcri package (PR9, §9). +Requires `daft` (a docs-only extra, NOT a tcri runtime dependency).""" +import daft +import matplotlib.pyplot as plt +import numpy as np + + +def build_nested_tcri_pgm(): + """ + A fully explicit TCRI PGM matching the implementation in _model.py + with improved layout to minimize edge crossings + """ + # Define colors + red, yellow, green, gray, blue = "#cd442a", "#f0bd00", "#7e9437", "#eee", "#009de1" + + # Create a PGM canvas + pgm = daft.PGM( + shape=[8, 8], # width x height + origin=[0, 0], + grid_unit=1.6, + node_unit=1.5 + ) + + # ------------------------------------------------------------------ + # 1) Global hyperparameters for Dirichlet priors + # ------------------------------------------------------------------ + pgm.add_node( + "global_scale", + r"$\mathrm{global\_scale}$", + 5.1, # x + 6.6, # y + fixed=True, + plot_params={"fc": "#DDD"} + ) + + # ------------------------------------------------------------------ + # 2) Plate: batch (b) - outermost plate + # ------------------------------------------------------------------ + + # Batch-level variables - aligned vertically + # ------------------------------------------------------------------ + # 3) Plate: clonotypes (c) - middle plate + # ------------------------------------------------------------------ + pgm.add_plate( + [1.0, 1.0, 6.0, 6.3], # [x, y, width, height] + label=r"clonotypes $(c)$", + shift=-0.1 + ) + + # p_c (Dirichlet) + pgm.add_node( + "p_c", + r"$p_c$", + 3.8, # x + 6.6, # y + observed=False, + plot_params={"fc": blue} + ) + + # Edge: global_scale -> p_c + pgm.add_edge("global_scale", "p_c") + + # ------------------------------------------------------------------ + # 4) Plate: clone-covariate (ct) - inner plate + # ------------------------------------------------------------------ + pgm.add_plate( + [1.5, 1.5, 5.0, 4.5], # [x, y, width, height] + label=r"clone-covariate $(ct)$", + shift=-0.1 + ) + + # local_scale moved inside clone-covariate plate + pgm.add_node( + "local_scale", + r"$\mathrm{local\_scale}$", + 5.2, # x + 5.3, # y + fixed=True, + plot_params={"fc": "#DDD"} + ) + + # p_ct (Dirichlet) + pgm.add_node( + "p_ct", + r"$p_{ct}$", + 3.8, # x + 5.3, # y + observed=False, + plot_params={"fc": yellow} + ) + + # Edges: p_c -> p_ct, local_scale -> p_ct + pgm.add_edge("p_c", "p_ct") + pgm.add_edge("local_scale", "p_ct") + + # ------------------------------------------------------------------ + # 5) Plate: data (i) - innermost plate + # ------------------------------------------------------------------ + pgm.add_plate( + [1.9, 2., 4.0, 2.7], # [x, y, width, height] + label=r"data $(i)$", + shift=-0.1 + ) + + # Grid layout for data-level variables - aligned vertically + # Column 1: Observed variables + pgm.add_node( + "obs", + r"$X_{i}$", + 5, # x + 4.0, # y + observed=True, + plot_params={"fc": gray} + ) + + pgm.add_node( + "obs_label", + r"$Pheno_{i}$", + 2.5, # x + 4.0, # y + observed=True, + plot_params={"fc": gray} + ) + + # Column 2: Latent variables + pgm.add_node( + "latent", + r"$z_i$", + 3.8, # x + 2.8, # y + observed=False, + plot_params={"fc": green} + ) + + pgm.add_node( + "z_i_phen", + r"$z_{i,\mathrm{phen}}$", + 3.8, # x + 4.0, # y + observed=False, + plot_params={"fc": red} + ) + + # Column 3: Decoder inputs + pgm.add_node( + "px_r", + r"$ZINB(X_{i})$", + 5, # x + 2.8, # y + observed=False, + plot_params={"fc": "#DDD"} + ) + + # Edges - now mostly vertical and horizontal + # Data-level edges + pgm.add_edge("p_ct", "z_i_phen") + pgm.add_edge("latent", "z_i_phen") + pgm.add_edge("z_i_phen", "obs_label") + + # Direct connections to z_i (previously through decoder) + pgm.add_edge("latent", "obs") + pgm.add_edge("px_r", "obs") + + # ------------------------------------------------------------------ + # Text / Title + # ------------------------------------------------------------------ + pgm.add_text(3.1,7.5, "TCRi Model", fontsize=14) + + return pgm + + +def draw_tcri_pgm_nested(): + pgm = build_nested_tcri_pgm() + pgm.render() + pgm.figure.savefig("tcri_model_fully_explicit.pdf", dpi=300) + plt.show() diff --git a/pyproject.toml b/pyproject.toml index e7fda8d..54c60dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ dependencies = [ "seaborn>=0.13.2", "mpltern>=1.0.4", "gseapy>=1.1.4", - "daft", "tqdm>=4.66.5", ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..154b8ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# Verified-green dependency lock for tcri (scverse ecosystem). +# Generated + tested in a fresh Python 3.12 venv; `pytest` = 44 passed / 0 skipped. +# pyproject.toml holds the compatible ranges; this file pins the tested-recent set. +# Regenerate by creating a fresh venv and `pip install -e ".[test]"`. + +numpy==2.4.6 +pandas==3.0.3 +scipy==1.18.0 +scikit-learn==1.9.0 +anndata==0.13.1 +scanpy==1.12.2 +torch==2.13.0 +pyro-ppl==1.9.1 +scvi-tools==1.5.0.post1 +umap-learn==0.5.12 +matplotlib==3.10.9 +seaborn==0.13.2 +mpltern==1.0.5 +gseapy==1.3.0 +daft==0.6.14 +tqdm==4.68.4 +pytest==9.1.1 +pytest-cov==7.1.0 diff --git a/tcri/__init__.py b/tcri/__init__.py index d42c7a8..1131424 100644 --- a/tcri/__init__.py +++ b/tcri/__init__.py @@ -5,12 +5,16 @@ except PackageNotFoundError: # running from a source tree without an install __version__ = "0.0.0+unknown" -from . import metrics as tl +from . import tools as tl # PR6: tl repointed metrics -> tools (engine-backed metrics) from . import preprocessing as pp from . import plotting as pl from . import utils as ut from . import model as ml +from . import diagnostics as diag # PR8 + +# The unified engine, re-exported top-level for prominence. +from .tools import joint_distribution import sys -sys.modules.update({f'{__name__}.{m}': globals()[m] for m in ['tl', 'pp', 'pl', 'ut', 'ml']}) \ No newline at end of file +sys.modules.update({f'{__name__}.{m}': globals()[m] for m in ['tl', 'pp', 'pl', 'ut', 'ml', 'diag']}) \ No newline at end of file diff --git a/tcri/_compute/__init__.py b/tcri/_compute/__init__.py new file mode 100644 index 0000000..3fd57d7 --- /dev/null +++ b/tcri/_compute/__init__.py @@ -0,0 +1,8 @@ +"""Private numeric + device seam for the engine (grafiti ``_compute`` parity). + +- :mod:`._xp` — device dispatch (torch-first; CPU / torch-CUDA), ``asnumpy`` boundary. +- :mod:`._joint` — ``_joint_draws``: the batched ``[S, n_clones, P]`` engine core. +- :mod:`._reduce` — batched entropy / mutual-information reductions (**bits / log2** default). + +Nothing here is public API; GPU libs are imported lazily inside functions. +""" diff --git a/tcri/_compute/_joint.py b/tcri/_compute/_joint.py new file mode 100644 index 0000000..be1c663 --- /dev/null +++ b/tcri/_compute/_joint.py @@ -0,0 +1,165 @@ +"""Batched engine core — the clone×phenotype joint as a ``[S, n_clones, P]`` stack, +device-routed via :mod:`._xp` (§7.2). ``tools/joint_distribution`` is a thin +DataFrame wrapper over :func:`_joint_draws`. + +Design invariants (§7.1/§7.8): +- Temperature-temper the base **once**: ``T==1`` is the exact identity (``base = p_ct``, + no ``eps`` round-trip), else ``base = softmax(log(p_ct+1e-8)/T)``. +- Draws are made over **all ct rows at once** (a single seeded ``Dirichlet.sample((N,))``), + then sliced per covariate — so the draw count == ``n_samples`` regardless of how many + covariates/groups are requested (the draw-once invariant). +- ``n_samples=0`` is deterministic (base only; no Monte-Carlo, ``random_state`` ignored). +- ``use_logits=True`` folds per-cell logits with ``log(base)`` (gate-aware) exactly like + ``predict()`` and scatter-adds per clone; ``use_logits=False`` returns the ct-level base. +- ``weighted`` scales each clone row by its **ct-keyed** cell count (the fix for the old + clone-indexed ``Counter`` bug); ``weighted=False`` row-normalizes to a per-clone simplex. +""" +from __future__ import annotations + +import contextlib + +import numpy as np + +from . import _xp + +_EPS = 1e-8 + + +@contextlib.contextmanager +def _torch_seed(random_state): + """Seed the global torch RNG from ``random_state`` for the duration, then restore + it (so the draw is reproducible without a permanent global side effect). ``None`` + leaves the RNG untouched (non-reproducible).""" + import torch + + if random_state is None: + yield + return + if isinstance(random_state, torch.Generator): + seed = int(random_state.initial_seed()) + elif isinstance(random_state, (int, np.integer)): + seed = int(random_state) + elif hasattr(random_state, "integers"): # numpy Generator + seed = int(random_state.integers(0, 2**31 - 1)) + else: + seed = int(np.random.default_rng(random_state).integers(0, 2**31 - 1)) + prev = torch.random.get_rng_state() + torch.manual_seed(seed) + try: + yield + finally: + torch.random.set_rng_state(prev) + + +def _joint_draws( + p_ct, + ct_to_cov, + ct_to_c, + ct_array, + cov_array, + *, + local_scale, + n_samples=0, + temperature=1.0, + use_logits=True, + covariate_idx=None, + logits=None, + gate_prob=None, + weighted=False, + random_state=None, + device=None, +): + """Compute the joint stack per covariate. + + Returns ``(blocks, n_draws)`` where ``blocks`` is a list of + ``(cov_idx, clone_idx[n_rows], J[S, n_rows, P])`` (host numpy, float64) — one block + per covariate processed (all covariates when ``covariate_idx is None``) — and + ``n_draws`` is the number of Dirichlet draws performed (``== n_samples``, or ``0``). + """ + import torch + + dev = _xp.torch_device(device) + p_ct_t = torch.as_tensor(np.asarray(p_ct), dtype=torch.float64, device=dev) # [n_ct, P] + ct_to_cov_t = torch.as_tensor(np.asarray(ct_to_cov), dtype=torch.long, device=dev) + ct_to_c_t = torch.as_tensor(np.asarray(ct_to_c), dtype=torch.long, device=dev) + ct_arr_t = torch.as_tensor(np.asarray(ct_array), dtype=torch.long, device=dev) + cov_arr_t = torch.as_tensor(np.asarray(cov_array), dtype=torch.long, device=dev) + n_ct, P = p_ct_t.shape + + # sanitize any non-finite p_ct rows to uniform (matches the legacy guard) + bad = ~torch.isfinite(p_ct_t) + if bad.any(): + p_ct_t = torch.where(bad, torch.full_like(p_ct_t, 1.0 / P), p_ct_t) + + # temper the base once (T==1 == exact identity, no eps round-trip) + if float(temperature) == 1.0: + base = p_ct_t + else: + base = torch.softmax(torch.log(p_ct_t + _EPS) / float(temperature), dim=-1) + + # draw over ALL ct rows once (shared-draw invariant), seeded + if n_samples and int(n_samples) > 0: + conc = torch.clamp(float(local_scale) * base, min=1e-3) + with _torch_seed(random_state): + bases = torch.distributions.Dirichlet(conc).sample((int(n_samples),)) # [N, n_ct, P] + n_draws = int(n_samples) + S = int(n_samples) + else: + bases = base.unsqueeze(0) # [1, n_ct, P] + n_draws = 0 + S = 1 + + if use_logits: + logits_t = torch.as_tensor(np.asarray(logits), dtype=torch.float64, device=dev) # [n_obs, P] + use_gate = gate_prob is not None and not (isinstance(gate_prob, float) and np.isnan(gate_prob)) + g = float(gate_prob) if use_gate else None + + covs = ([int(covariate_idx)] if covariate_idx is not None + else sorted(int(c) for c in torch.unique(ct_to_cov_t).tolist())) + + blocks = [] + for m in covs: + ct_rows = torch.nonzero(ct_to_cov_t == m, as_tuple=True)[0] # ct indices at cov m + clones_m = ct_to_c_t[ct_rows] # clone id per ct row + # ct-keyed cell counts at this covariate (the weighting fix) + counts_per_ct = torch.bincount( + ct_arr_t[cov_arr_t == m], minlength=n_ct + ).to(torch.float64)[ct_rows] # [n_ct_m] + + if not use_logits: + J = bases[:, ct_rows, :].clone() # [S, n_ct_m, P] — each ct row is a clone + if weighted: + J = J * counts_per_ct[None, :, None] + else: + cells_m = torch.nonzero(cov_arr_t == m, as_tuple=True)[0] + ct_of_cell = ct_arr_t[cells_m] # global ct per cell + lut = torch.full((n_ct,), -1, dtype=torch.long, device=dev) + lut[ct_rows] = torch.arange(ct_rows.shape[0], device=dev) + local_ct = lut[ct_of_cell] # [n_cells_m] local clone-row index + ell = logits_t[cells_m].unsqueeze(0) # [1, n_cells_m, P] + J = torch.zeros((S, ct_rows.shape[0], P), dtype=torch.float64, device=dev) + # Chunk over draws. The per-cell chain below needs four live + # [S, n_cells_m, P] float64 tensors at once (~2.9 GB each at S=500, + # 60k cells, P=12 — 11 GB measured). Draws are independent, so chunking + # is bit-identical and caps peak memory at a few hundred MB. + n_cells_m = cells_m.shape[0] + per_draw = max(1, n_cells_m * P) + chunk = max(1, min(S, int(8_000_000 // per_draw))) # ~64 MB/temp in float64 + for s0 in range(0, S, chunk): + s1 = min(s0 + chunk, S) + b_cell = bases[s0:s1][:, ct_of_cell, :] # [c, n_cells_m, P] + log_b = torch.log(b_cell + _EPS) + combine = (g * ell + (1.0 - g) * log_b) if use_gate else (ell + log_b) + # §7.1: the base is already tempered (b) and the combine is divided by T here; + # at T==1 both are the identity, so this reproduces predict() bit-for-bit. T!=1 is + # a deliberate analysis-time temper (the two T's do not cancel — documented). + p_cell = torch.softmax(combine / float(temperature), dim=-1) + Jc = torch.zeros((s1 - s0, ct_rows.shape[0], P), dtype=torch.float64, device=dev) + Jc.index_add_(1, local_ct, p_cell) # sum P over cells per clone (row sum == count) + J[s0:s1] = Jc + if not weighted: + J = J / J.sum(-1, keepdim=True).clamp_min(_EPS) + + blocks.append((m, _xp.asnumpy(clones_m), _xp.asnumpy(J))) + + return blocks, n_draws diff --git a/tcri/_compute/_reduce.py b/tcri/_compute/_reduce.py new file mode 100644 index 0000000..16bd6e6 --- /dev/null +++ b/tcri/_compute/_reduce.py @@ -0,0 +1,56 @@ +"""Batched information-theoretic reductions over the joint stack ``[S, n_clones, P]``. + +**All quantities default to BITS (log2)** — the tcri convention (matches +:mod:`tcri._distance`); pass ``base=None`` for nats or ``base=b`` for an arbitrary +base. Reductions run in **float64** accumulators (§7.4 guardrail 6) so a future GPU +path matches CPU, and use the ``0·log0 := 0`` convention. Grafiti `joint.py::_entropy/_mi` +parity, generalized to batch over the leading sample/covariate/group axes. +""" +from __future__ import annotations + +import numpy as np + +_EPS = 1e-12 + + +def _logb(x, base): + if base == 2: + return np.log2(x) + if base is None: # nats + return np.log(x) + return np.log(x) / np.log(base) + + +def entropy(p, *, axis=-1, base=2): + """Shannon entropy of a distribution along ``axis`` (bits by default), batched + over every other axis. ``0·log0 := 0``; ``p`` is used as-is (assumed normalized).""" + p = np.asarray(p, dtype=np.float64) + with np.errstate(divide="ignore", invalid="ignore"): + terms = np.where(p > 0, p * _logb(p, base), 0.0) + return -np.sum(terms, axis=axis) + + +def mutual_information(joint, *, base=2): + """MI(X;Y) (bits by default) of a joint ``P[..., X, Y]`` over the last two axes, + batched over the leading axes. ``joint`` is renormalized per batch element, so + an un-normalized (e.g. cell-weighted count) table is accepted.""" + P = np.asarray(joint, dtype=np.float64) + Z = P.sum(axis=(-2, -1), keepdims=True) + P = np.where(Z > 0, P / Z, 0.0) + Px = P.sum(axis=-1, keepdims=True) # [..., X, 1] + Py = P.sum(axis=-2, keepdims=True) # [..., 1, Y] + indep = Px * Py + with np.errstate(divide="ignore", invalid="ignore"): + terms = np.where(P > 0, P * _logb((P + _EPS) / (indep + _EPS), base), 0.0) + return np.sum(terms, axis=(-2, -1)) + + +def joint_from_conditional(cond, weights): + """Assemble a normalized joint ``P[..., C, Y]`` from per-clone conditionals + ``cond`` = P(φ|c) ``[..., C, Y]`` and clone ``weights`` ``[..., C]`` (any scale; + normalized here). ``weighted=False`` passes uniform weights, ``weighted=True`` + passes clone cell counts — the clone-mass choice lives with the caller.""" + cond = np.asarray(cond, dtype=np.float64) + w = np.asarray(weights, dtype=np.float64) + w = w / np.clip(w.sum(axis=-1, keepdims=True), _EPS, None) + return cond * w[..., None] diff --git a/tcri/_compute/_xp.py b/tcri/_compute/_xp.py new file mode 100644 index 0000000..a795cb9 --- /dev/null +++ b/tcri/_compute/_xp.py @@ -0,0 +1,62 @@ +"""Device seam for the batched engine — **torch-first** (grafiti `_compute/_xp` +parity, adapted because tcri's numeric core is torch: the Dirichlet draws and the +softmax are `torch`, and torch≥2.4 is already a hard dep so torch.cuda is zero new +deps). `cupy` may be added later as a second backend; for now the ladder is +CPU / torch-CUDA. Every accelerated function returns a host numpy array via +:func:`asnumpy` (§7.4 guardrail 4). GPU libs are imported lazily inside functions +(guardrail 1) — importing this module never touches CUDA. +""" +from __future__ import annotations + +import warnings + +import numpy as np + + +def resolve_device(device: str | None) -> str: + """Resolve ``device`` to ``"cpu"`` or ``"cuda"`` (§7.1 ladder). + + ``None``/``"cpu"`` → cpu; ``"mps"`` → cpu (first pass: the Dirichlet core has no + Metal backend); ``"auto"``/``"gpu"``/``"cuda"`` → cuda only if torch reports a + device, else cpu (an explicit ``"cuda"`` warns on fallback; ``auto``/``gpu`` are + silent). + """ + if device in (None, "cpu"): + return "cpu" + if device == "mps": + return "cpu" + if device in ("cuda", "gpu", "auto"): + try: + import torch + + if torch.cuda.is_available() and torch.cuda.device_count() > 0: + return "cuda" + except Exception: + pass + if device == "cuda": + warnings.warn( + "device='cuda' requested but torch CUDA is unavailable; using CPU.", + stacklevel=2, + ) + return "cpu" + warnings.warn(f"device={device!r} not recognized; using CPU.", stacklevel=2) + return "cpu" + + +def torch_device(device: str | None): + """The :class:`torch.device` for the resolved device.""" + import torch + + return torch.device("cuda" if resolve_device(device) == "cuda" else "cpu") + + +def asnumpy(x) -> np.ndarray: + """Bring an array (possibly a torch tensor, possibly on GPU) back to host numpy.""" + try: + import torch + + if isinstance(x, torch.Tensor): + return x.detach().cpu().numpy() + except Exception: + pass + return np.asarray(x) diff --git a/tcri/_contract.pyi b/tcri/_contract.pyi index 1ac327f..d50fe37 100644 --- a/tcri/_contract.pyi +++ b/tcri/_contract.pyi @@ -5,6 +5,10 @@ The conformance test (``tests/test_contract_conformance.py``) checks each but-absent functions are the refactor worklist. Prose spec: ``docs/contract/tcri_api_and_responsibilities.md``. +This freezes the public *interface*. Its sibling — ``tcri/model/_model_contract.py`` +(prose: ``docs/contract/MODEL_CONTRACT.md``) — freezes the model's *mathematics* +(Supplementary Note 1). + RULES this file encodes: - Only the KEPT surface is declared. A symbol NOT in this file must NOT be public after the refactor (see the Removal Ledger in ``docs/contract/REFACTOR_AGENDA.md``). @@ -26,19 +30,23 @@ class TCRIModel: def setup_anndata( cls, adata: AnnData, *, layer: Optional[str] = ..., clonotype_key: str = ..., phenotype_key: str = ..., - covariate_key: str = ..., batch_key: str = ..., + covariate_key: str = ..., batch_key: str = ..., **kwargs: Any, ) -> None: ... def train( self, max_epochs: int = ..., batch_size: int = ..., lr: float = ..., - reconstruction_loss_scale: float = ..., n_steps_kl_warmup: int = ..., + reconstruction_loss_scale: float = ..., n_steps_kl_warmup: int = ..., **kwargs: Any, ) -> None: ... def get_latent_representation( self, adata: Optional[AnnData] = ..., indices: Any = ..., batch_size: Optional[int] = ..., ) -> Any: ... - def predict(self, adata: Optional[AnnData] = ..., *, batch_size: int = ...) -> pd.DataFrame: ... + def predict( + self, adata: Optional[AnnData] = ..., *, batch_size: int = ..., eps: float = ..., + ) -> pd.DataFrame: ... def get_p_ct(self) -> Any: ... - def to_anndata(self, adata: AnnData, *, batch_size: int = ..., compute_umap: bool = ...) -> AnnData: ... + def to_anndata( + self, adata: Optional[AnnData] = ..., *, batch_size: int = ..., compute_umap: bool = ..., + ) -> AnnData: ... # ── preprocessing (pp) ─────────────────────────────────────────────────────── @@ -56,11 +64,13 @@ class tl: adata: AnnData, *, covariate: Optional[str] = ..., groupby: Optional[str] = ..., n_samples: int = ..., use_logits: bool = ..., weighted: bool = ..., clones: Any = ..., temperature: float = ..., random_state: Any = ..., + device: Any = ..., ) -> pd.DataFrame: ... def clonotypic_entropy( adata_or_jd: Any, *, covariate: Optional[str] = ..., groupby: Optional[str] = ..., splitby: Optional[str] = ..., n_samples: int = ..., temperature: float = ..., - clones: Any = ..., weighted: bool = ..., normalized: bool = ..., random_state: Any = ..., + clones: Any = ..., weighted: bool = ..., normalized: bool = ..., + n_clones_ref: Any = ..., random_state: Any = ..., ) -> Any: ... def phenotypic_entropy( adata_or_jd: Any, *, covariate: Optional[str] = ..., groupby: Optional[str] = ..., @@ -114,7 +124,7 @@ class pl: distance_metric: str = ..., palette: Any = ..., ax: Any = ..., figsize: Any = ..., save: Any = ..., show: Any = ..., return_axes: bool = ..., ) -> Any: ... - def resolve_palette(adata: AnnData, columns: Any) -> Any: ... + def resolve_palette(adata: AnnData, columns: Any, *, palette: Any = ...) -> Any: ... # ── diagnostics (diag) — returns DataFrames ────────────────────────────────── diff --git a/tcri/_keys.py b/tcri/_keys.py index 5bf5940..894bd59 100644 --- a/tcri/_keys.py +++ b/tcri/_keys.py @@ -6,9 +6,9 @@ conformance test forbids it. Migrating a reader/writer means swapping the literal for the constant here. -The two legacy shadow keys (``tcri_clone_key`` / ``tcri_phenotype_key``) and the -legacy ``X_tcri_phenotypes`` obsm slot are listed only so the removal step can -find and delete them; new code uses ``METADATA`` + ``X_PROBABILITIES``. +The legacy shadow keys (``tcri_clone_key`` / ``tcri_phenotype_key``) and the old +``X_tcri_phenotypes`` obsm slot have been removed — use ``METADATA`` and +``X_PROBABILITIES``. """ # ── uns: metadata + learned priors ─────────────────────────────────────────── @@ -45,8 +45,10 @@ PHENOTYPE_COL = "phenotype_col" BATCH_COL = "batch_col" -# ── legacy — declared ONLY so the removal step can find + delete them ───────── -LEGACY_MANAGER = "tcri_manager" # non-picklable AnnDataManager stash → drop -LEGACY_CLONE_KEY = "tcri_clone_key" # shadow of METADATA[CLONE_COL] → drop -LEGACY_PHENOTYPE_KEY = "tcri_phenotype_key" # shadow of METADATA[PHENOTYPE_COL] → drop -LEGACY_X_PHENOTYPES = "X_tcri_phenotypes" # old prob slot → X_PROBABILITIES +# ── legacy ─────────────────────────────────────────────────────────────────── +# The shadow keys `tcri_clone_key` / `tcri_phenotype_key` and the old +# `X_tcri_phenotypes` obsm slot are GONE: `to_anndata` no longer writes them and +# nothing reads them (`pp.clone_size`, the last reader, now uses METADATA). +# `LEGACY_MANAGER` stays because it still does defensive work — `save_tcri_session` +# pops it so a stray non-picklable AnnDataManager can never be serialized. +LEGACY_MANAGER = "tcri_manager" # popped defensively before save diff --git a/tcri/_stats.py b/tcri/_stats.py index 6468298..1908ddd 100644 --- a/tcri/_stats.py +++ b/tcri/_stats.py @@ -11,7 +11,7 @@ import math import numpy as np -from scipy.stats import mannwhitneyu +from scipy.stats import mannwhitneyu, rankdata from sklearn.metrics import roc_auc_score @@ -35,26 +35,51 @@ def mann_whitney(a, b, *, alternative: str = "two-sided"): def auc_and_label_permutation(scores, labels, pos_label=None, n_perm=200_000, seed=42, max_exact=200_000): - """Observed AUROC + a label-permutation p-value (exact when feasible).""" + """Observed AUROC + a label-permutation p-value (exact when feasible). + + Under label permutation the *scores* never change, so the ranks are computed + ONCE and each permuted AUROC is a rank-sum over the permuted positive set via + the Mann–Whitney identity + + AUC = (Σ ranks[pos] − n_pos(n_pos+1)/2) / (n_pos·n_neg) + + which is exact (midranks reproduce ``roc_auc_score``'s tie handling) and turns + each draw from an O(n log n) re-sort into an O(n_pos) sum. Measured 137× on the + Monte-Carlo path (191 s → 1.4 s at the default ``n_perm``). + """ scores = np.asarray(scores, dtype=float) labels = np.asarray(labels) if pos_label is None: pos_label = sorted(set(labels))[-1] y = (labels == pos_label).astype(int) obs_auc = roc_auc_score(y, scores) + n = len(y) n_pos = int(y.sum()) - n_exact = math.comb(len(y), n_pos) + n_neg = n - n_pos + + if n_pos == 0 or n_neg == 0: # AUROC undefined; keep the old failure mode + perm_stats = np.array([]) + return obs_auc, float("nan"), perm_stats, "degenerate" + + # midranks: ties get the average rank, matching roc_auc_score exactly + ranks = rankdata(scores) + denom = float(n_pos) * float(n_neg) + offset = n_pos * (n_pos + 1) / 2.0 + + n_exact = math.comb(n, n_pos) if n_exact <= max_exact: - perm_stats = np.array([ - roc_auc_score(np.isin(np.arange(len(y)), idx).astype(int), scores) - for idx in itertools.combinations(range(len(y)), n_pos) - ]) + perm_stats = np.fromiter( + ((ranks[list(idx)].sum() - offset) / denom + for idx in itertools.combinations(range(n), n_pos)), + dtype=float, count=n_exact, + ) perm_mode = "exact" else: rng = np.random.default_rng(seed) - perm_stats = np.array([ - roc_auc_score(rng.permutation(y), scores) for _ in range(n_perm) - ]) + perm_stats = np.empty(n_perm, dtype=float) + for i in range(n_perm): + # a random size-n_pos subset of ranks == a random label permutation + perm_stats[i] = (rng.permutation(ranks)[:n_pos].sum() - offset) / denom perm_mode = "mc" p_perm = np.mean(np.abs(perm_stats - 0.5) >= np.abs(obs_auc - 0.5)) return obs_auc, p_perm, perm_stats, perm_mode diff --git a/tcri/diagnostics/__init__.py b/tcri/diagnostics/__init__.py new file mode 100644 index 0000000..7ab9e95 --- /dev/null +++ b/tcri/diagnostics/__init__.py @@ -0,0 +1,19 @@ +"""``tcri.diag`` — diagnostics: posterior-predictive checks (all return DataFrames) + the +two relocated training plots. Read-only checks on a finalized model. +""" +from ._ppc import ( + joint_distribution_ppc, + phenotype_calibration, + reconstruction_ppc, + permutation_null, +) +from ._training import loss, archetypes + +__all__ = [ + "joint_distribution_ppc", + "phenotype_calibration", + "reconstruction_ppc", + "permutation_null", + "loss", + "archetypes", +] diff --git a/tcri/diagnostics/_ppc.py b/tcri/diagnostics/_ppc.py new file mode 100644 index 0000000..9d1ec9f --- /dev/null +++ b/tcri/diagnostics/_ppc.py @@ -0,0 +1,170 @@ +"""``tcri.diag`` posterior-predictive checks (§9.1). All return DataFrames. ``model`` is +required only for ``reconstruction_ppc`` (the live ZINB decoder); the rest are adata-only. +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +from .. import _keys as K + +__all__ = ["joint_distribution_ppc", "phenotype_calibration", "reconstruction_ppc", "permutation_null"] + + +def joint_distribution_ppc(adata, *, covariate=None, distance_metric="l1", temperature=1.0, + clones=None, random_state=None): + """Model vs empirical per-clone phenotype frequencies (the fixed + ``compare_joint_distribution``). Per-clone distance + per-covariate aggregate. adata-only.""" + from .._distance import phenotype_distance + from ..tools import joint_distribution + + dist_fn = phenotype_distance(distance_metric) + meta = adata.uns[K.METADATA] + clone_col, pheno_col, cov_col = meta["clone_col"], meta["phenotype_col"], meta["covariate_col"] + phenos = list(adata.uns[K.PHENOTYPE_CATEGORIES]) + covs = [covariate] if covariate is not None else list(adata.uns[K.COVARIATE_CATEGORIES]) + + rows = [] + for m in covs: + Jm = joint_distribution(adata, covariate=m, use_logits=True, n_samples=0, + temperature=temperature, clones=clones) + cmask = adata.obs[cov_col].astype(str) == str(m) + sub = adata.obs.loc[cmask, [clone_col, pheno_col]] + for c in Jm.index: + emp = (sub.loc[sub[clone_col] == c, pheno_col].value_counts() + .reindex(phenos).fillna(0.0).to_numpy(dtype=float)) + if emp.sum() <= 0: + continue + emp = emp / emp.sum() + model_p = np.asarray(Jm.loc[c].reindex(phenos).to_numpy(), dtype=float) + rows.append({"covariate": m, "clonotype": c, + "distance": float(dist_fn(emp, model_p))}) + df = pd.DataFrame(rows) + if len(df): + agg = df.groupby("covariate")["distance"].mean().rename("mean_distance").reset_index() + df.attrs["per_covariate"] = agg + return df + + +def phenotype_calibration(adata, *, n_bins=10): + """Reliability of predict() probabilities: bin by predicted max-prob, compare mean + predicted prob to empirical accuracy per bin; scalar ECE in ``df.attrs['ECE']``. adata-only.""" + probs = np.asarray(adata.obsm[K.X_PROBABILITIES], dtype=float) + phenos = list(adata.uns[K.PHENOTYPE_CATEGORIES]) + conf = probs.max(axis=1) + pred = np.asarray(phenos)[probs.argmax(axis=1)] + true = adata.obs[adata.uns[K.METADATA]["phenotype_col"]].astype(str).to_numpy() + correct = (pred == true).astype(float) + + edges = np.linspace(0.0, 1.0, n_bins + 1) + rows = [] + ece = 0.0 + N = max(len(conf), 1) + for b in range(n_bins): + lo, hi = edges[b], edges[b + 1] + m = (conf >= lo) & (conf <= hi if b == n_bins - 1 else conf < hi) + n = int(m.sum()) + if n == 0: + rows.append({"bin": b, "mean_pred": np.nan, "emp_freq": np.nan, "count": 0}) + continue + mp, acc = float(conf[m].mean()), float(correct[m].mean()) + rows.append({"bin": b, "mean_pred": mp, "emp_freq": acc, "count": n}) + ece += (n / N) * abs(acc - mp) + df = pd.DataFrame(rows) + df.attrs["ECE"] = float(ece) + return df + + +def reconstruction_ppc(model, adata=None, *, n_sims=100, random_state=0): + """ZINB reconstruction PPC: simulate counts from the fitted decoder and compare library + size / dropout / mean / variance vs observed. ``model`` REQUIRED (live decoder).""" + import torch + import pyro.distributions as dist + from scvi import REGISTRY_KEYS + + module = model.module + module.eval() + adata = model._validate_anndata(adata) + device = next(module.parameters()).device + torch.manual_seed(int(random_state)) + loader = model._make_data_loader(adata=adata, batch_size=256) + + obs_lib, sim_lib, obs_all, sim_all = [], [], [], [] + with torch.no_grad(): + for tensors in loader: + x = tensors[REGISTRY_KEYS.X_KEY].to(device) + b = tensors[REGISTRY_KEYS.BATCH_KEY].long().to(device) + log_lib = torch.log(x.sum(1, keepdim=True) + 1e-6) + z_loc, _, _ = module.encoder(x, b) + _px_scale, _px_r, px_rate, px_dropout = module.decoder("gene", z_loc, log_lib, b) + gate = torch.sigmoid(px_dropout).clamp(1e-3, 1 - 1e-3) + # mirror the module's generative distribution exactly (same eps + [-10,10] clamp, + # tcri/model/_module.py) so the PPC samples from what the model actually defines + nb_logits = (px_rate + module.eps).log() - (module.px_r.exp() + module.eps).log() + nb_logits = torch.clamp(nb_logits, min=-10.0, max=10.0) + total = module.px_r.exp().clamp(max=1e4) + xd = dist.ZeroInflatedNegativeBinomial(gate=gate, total_count=total, + logits=nb_logits, validate_args=False) + sim = xd.sample() + obs_lib.append(x.sum(1).cpu().numpy()); sim_lib.append(sim.sum(1).cpu().numpy()) + obs_all.append(x.cpu().numpy()); sim_all.append(sim.cpu().numpy()) + + ol, sl = np.concatenate(obs_lib), np.concatenate(sim_lib) + oa, sa = np.concatenate(obs_all), np.concatenate(sim_all) + stats = { + "mean_library_size": (float(ol.mean()), float(sl.mean())), + "median_library_size": (float(np.median(ol)), float(np.median(sl))), + "dropout_fraction": (float((oa == 0).mean()), float((sa == 0).mean())), + "mean_expression": (float(oa.mean()), float(sa.mean())), + "var_expression": (float(oa.var()), float(sa.var())), + } + return pd.DataFrame([ + {"statistic": k, "observed": o, "simulated": s, "discrepancy": abs(o - s)} + for k, (o, s) in stats.items() + ]) + + +def permutation_null(adata, *, metric="mutual_information", covariate=None, groupby=None, + n_perm=1000, random_state=None): + """Permutation null for a clone↔phenotype metric: permute phenotype labels within each + covariate, recompute the metric on the **empirical** clone×phenotype joint to form a null; + report observed, null mean/sd, z, p. adata-only, model-free.""" + from ..tools._mutual_information import _mi_from_joint + + if metric != "mutual_information": + raise ValueError("permutation_null currently supports metric='mutual_information'.") + rng = np.random.default_rng(random_state) + meta = adata.uns[K.METADATA] + clone_col, pheno_col, cov_col = meta["clone_col"], meta["phenotype_col"], meta["covariate_col"] + phenos = list(adata.uns[K.PHENOTYPE_CATEGORIES]) + pheno_index = {p: i for i, p in enumerate(phenos)} + covs = [covariate] if covariate is not None else list(adata.uns[K.COVARIATE_CATEGORIES]) + + n_phenos = len(phenos) + + def _empirical_mi(clones_codes, pheno_codes, n_clones): + # bincount on the flattened (clone, phenotype) key rather than np.add.at — + # identical counts, ~3.7x faster (np.add.at is the unbuffered ufunc path). + flat = np.bincount( + clones_codes * n_phenos + pheno_codes, minlength=n_clones * n_phenos + ) + J = flat.astype(float).reshape(n_clones, n_phenos) + return _mi_from_joint(J, normalized=True, mode="min") + + rows = [] + for m in covs: + cmask = (adata.obs[cov_col].astype(str) == str(m)).to_numpy() + clones = adata.obs.loc[cmask, clone_col].astype(str).to_numpy() + uc = {c: i for i, c in enumerate(sorted(set(clones)))} + cc = np.array([uc[c] for c in clones]) + pc = adata.obs.loc[cmask, pheno_col].astype(str).map(pheno_index).to_numpy() + if len(cc) == 0: + continue + obs_mi = _empirical_mi(cc, pc, len(uc)) + null = np.array([_empirical_mi(cc, rng.permutation(pc), len(uc)) for _ in range(int(n_perm))]) + mu, sd = float(null.mean()), float(null.std(ddof=1) if null.size > 1 else 0.0) + z = (obs_mi - mu) / sd if sd > 0 else np.nan + p = float(np.mean(null >= obs_mi)) + rows.append({"covariate": m, "observed": obs_mi, "null_mean": mu, "null_sd": sd, + "z": z, "p": p}) + return pd.DataFrame(rows) diff --git a/tcri/diagnostics/_training.py b/tcri/diagnostics/_training.py new file mode 100644 index 0000000..745927b --- /dev/null +++ b/tcri/diagnostics/_training.py @@ -0,0 +1,78 @@ +"""``tcri.diag`` training diagnostics (§9.2) — the two model plots relocated off +``TCRIModel`` (``plot_loss`` → :func:`loss`, ``plot_archetypes`` → :func:`archetypes`).""" +from __future__ import annotations + +import numpy as np + +__all__ = ["loss", "archetypes"] + + +def _series(hist, key): + """Pull a 1-D value array from an scvi ``history_`` entry (DataFrame/Series/list).""" + v = hist.get(key) if hasattr(hist, "get") else None + if v is None: + return [] + if hasattr(v, "values"): + arr = np.asarray(v.values).ravel() + return arr.tolist() + return list(v) + + +def loss(model, *, log_scale=False, ax=None, save=None): + """Plot training/validation ELBO and prior-KL from ``model.history_``.""" + import matplotlib.pyplot as plt + + hist = getattr(model, "history_", {}) or {} + elbo_train = _series(hist, "elbo_train") + elbo_val = _series(hist, "elbo_validation") + kl_train = _series(hist, "kl_divergence_with_prior_train_epoch") + kl_val = _series(hist, "kl_divergence_with_prior_val") + + if ax is None: + fig, axes = plt.subplots(2, 1, figsize=(10, 10)) + else: + fig = ax.figure + axes = [ax, ax.figure.add_subplot(212)] if len(ax.figure.axes) < 2 else ax.figure.axes[:2] + + axes[0].plot(elbo_train, label="train ELBO") + if elbo_val: + axes[0].plot(elbo_val, label="val ELBO") + axes[0].set_xlabel("epoch"); axes[0].set_ylabel("ELBO"); axes[0].set_title("ELBO"); axes[0].legend() + if kl_train or kl_val: + if kl_train: + axes[1].plot(kl_train, label="train KL(prior)") + if kl_val: + axes[1].plot(kl_val, label="val KL(prior)") + axes[1].set_xlabel("epoch"); axes[1].set_ylabel("KL"); axes[1].set_title("prior KL"); axes[1].legend() + if log_scale: + for a in axes: + a.set_yscale("log") + fig.tight_layout() + if save: + fig.savefig(save, bbox_inches="tight", dpi=150) + return axes[0] + + +def archetypes(model, *, ax=None, save=None): + """Cluster-ordered clone×phenotype heatmap + archetype centroids, ordered by the + ``build_archetypes`` labels retained on the model.""" + import matplotlib.pyplot as plt + + labels = np.asarray(model.labels) + prior = np.asarray(model.clone_phenotype_prior) + centers = np.asarray(model.centers) + order = np.argsort(labels) + + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) if ax is None else (ax.figure, [ax, ax.figure.add_subplot(122)]) + im0 = axes[0].imshow(prior[order, :], aspect="auto", cmap="viridis") + axes[0].set_title("clone phenotype prior (cluster-ordered)") + axes[0].set_xlabel("phenotype"); axes[0].set_ylabel("clone (by cluster)") + fig.colorbar(im0, ax=axes[0]) + im1 = axes[1].imshow(centers, aspect="auto", cmap="viridis") + axes[1].set_title("archetype centroids") + axes[1].set_xlabel("phenotype"); axes[1].set_ylabel("archetype") + fig.colorbar(im1, ax=axes[1]) + fig.tight_layout() + if save: + fig.savefig(save, bbox_inches="tight", dpi=150) + return axes[0] diff --git a/tcri/metrics/__init__.py b/tcri/metrics/__init__.py deleted file mode 100644 index 6195be4..0000000 --- a/tcri/metrics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ..metrics._metrics import * diff --git a/tcri/metrics/_metrics.py b/tcri/metrics/_metrics.py deleted file mode 100644 index 6ff3d51..0000000 --- a/tcri/metrics/_metrics.py +++ /dev/null @@ -1,1000 +0,0 @@ -# Standard library imports -import warnings -from .. import _keys as K -import numpy as np -import pandas as pd -import torch -import torch.nn.functional as F -import scanpy as sc -from typing import Optional, List, Union - -# Third-party imports -from scipy.stats import entropy -from scipy.spatial import distance -import gseapy as gp -import numpy as np, pandas as pd, torch, umap -from tqdm.auto import tqdm -from scvi import REGISTRY_KEYS -import itertools -import collections -# Local imports -from ..preprocessing._preprocessing import joint_distribution,joint_distribution_posterior - -warnings.filterwarnings('ignore') - -import numpy as np, pandas as pd -from typing import Optional, List -from tqdm.auto import tqdm # nice progress bar in notebooks -# ╭──────────────────────────────────────────────────────────────╮ -# │ Δ-E N T R O P Y T A B L E B U I L D E R (v2) │ -# ╰──────────────────────────────────────────────────────────────╯ -import numpy as np, pandas as pd -from typing import Optional, List -from tqdm.auto import tqdm - - -# ------------ simple ANSI helpers ------------ # -RESET = "\x1b[0m" -BOLD = "\x1b[1m" -DIM = "\x1b[2m" -GREEN = "\x1b[32m" -CYAN = "\x1b[36m" -MAGENT = "\x1b[35m" - -# ╭─ colour / pretty-print helpers ─────────────────────────────────────────╮ -RESET = "\x1b[0m"; BOLD = "\x1b[1m"; DIM = "\x1b[2m" -GRN = "\x1b[32m"; CYN = "\x1b[36m"; MAG = "\x1b[35m"; YLW = "\x1b[33m"; RED = "\x1b[31m" - -from .._console import _ok, _info, _warn, _fin -# ╰──────────────────────────────────────────────────────────────────────────╯ - - -# ╭─ tiny ASCII histogram (handy in notebooks/SSH) ──────────────────────────╮ -def _ascii_hist(samples, bins=25, width=40) -> str: - hist, edges = np.histogram(samples, bins=bins) - top = hist.max() - lines=[] - for h,e0,e1 in zip(hist, edges[:-1], edges[1:]): - bar = "█"*int(width*h/top) if top else "" - lines.append(f"{e0:7.3f}-{e1:7.3f} | {bar}") - return "\n".join(lines) -# ╰───────────────── - - -# ╭─ MI helper (single source of truth) ─────────────────────────────────────╮ -def _mi_from_joint(pxy: np.ndarray, normalised: bool, mode: str="average") -> float: - """ - Mutual information (optionally normalised) from an *already normalised* - joint table pxy (shape C×P). - """ - eps = 1e-15 - px = pxy.sum(1, keepdims=True) - py = pxy.sum(0, keepdims=True) - mi = np.sum(pxy * np.log2((pxy+eps) / (px @ py + eps))) - - if not normalised: - return mi - h_c = -np.sum(px * np.log2(px+eps)) - h_p = -np.sum(py * np.log2(py+eps)) - denom = 0.5*(h_c+h_p) if mode == "average" else min(h_c, h_p) - return mi/denom if denom > 0 else 0.0 -# ╰──────────────────────────────────────────────────────────────────────────╯ - - -def mi_compare(adata, groupby, groups=None, treatment=None, n_samples=50, - patient_col=None, clone_col=None, covariate_col=None, - verbose=True, **mi_kwargs): - meta = adata.uns[K.METADATA] - patient_col = patient_col or meta["batch_col"] - clone_col = clone_col or meta["clone_col"] - covariate_col = covariate_col or meta["covariate_col"] - - covariates = treatment - if covariates is None: - covariates = adata.obs[covariate_col].cat.categories.tolist() - elif isinstance(covariates, str): - covariates = [covariates] - - # resolve groups into list of 2-tuples - unique_groups = adata.obs[groupby].dropna().unique().tolist() - if groups is None: - pairs = list(itertools.combinations(sorted(unique_groups), 2)) - elif isinstance(groups[0], (list, tuple)): - pairs = [tuple(g) for g in groups] - else: - pairs = list(itertools.combinations(groups, 2)) - - # compute patient-level MI samples - keep_groups = set(g for pair in pairs for g in pair) - records = [] - patients = adata.obs[patient_col].unique() - for p in tqdm(patients, disable=not verbose, desc="MI per patient"): - pmask = adata.obs[patient_col] == p - group_val = adata.obs.loc[pmask, groupby].iloc[0] - if group_val not in keep_groups: - continue - clones = adata.obs.loc[pmask, clone_col].unique().tolist() - for cov in covariates: - try: - samples = mutual_information( - adata, cov, n_samples=n_samples, - clones=clones, verbose=False, **mi_kwargs - ) - except Exception as e: - if verbose: - print(f" Skip {p}/{cov}: {e}") - continue - for s in np.atleast_1d(samples): - records.append({"patient": p, "group": group_val, - "covariate": cov, "MI": float(s)}) - - result = pd.DataFrame(records) - summary = ( - result.groupby(["patient", "group", "covariate"])["MI"] - .agg(mean="mean", median="median", - lo=lambda x: np.quantile(x, 0.025), - hi=lambda x: np.quantile(x, 0.975), - sd="std", n="size") - .reset_index() - ) - - return { - "samples": result, - "summary": summary, - "pairs": pairs, - "params": {"groupby": groupby, "covariates": covariates, - "n_samples": n_samples, "patient_col": patient_col}, - } - - -def dkl(p, q): - epsilon = 1e-10 - p = np.clip(p, epsilon, 1) - q = np.clip(q, epsilon, 1) - return entropy(p, q) - -import numpy as np, pandas as pd -from scipy.stats import entropy # Shannon entropy -from typing import Optional, List, Union -import numpy as np, pandas as pd -from typing import Optional, List, Union -from scipy.stats import entropy # Shannon H - -# ---------- ANSI helpers ------------------------------------------- -RESET="\x1b[0m"; BOLD="\x1b[1m"; DIM="\x1b[2m" -GRN="\x1b[32m"; CYN="\x1b[36m"; MAG="\x1b[35m"; YLW="\x1b[33m" - -# small helper ---------------------------------------------- -def _ent(p, base=2): - p = np.asarray(p, dtype=float) - eps = 1e-15 - p = p.clip(eps) / p.sum() - return entropy(p, base=base) - -def clonotypic_entropy_base( - adata, - covariate_label: str, - phenotype: str, - *, - base: int = 2, - normalised: bool = True, - temperature: float = 1.0, - clones: Optional[List[str]] = None, - weighted: bool = False, - posterior: bool = True, - combine_with_logits: bool = True, -) -> float: - if posterior: - jd = joint_distribution_posterior( - adata, - covariate_label = covariate_label, - temperature = temperature, - clones = clones, - weighted = weighted, - combine_with_logits = combine_with_logits, - silent = True) - else: - jd = joint_distribution( - adata, - covariate_label = covariate_label, - temperature = temperature, - n_samples = 0, - clones = clones, - weighted = weighted) - - if jd is None or jd.empty or phenotype not in jd.columns: - return 0.0 - - vec = jd[phenotype].to_numpy(dtype=float) - eps = 1e-15 - vec = np.clip(vec, eps, None) - vec = vec / vec.sum() # ensure ∑=1 - - H = entropy(vec, base=base) - if normalised and len(vec) > 1: - H /= np.log(len(vec)) / np.log(base) # # max-entropy normalisation - return H - -def clonotypic_entropy( - adata, - covariate: str, - *, - point_estimate: bool = True, - n_samples: int = 200, - temperature: float = 1.0, - combine_with_logits: bool = True, - _clones: Optional[List[str]] = None, -) -> Union[pd.Series, np.ndarray]: - r"""Clonotypic entropy of each phenotype at one covariate value. - - For a phenotype :math:`\phi`, this is the normalized Shannon entropy of the - distribution over clonotypes carrying that phenotype, - :math:`H\!\left[P(c \mid \phi,\, m)\right]`, estimated from posterior draws of - the clone–phenotype joint distribution. High values mean the phenotype is - spread across many clones; low values mean a few clones dominate it. - - Parameters - ---------- - adata : AnnData - Registered object (see - :func:`~tcri.preprocessing._preprocessing.register_model`). - covariate : str - Covariate value :math:`m` to condition on (a category of the registered - covariate column). - point_estimate : bool, default True - If True, return the posterior-mean entropy per phenotype; if False, return - the full matrix of per-draw entropies. - n_samples : int, default 200 - Number of posterior draws to average over. Must be ``>= 1``. - temperature : float, default 1.0 - Sharpen (``<1``) or flatten (``>1``) the per-cell distribution before - aggregating. - combine_with_logits : bool, default True - Combine the sampled prior :math:`p_{ct}` with the per-cell classifier - logits (the full posterior) rather than the prior alone. - - Returns - ------- - pandas.Series or numpy.ndarray - If ``point_estimate`` is True, a Series indexed by phenotype name whose - values are the mean entropy in bits, normalized to :math:`[0, 1]` by - :math:`\log_2 n_\text{clones}`, over ``n_samples`` draws. Otherwise an - array of shape ``(n_samples, n_phenotypes)``. - - Raises - ------ - ValueError - If ``n_samples < 1``. - - See Also - -------- - phenotypic_entropy : the per-clone analogue, :math:`H[P(\phi \mid c, m)]`. - mutual_information : clone–phenotype coupling at a covariate. - - Examples - -------- - >>> covariate = adata.uns["tcri_covariate_categories"][0] - >>> ce = clonotypic_entropy(adata, covariate, n_samples=50) - >>> ce.sort_values(ascending=False).head() - """ - if n_samples < 1: - raise ValueError("n_samples must be >= 1") - - phenotypes = list(adata.uns[K.PHENOTYPE_CATEGORIES]) - n_pheno = len(phenotypes) - samples = np.empty((n_samples, n_pheno), dtype=float) - - for i in range(n_samples): - jd = joint_distribution_posterior( - adata, - covariate_label = covariate, - temperature = temperature, - clones = _clones, - combine_with_logits = combine_with_logits, - silent = True, - ) - if jd is None or jd.empty: - samples[i, :] = np.nan - continue - for j, p in enumerate(phenotypes): - if p not in jd.columns: - samples[i, j] = np.nan - continue - vec = jd[p].to_numpy(dtype=float) - vec = np.clip(vec, 1e-15, None) - vec = vec / vec.sum() - H = entropy(vec, base=2) - if len(vec) > 1: - H /= np.log2(len(vec)) - samples[i, j] = H - - if point_estimate: - return pd.Series(np.nanmean(samples, axis=0), index=phenotypes, name=covariate) - return samples - -def delta_clonotypic_entropy( - adata, - phenotype: str, - *, - cov_pre: str = "Pre-treatment", - cov_post: str = "Post-treatment", - n_samples: int = 1_000, - temperature: float = 1.0, - clones: Optional[List[str]] = None, - weighted: bool = False, - normalised: bool = True, - base: int = 2, - posterior: bool = True, - combine_with_logits: bool = True, - verbose: bool = True, - graph: bool = False, # ASCII plot of Δ posterior - seed: Optional[int] = None, -) -> np.ndarray: - """ - Sample Δ-entropy = H_post – H_pre for one phenotype. - - Returns - ------- - delta_samples : ndarray (shape = (n_samples,)) - Positive values ⇒ entropy increased from pre → post. - """ - if seed is not None: - np.random.seed(seed) - - if verbose: - print(f"{BOLD}{MAG}Δ-Entropy {phenotype}: " - f"'{cov_pre}' ⟶ '{cov_post}'{RESET}") - _info("# samples", n_samples) - _info("posterior", posterior) - _info("weighted", weighted) - - Δ = np.empty(n_samples, dtype=float) - - # --- Monte Carlo loop ----------------------------------------- - for i in range(n_samples): - H_pre = clonotypic_entropy_base( - adata, cov_pre, phenotype, - base=base, normalised=normalised, - temperature=temperature, - clones=clones, weighted=weighted, - posterior=posterior, - combine_with_logits=combine_with_logits) - - H_post = clonotypic_entropy_base( - adata, cov_post, phenotype, - base=base, normalised=normalised, - temperature=temperature, - clones=clones, weighted=weighted, - posterior=posterior, - combine_with_logits=combine_with_logits) - Δ[i] = H_post - H_pre - - if verbose: - _ok("sampling complete") - _info("mean ± sd", f"{Δ.mean():.4f} ± {Δ.std():.4f}") - lo,hi = np.percentile(Δ,[2.5,97.5]) - _info("95 % CI", f"[{lo:.4f}, {hi:.4f}]") - if graph: - print(f"{DIM}\nASCII histogram of Δ:\n{_ascii_hist(Δ)}{RESET}") - - return Δ - - -def delta_entropy_table( - adata, - *, - cov_pre : str = "Pre-treatment", - cov_post: str = "Post-treatment", - splitby : str = "response", - n_samples : int = 1_000, - temperature: float = 1.0, - weighted : bool = False, - normalised : bool = True, - base : int = 2, - posterior : bool = True, - combine_with_logits : bool = True, - seed : Optional[int] = 42, - show_progress: bool = True -) -> pd.DataFrame: - """ - Build a tidy Δ-clonotypic-entropy table (post – pre). - - Each row ⇢ one phenotype × one `splitby` group. - The `delta_samples` column keeps the full NumPy vector so you can - re-plot KDEs or run further stats without re-sampling. - """ - # reproducibility - if seed is not None: - np.random.seed(seed) - - meta = adata.uns[K.METADATA] - clone_col = meta["clone_col"] - phen_col = meta["phenotype_col"] - - groups = sorted(adata.obs[splitby].dropna().unique().tolist()) - phenotypes = adata.obs[phen_col].astype("category").cat.categories.tolist() - - records = [] - iterator = tqdm(groups, desc="Δ-entropy groups") if show_progress else groups - - for g in iterator: - # — restrict ONLY the clone list, keep full AnnData for index integrity - mask_g = adata.obs[splitby] == g - clones_g = adata.obs.loc[mask_g, clone_col].unique().tolist() - - for ph in phenotypes: - delta = delta_clonotypic_entropy( - adata, ph, - cov_pre = cov_pre, - cov_post = cov_post, - n_samples = n_samples, - temperature = temperature, - clones = clones_g, # ⬅ scoped clones - weighted = weighted, - normalised = normalised, - base = base, - posterior = posterior, - combine_with_logits = combine_with_logits, - verbose = False - ) - - d_mean = delta.mean() - d_sd = delta.std() - hdi_lo, hdi_hi = np.percentile(delta, [2.5, 97.5]) - p_gt = (delta > 0).mean() - p_lt = (delta < 0).mean() - - records.append(dict( - **{splitby: g, "phenotype": ph}, - delta_samples = delta, - delta_mean = d_mean, - delta_sd = d_sd, - hdi_low = hdi_lo, - hdi_high = hdi_hi, - p_greater = p_gt, - p_less = p_lt - )) - - return pd.DataFrame.from_records(records) - - -def phenotypic_entropy( - adata, - covariate: str, - *, - point_estimate: bool = True, - n_samples: int = 200, - temperature: float = 1.0, - combine_with_logits: bool = True, -) -> Union[pd.Series, np.ndarray]: - r"""Phenotypic entropy of each clonotype at one covariate value. - - For a clonotype :math:`c`, this is the normalized Shannon entropy of its - distribution over phenotypes, :math:`H\!\left[P(\phi \mid c,\, m)\right]`, - estimated from posterior draws. High values mean the clone is phenotypically - plastic; low values mean it is committed to one phenotype. - - Parameters - ---------- - adata : AnnData - Registered object (see - :func:`~tcri.preprocessing._preprocessing.register_model`). - covariate : str - Covariate value :math:`m` to condition on. - point_estimate : bool, default True - If True, return the posterior-mean entropy per clone; if False, the full - per-draw matrix. - n_samples : int, default 200 - Number of posterior draws to average over. Must be ``>= 1``. - temperature : float, default 1.0 - Sharpen (``<1``) or flatten (``>1``) the per-cell distribution. - combine_with_logits : bool, default True - Combine the sampled prior with the per-cell classifier logits. - - Returns - ------- - pandas.Series or numpy.ndarray - If ``point_estimate`` is True, a Series indexed by clonotype, in bits and - normalized to :math:`[0, 1]` by :math:`\log_2 n_\text{phenotypes}`. - Otherwise an array of shape ``(n_samples, n_clones)`` over the clones - present at ``covariate``. - - Raises - ------ - ValueError - If ``n_samples < 1``. - - See Also - -------- - clonotypic_entropy : the per-phenotype analogue. - flux : change in a clone's phenotype distribution between two covariates. - - Examples - -------- - >>> covariate = adata.uns["tcri_covariate_categories"][0] - >>> pe = phenotypic_entropy(adata, covariate, n_samples=50) - >>> pe.mean() # average phenotypic plasticity across clones - """ - if n_samples < 1: - raise ValueError("n_samples must be >= 1") - - meta = adata.uns[K.METADATA] - clone_col = meta["clone_col"] - covariate_col = meta["covariate_col"] - - clones_list = ( - adata.obs.loc[adata.obs[covariate_col] == covariate, clone_col] - .unique() - .tolist() - ) - - if len(clones_list) == 0: - if point_estimate: - return pd.Series(dtype=float, name=covariate) - return np.empty((n_samples, 0), dtype=float) - - n_clones = len(clones_list) - samples = np.empty((n_samples, n_clones), dtype=float) - - for i in range(n_samples): - jd = joint_distribution_posterior( - adata, - covariate_label = covariate, - temperature = temperature, - clones = clones_list, - combine_with_logits = combine_with_logits, - silent = True, - ) - if jd is None or jd.empty: - samples[i, :] = np.nan - continue - n_phen = jd.shape[1] - norm = np.log2(n_phen) if n_phen > 1 else 1.0 - for j, cl in enumerate(clones_list): - if cl not in jd.index: - samples[i, j] = np.nan - continue - p = jd.loc[cl].to_numpy(dtype=float) - p = np.clip(p, 1e-15, None) - p = p / p.sum() - samples[i, j] = entropy(p, base=2) / norm - - if point_estimate: - return pd.Series(np.nanmean(samples, axis=0), index=clones_list, name=covariate) - return samples - - -def clonality(adata): - r"""Phenotype clonality: how clonally concentrated each phenotype is. - - For each phenotype, clonality is :math:`1 - H / \log_2 K`, where :math:`H` is - the Shannon entropy (bits) of the clone-size distribution among cells of that - phenotype and :math:`K` is the number of distinct clones. It is the normalized - complement of clonotypic entropy: ``1`` means a single clone dominates the - phenotype, ``0`` means all clones are equally represented. - - Parameters - ---------- - adata : AnnData - Registered object. Uses the observed (hard) clone and phenotype labels - rather than the posterior, so it only needs the registered category keys. - - Returns - ------- - dict of {str: float} - Maps each phenotype to its clonality in :math:`[0, 1]`. - - See Also - -------- - clonotypic_entropy : the soft, posterior per-phenotype entropy. - - Examples - -------- - >>> clonality(adata) - {'A': 0.31, 'B': 0.07, 'C': 0.52} - """ - phenotypes = adata.obs[adata.uns["tcri_phenotype_key"]].tolist() - unique_phenotypes = np.unique(phenotypes) - entropys = dict() - df = adata.obs.copy() - for phenotype in unique_phenotypes: - pheno_df = df[df[adata.uns['tcri_phenotype_key']]==phenotype] - clonotypes = pheno_df[adata.uns["tcri_clone_key"]].tolist() - unique_clonotypes = np.unique(clonotypes) - nums = [] - for tcr in unique_clonotypes: - tcrs = pheno_df[pheno_df[adata.uns["tcri_clone_key"]]==tcr] - nums.append(len(tcrs)) - clonality = 1 - entropy(np.array(nums),base=2) / np.log2(len(nums)) - entropys[phenotype] = np.nan_to_num(clonality) - return entropys - -def clone_fraction(adata, groupby): - frequencies = dict() - for group in set(adata.obs[groupby]): - frequencies[group] = dict() - sdata = adata[adata.obs[groupby] == group] - total_cells = len(sdata.obs.index.tolist()) - clones = sdata.obs[sdata.uns["tcri_clone_key"]].tolist() - for c in set(clones): - frequencies[group][c] = clones.count(c) / total_cells - return frequencies - - -def mutual_information( - adata, - covariate: str, - *, - temperature: float = 1.0, - n_samples: int = 0, - clones: Optional[List[str]] = None, - normalised: bool = True, - normalise_mode: str = "average", - posterior: bool = True, - combine_with_logits: bool = True, - verbose: bool = True, - graph: bool = False, -) -> Union[float, np.ndarray]: - r"""Mutual information between clonotype and phenotype at one covariate value. - - :math:`I(c; \phi \mid m)` quantifies how much knowing a cell's clonotype tells - you about its phenotype at covariate :math:`m` — the strength of - clone–phenotype coupling. Zero means clonotype and phenotype are independent; - larger values mean clones are phenotypically structured. - - Parameters - ---------- - adata : AnnData - Registered object (see - :func:`~tcri.preprocessing._preprocessing.register_model`). - covariate : str - Covariate value :math:`m` to condition on. - temperature : float, default 1.0 - Sharpen (``<1``) or flatten (``>1``) the distributions before computing MI. - n_samples : int, default 0 - ``0`` returns a single point estimate; ``> 0`` returns one MI value per - posterior draw. - clones : list of str, optional - Restrict to these clonotypes; default uses all. - normalised : bool, default True - Normalize the MI (see ``normalise_mode``) to :math:`[0, 1]`. - normalise_mode : str, default "average" - Normalization denominator when ``normalised`` is True (e.g. the average of - the two marginal entropies). - posterior : bool, default True - Use a Dirichlet draw of :math:`p_{ct}` (optionally combined with the - classifier logits). If False, use the prior-only joint distribution. - combine_with_logits : bool, default True - Combine the sampled prior with the per-cell logits (only when ``posterior``). - verbose : bool, default True - Print a short progress summary. - graph : bool, default False - Print an ASCII histogram of the posterior MI draws. - - Returns - ------- - float or numpy.ndarray - A single (normalized) MI in bits if ``n_samples == 0``; otherwise an array - of shape ``(n_samples,)`` of per-draw values. - - Notes - ----- - .. math:: - - I(c; \phi) = \sum_{c,\, \phi} p(c, \phi)\, - \log_2 \frac{p(c, \phi)}{p(c)\, p(\phi)} - - See Also - -------- - clonotypic_entropy, phenotypic_entropy : the marginal entropies MI builds on. - - Examples - -------- - >>> covariate = adata.uns["tcri_covariate_categories"][0] - >>> mutual_information(adata, covariate, verbose=False) - 0.42 - """ - - if verbose: - print(f"{BOLD}{MAGENT}📊 MI for '{covariate}'{RESET}") - _info("posterior", posterior) - _info("n_samples", n_samples) - - # helper to obtain *one* joint table - def _get_df(silent_flag: bool): - if posterior: - return joint_distribution_posterior( - adata, - covariate_label = covariate, - temperature = temperature, - clones = clones, - combine_with_logits = combine_with_logits, - silent = silent_flag, - ) - else: - # fall back to prior-only version (not shown here) - raise NotImplementedError("prior-only joint_distribution not included") - - # ---------- single draw ---------------------------------------- - if n_samples == 0: - df = _get_df(silent_flag=not verbose) - pxy = df.to_numpy().astype(float) - pxy /= pxy.sum() - mi = _mi_from_joint(pxy, normalised, normalise_mode) - _ok("computed MI", quiet=not verbose) - _info("value", f"{mi:.4f}", quiet=not verbose) - return mi - - # ---------- multiple draws ------------------------------------- - mi_samples = np.empty(n_samples, dtype=float) - iter_bar = tqdm(range(n_samples), disable=not verbose, leave=False, - desc=f"sampling MI [{covariate}]") - for i in iter_bar: - df = _get_df(silent_flag=True) - pxy = df.to_numpy().astype(float) - pxy /= pxy.sum() - mi_samples[i] = _mi_from_joint(pxy, normalised, normalise_mode) - - _ok("computed MI for all samples", quiet=not verbose) - _info("mean ± sd", - f"{mi_samples.mean():.4f} ± {mi_samples.std():.4f}", - quiet=not verbose) - _info("95% CI", - f"[{np.percentile(mi_samples,2.5):.4f}, " - f"{np.percentile(mi_samples,97.5):.4f}]", - quiet=not verbose) - - if graph and n_samples > 1 and verbose: - print(f"{DIM}\nASCII histogram of MI posterior:\n" - f"{_ascii_hist(mi_samples)}{RESET}") - - return mi_samples - - - -# ──────────────────────────────────────────────────────────────── -def flux_table( - adata, - *, - cov_pre: str = "Pre-treatment", - cov_post: str = "Post-treatment", - splitby: str = "response", - n_samples: int = 0, - temperature: float = 1.0, - weighted: bool = False, - posterior: bool = True, - combine_with_logits: bool = True, - distance_metric: str = "l1", - seed: Optional[int] = 42, - show_progress: bool = True -) -> pd.DataFrame: - """ - Build a tidy table with per-clone flux distances and clone size. - - Nested progress bars: - • outer: groups in `splitby` - • inner: clones within that group - """ - - if seed is not None: - np.random.seed(seed) - - meta = adata.uns[K.METADATA] - clone_col = meta["clone_col"] - - groups = sorted(adata.obs[splitby].dropna().unique().tolist()) - records = [] - - outer_it = groups if not show_progress else tqdm(groups, desc="groups") - - for g in outer_it: - # -------- subset AnnData to *this* group ----------------- - mask_g = adata.obs[splitby] == g - adata_g = adata[mask_g] - clones_g = adata_g.obs[clone_col].unique().tolist() - - # -------- clone sizes (within this group) ---------------- - c_sizes = adata_g.obs[clone_col].value_counts().to_dict() - - # -------- flux distances (vector, index = clone_id) ----- - dist = flux( - adata, - from_this = cov_pre, - to_that = cov_post, - clones = clones_g, - temperature = temperature, - distance_metric = distance_metric, - n_samples = n_samples, - weighted = weighted, - posterior = posterior, - combine_with_logits = combine_with_logits, - graph = False # keep helper quiet - ) - - # nested bar over clones ---------------------------------- - inner_it = clones_g if not show_progress else tqdm( - clones_g, desc=f"{g}: clones", leave=False) - - for cl in inner_it: - if n_samples == 0: - val = float(dist.get(cl, np.nan)) - sd = 0.0 - vec = np.array([val]) - else: - # `dist` is (n_samples, n_clones); retrieve column - idx = clones_g.index(cl) - vec = dist[:, idx] - val = float(vec.mean()) - sd = float(vec.std(ddof=1)) - - records.append(dict( - **{splitby: g, "clone_id": cl}, - flux_samples = vec, - flux_mean = val, - flux_sd = sd, - clone_size = c_sizes.get(cl, 0) - )) - - return pd.DataFrame.from_records(records) - - -# ──────────────────────────────────────────────────────────────── -def flux( - adata, - *, - from_this : str, - to_that : str, - clones : Optional[Union[str, List[str]]] = None, - temperature : float = 1.0, - distance_metric : Union[str, callable] = "l1", # MODIFIED: Type hint updated - n_samples : int = 0, # posterior draws - weighted : bool = False, - posterior : bool = True, - combine_with_logits : bool = True, - graph : bool = False, # ASCII histogram - seed : Optional[int] = 42 -) -> Union[pd.Series, np.ndarray]: - r"""Phenotypic flux of each clonotype between two covariate values. - - For each clonotype, the distance between its phenotype distribution at - ``from_this`` and at ``to_that`` — how much the clone's phenotype mix shifts - across the two covariates. Useful for tracking phenotypic movement over time or - treatment. - - Parameters - ---------- - adata : AnnData - Registered object (see - :func:`~tcri.preprocessing._preprocessing.register_model`). - from_this, to_that : str - The two covariate values to compare (e.g. ``"Pre-treatment"`` and - ``"Post-treatment"``). - clones : str or list of str, optional - Restrict to these clonotypes; default uses all clones present. - temperature : float, default 1.0 - Sharpen (``<1``) or flatten (``>1``) the distributions. - distance_metric : str or callable, default "l1" - Distance between the two phenotype distributions — e.g. ``"l1"`` or - ``"dkl"``, or a callable ``f(p, q) -> float``. - n_samples : int, default 0 - ``0`` returns a point estimate per clone; ``> 0`` returns per-draw values. - weighted : bool, default False - Weight clones by size when building the joint distribution. - posterior : bool, default True - Use posterior draws of :math:`p_{ct}` (vs the prior-only joint). - combine_with_logits : bool, default True - Combine the sampled prior with the per-cell logits (only when ``posterior``). - graph : bool, default False - Print an ASCII histogram of the flux distribution. - seed : int, optional - Seed for the posterior sampling (default 42). - - Returns - ------- - pandas.Series or numpy.ndarray - A Series indexed by clonotype if ``n_samples == 0``; otherwise an array of - shape ``(n_samples, n_clones)`` whose rows are posterior draws. - - Raises - ------ - ValueError - If the requested clones do not overlap the data at both covariates. - - See Also - -------- - phenotypic_entropy : per-clone phenotypic spread at a single covariate. - - Examples - -------- - >>> flux(adata, from_this="T1", to_that="T2").sort_values(ascending=False).head() - """ - if seed is not None: - np.random.seed(seed) - - # ---------- which clones? ---------------------------------- - clone_col = adata.uns[K.METADATA]["clone_col"] - if clones is None: - clones = adata.obs[clone_col].unique().tolist() - elif isinstance(clones, str): - clones = [clones] - - # ---------- get joint tables ------------------------------- - get = joint_distribution_posterior if posterior else joint_distribution - - jd_from = get( - adata, - covariate_label = from_this, - temperature = temperature, - clones = clones, - weighted = weighted, - combine_with_logits = combine_with_logits if posterior else None, - silent = True - ) - jd_to = get( - adata, - covariate_label = to_that, - temperature = temperature, - clones = clones, - weighted = weighted, - combine_with_logits = combine_with_logits if posterior else None, - silent = True - ) - - if jd_from.empty or jd_to.empty: - raise ValueError("No overlap between requested clones and data.") - - common = jd_from.index.intersection(jd_to.index) - - # --- MINIMAL CHANGE BLOCK 1 --- - # Define _dkl once if needed - _dkl = None - if isinstance(distance_metric, str) and distance_metric.lower() == "dkl": - eps = 1e-15 - def dkl_func(p, q): - p = p.clip(eps)/p.sum(); q = q.clip(eps)/q.sum() - return float(np.sum(p*np.log(p/q))) - _dkl = dkl_func - - if isinstance(distance_metric, collections.abc.Callable): - dist = pd.Series( - {cl: distance_metric(jd_from.loc[cl], jd_to.loc[cl]) for cl in common} - ) - elif isinstance(distance_metric, str) and distance_metric.lower() == "l1": - dist = (jd_from.loc[common] - jd_to.loc[common]).abs().sum(axis=1) - elif _dkl is not None: - dist = pd.Series( - {cl: _dkl(jd_from.loc[cl], jd_to.loc[cl]) for cl in common} - ) - else: - raise ValueError("distance_metric must be 'l1', 'dkl', or a callable function.") - # --- END OF CHANGE --- - - # ---------- single / multi-sample handling ----------------- - if n_samples == 0: - return dist - - # posterior draws (one extra call per sample) - samples = np.empty((n_samples, len(common)), dtype=float) - for i in range(n_samples): - jd_from_s = get(adata, covariate_label = from_this, temperature=temperature, - clones=clones, weighted=weighted, - combine_with_logits=combine_with_logits if posterior else None, - silent=True) - jd_to_s = get(adata, covariate_label = to_that, temperature=temperature, - clones=clones, weighted=weighted, - combine_with_logits=combine_with_logits if posterior else None, - silent=True) - - # --- MINIMAL CHANGE BLOCK 2 --- - if isinstance(distance_metric, collections.abc.Callable): - samples[i] = [distance_metric(jd_from_s.loc[c], jd_to_s.loc[c]) for c in common] - elif isinstance(distance_metric, str) and distance_metric.lower() == "l1": - samples[i] = (jd_from_s.loc[common] - jd_to_s.loc[common]).abs().sum(axis=1) - else: # Handles the 'dkl' case using the function defined above - samples[i] = [ _dkl(jd_from_s.loc[c], jd_to_s.loc[c]) for c in common ] - # --- END OF CHANGE --- - - if graph: - print(_ascii_hist(samples.ravel())) - - return samples \ No newline at end of file diff --git a/tcri/model/_classifier.py b/tcri/model/_classifier.py new file mode 100644 index 0000000..8c41e43 --- /dev/null +++ b/tcri/model/_classifier.py @@ -0,0 +1,23 @@ +"""Phenotype classifier head for the TCRI Pyro module (:class:`~tcri.model._module.TCRIModule`).""" +import torch.nn as nn + +__all__ = ["PhenotypeClassifier"] + + +class PhenotypeClassifier(nn.Module): + def __init__(self, n_latent, classifier_hidden, P, num_layers=3, dropout_rate=0.1, temperature=1.0): + super(PhenotypeClassifier, self).__init__() + layers = [] + input_dim = n_latent + for _ in range(num_layers): + layers.append(nn.Linear(input_dim, classifier_hidden)) + layers.append(nn.ReLU()) + layers.append(nn.Dropout(dropout_rate)) + input_dim = classifier_hidden + layers.append(nn.Linear(classifier_hidden, P)) + self.mlp = nn.Sequential(*layers) + self.temperature = temperature # Add temperature parameter + + def forward(self, x): + logits = self.mlp(x) + return logits / self.temperature diff --git a/tcri/model/_model.py b/tcri/model/_model.py index 8188252..ecfe3fe 100644 --- a/tcri/model/_model.py +++ b/tcri/model/_model.py @@ -1,33 +1,40 @@ +"""User-facing scvi-tools model class :class:`TCRIModel`. + +The generative model, priors, classifier, and training plan live in sibling +modules; this file holds only the high-level `BaseModelClass` API +(`setup_anndata`, `__init__`, `train`, `get_latent_representation`, +`predict`, `to_anndata`, `get_p_ct`, ...): + +- :mod:`._module` -- Pyro model/guide (:class:`TCRIModule`) +- :mod:`._priors` -- :class:`MixtureDirichlet`, :class:`VampPrior` +- :mod:`._classifier` -- :class:`PhenotypeClassifier` +- :mod:`._training` -- :class:`UnifiedTrainingPlan`, :func:`build_archetypes` +""" import logging +import os +import warnings + import numpy as np import pandas as pd +import pyro import torch -import matplotlib.pyplot as plt import torch.nn.functional as F -import pyro -import pyro.distributions as dist -import pyro.poutine as poutine -import torch.nn as nn - +import matplotlib.pyplot as plt -from typing import Dict, Optional +from typing import Optional from anndata import AnnData -import os -from scvi.data.fields import CategoricalObsField, LayerField +from scvi import REGISTRY_KEYS from scvi.data import AnnDataManager +from scvi.data.fields import CategoricalObsField, LayerField from scvi.model.base import BaseModelClass -from scvi.train import PyroTrainingPlan, TrainRunner -from scvi import REGISTRY_KEYS -from scvi.nn import Encoder, DecoderSCVI -from scvi.module.base import PyroBaseModuleClass, auto_move_data -from scvi.utils import setup_anndata_dsp +from scvi.train import TrainRunner from scvi.dataloaders import DataSplitter -from torch.nn.functional import cosine_similarity -from pyro.infer import TraceEnum_ELBO, Trace_ELBO -from torch.distributions import Categorical, Dirichlet, MixtureSameFamily -from sklearn.cluster import KMeans -import warnings + +from ._module import TCRIModule +from ._training import UnifiedTrainingPlan, build_archetypes + +__all__ = ["TCRIModel"] warnings.filterwarnings("ignore", category=UserWarning, message="Found auxiliary vars") warnings.filterwarnings( @@ -41,637 +48,27 @@ logger = logging.getLogger(__name__) - -def build_archetypes(c2p_mat, K=4): - kmeans = KMeans(n_clusters=K, random_state=42) - labels = kmeans.fit_predict(c2p_mat) - centers = kmeans.cluster_centers_ - centers = np.clip(centers, 1e-8, None) - centers = centers / centers.sum(axis=1, keepdims=True) - return centers, labels - - -class PhenotypeClassifier(nn.Module): - def __init__(self, n_latent, classifier_hidden, P, num_layers=3, dropout_rate=0.1, temperature=1.0): - super(PhenotypeClassifier, self).__init__() - layers = [] - input_dim = n_latent - for _ in range(num_layers): - layers.append(nn.Linear(input_dim, classifier_hidden)) - layers.append(nn.ReLU()) - layers.append(nn.Dropout(dropout_rate)) - input_dim = classifier_hidden - layers.append(nn.Linear(classifier_hidden, P)) - self.mlp = nn.Sequential(*layers) - self.temperature = temperature # Add temperature parameter - - def forward(self, x): - logits = self.mlp(x) - return logits / self.temperature - - -class VampPrior(torch.nn.Module): - def __init__(self, pseudo_inputs, encoder): - """ - Args: - pseudo_inputs (torch.Tensor): Initial pseudo-inputs of shape (K, input_dim). - encoder (torch.nn.Module): Encoder that takes an input and returns (mean, log_var) - for the approximate posterior q(z|x). - """ - super(VampPrior, self).__init__() - # Learnable pseudo-inputs; these are optimized during training. - self.pseudo_inputs = torch.nn.Parameter(pseudo_inputs) - self.encoder = encoder - - def get_mixture(self): - """ - Constructs the VampPrior as a uniform mixture of q(z|u_k) for each pseudo-input u_k. - """ - # Compute the approximate posterior parameters for each pseudo-input. - # Expected output shapes: means and log_vars: (K, latent_dim) - K = self.pseudo_inputs.size(0) - # Create a dummy categorical argument; unsqueeze to have shape (K, 1) - dummy_batch = torch.zeros(K, dtype=torch.long, device=self.pseudo_inputs.device).unsqueeze(1) - means, log_vars, _ = self.encoder(self.pseudo_inputs, dummy_batch) - scales = torch.sqrt(torch.exp(log_vars)) - component_dist = dist.Independent(dist.Normal(means, scales), 1) - mixture_weights = torch.ones(K, device=self.pseudo_inputs.device) / K - mixture = dist.MixtureSameFamily( - dist.Categorical(mixture_weights), - component_dist - ) - return mixture - - def log_prob(self, z): - """ - Computes log p(z) under the VampPrior. - """ - return self.get_mixture().log_prob(z) - - def sample(self, sample_shape=torch.Size()): - """ - Draws samples from the VampPrior. - """ - return self.get_mixture().sample(sample_shape) - - -############################################################################### -# 0) Mixture of Dirichlet Distributions - TODO: Refactor -############################################################################### -class MixtureDirichlet(dist.TorchDistribution): - """ - Mixture of Dirichlet distributions parameterized by mixture weights and concentration parameters." - """ - arg_constraints = { - "mixture_weights": dist.constraints.simplex, # shape: batch_shape + (B,) - "concentration": dist.constraints.positive, # shape: batch_shape + (B, K) - } - support = dist.constraints.simplex # each sample is a simplex over K categories - has_rsample = False - - def __init__( - self, - mixture_weights: torch.Tensor, - concentration: torch.Tensor, - validate_args=None, - ): - """ - mixture_weights: Tensor of shape batch_shape + (B,), with each row summing to 1. - concentration: Tensor of shape batch_shape + (B, K), where K is the number of categories. - """ - self.mixture_weights = mixture_weights - # Clamp concentrations to ensure positivity. - self.concentration = torch.clamp(concentration, min=1e-3) - # Determine batch shape, B, and K. - batch_shape = self.mixture_weights.shape[:-1] - self.B = self.mixture_weights.size(-1) - self.K = self.concentration.size(-1) - event_shape = (self.K,) - super(MixtureDirichlet, self).__init__( - batch_shape, event_shape, validate_args=validate_args - ) - - def sample(self, sample_shape=torch.Size()): - """ - Returns a sample of shape: sample_shape + batch_shape + (K,). - For each batch element, first sample a mixture component, then sample from the corresponding Dirichlet. - """ - # Create categorical for mixture weights. - cat = dist.Categorical(self.mixture_weights) - # Sample mixture indices; shape: sample_shape + batch_shape. - mixture_idx = cat.sample(sample_shape) - full_shape = mixture_idx.shape # sample_shape + batch_shape - - # Expand concentration to shape: sample_shape + batch_shape + (B, K). - target_shape = sample_shape + self.concentration.shape - expanded_concentration = self.concentration.expand(target_shape) - - # Flatten the sample and batch dimensions. - flat_shape = (-1, self.B, self.K) - flat_concentration = expanded_concentration.reshape(flat_shape) - flat_idx = mixture_idx.reshape(-1) # shape: (num_samples,) - - # Select the concentration parameters corresponding to the sampled mixture index. - selected_concentration = flat_concentration[ - torch.arange(flat_idx.size(0)), flat_idx - ] - - # Sample from the Dirichlet for each sample. - flat_samples = dist.Dirichlet(selected_concentration).sample() - # Reshape to sample_shape + batch_shape + (K,). - return flat_samples.reshape(full_shape + (self.K,)) - - def log_prob(self, value): - device = value.device # get the device from input tensor - - # Move tensors explicitly to the same device - value_expanded = value.unsqueeze(-2).to(device) - expanded_concentration = self.concentration.expand( - value.shape[:-1] + (self.B, self.K) - ).to(device) - - d = dist.Dirichlet(expanded_concentration) - - component_log_probs = d.log_prob( - value_expanded.expand(expanded_concentration.shape) - ) - - expanded_weights = self.mixture_weights.expand(value.shape[:-1] + (self.B,)).to(device) - mixture_log = torch.log(expanded_weights) - - return torch.logsumexp(mixture_log + component_log_probs, dim=-1) - - def score_parts(self, value): - # Compute log probability. - lp = self.log_prob(value) - # Return dummy zeros for the score function and entropy terms. - zeros = torch.zeros_like(lp) - return lp, zeros, zeros - - def __call__(self, *args, **kwargs): - return self.sample(*args, **kwargs) - - -############################################################################### -# 1) Pyro Module with CVAE + Hierarchical Priors -############################################################################### -class TCRIModule(PyroBaseModuleClass): - """ - Two-level model that incorporates hierarchical priors (clonotype-level) - and a CVAE structure that explicitly conditions gene expression on the - observed cell-level phenotype. - """ - - def __init__( - self, - n_input: int, - n_latent: int, - P: int, - n_batch: int, - global_scale: float = 10.0, - local_scale: float = 5.0, - prior_temperature: float = 1.0, - guide_temperature: float = 1.0, - gate_prob: float = 0.5, - mixture_concentration: torch.Tensor = None, - n_pseudo_obs: int = 10, - use_enumeration: bool = False, - classifier_hidden: int = 128, - classifier_dropout: float = 0.1, - classifier_n_layers: int = 3, - n_hidden: int = 128, - n_layers: int = 3, - class_weights: torch.Tensor = None, - kl_weight_max: float = 1.0, - guide_init_scale: float = 10.0, - classifier_temperature: float = 1.0, - ): - super().__init__() - self.n_input = n_input - self.n_latent = n_latent - self.P = P - self.n_hidden = n_hidden - self.n_layers = n_layers - self.global_scale = global_scale - self.local_scale = local_scale - self.prior_temperature = prior_temperature - self.guide_temperature = guide_temperature - self.mixture_concentration = mixture_concentration - self.n_pseudo_obs = n_pseudo_obs - self.gate_prob = gate_prob - # Assert that it is not None - assert ( - self.mixture_concentration is not None - ), "mixture_concentration must be provided" - self.use_enumeration = use_enumeration - self.eps = 1e-6 - self.classifier_hidden = classifier_hidden - self.classifier_dropout = classifier_dropout - self.kl_weight_max = kl_weight_max - self.classifier_n_layers = classifier_n_layers - self.guide_init_scale = guide_init_scale - self.classifier_temperature = classifier_temperature - - # Defaults so model()/guide() work before train() sets them - self.kl_weight = 1e-6 - self.reconstruction_loss_scale = 1e-3 - - self.encoder = Encoder( - n_input=n_input, - n_output=n_latent, - n_layers=n_layers, - n_hidden=n_hidden, - n_cat_list=[n_batch], - use_layer_norm=True, - ) - - # VampPrior - pseudo_inputs = torch.randn(self.n_pseudo_obs, self.n_input) - self.vamp_prior = VampPrior(pseudo_inputs, self.encoder) - - self.decoder_input_dim = self.n_latent - self.decoder = DecoderSCVI( - self.decoder_input_dim, - n_input, - n_layers=n_layers, - n_hidden=n_hidden, - n_cat_list=[n_batch], - scale_activation="softplus", - use_layer_norm=True, - ) - - self.px_r = torch.nn.Parameter(torch.ones(n_input)) - - self.classifier = PhenotypeClassifier( - n_latent=self.n_latent, - classifier_hidden=self.classifier_hidden, - P=self.P, - num_layers=self.classifier_n_layers, - temperature=self.classifier_temperature, - ) - - self.register_buffer("clone_phen_prior", torch.empty(0)) - self.register_buffer("ct_to_c", torch.empty(0, dtype=torch.long)) - self.register_buffer("c_array", torch.empty(0, dtype=torch.long)) - self.register_buffer("ct_array", torch.empty(0, dtype=torch.long)) - self.register_buffer("ct_to_cov", torch.empty(0, dtype=torch.long)) - self.c_count = 0 - self.ct_count = 0 - self.n_cells = 0 - - self.register_buffer("_target_phenotypes", torch.empty(0, dtype=torch.long)) - - # Store or compute log of class weights if provided - if class_weights is not None: - # Expect a tensor of shape (P,) - self.register_buffer("log_class_weights", torch.log(class_weights)) - else: - self.log_class_weights = None - - def prepare_two_level_params( - self, - c_count: int, - ct_count: int, - clone_phen_prior_mat: torch.Tensor, - ct_to_c_array: torch.Tensor, - c_array_for_cells: torch.Tensor, - ct_array_for_cells: torch.Tensor, - target_phenotypes: torch.Tensor, - ct_to_cov_array: torch.Tensor = None, - ): - self.c_count = c_count - self.ct_count = ct_count - self.n_cells = c_array_for_cells.shape[0] - - prior_mat = clone_phen_prior_mat + self.eps - prior_mat = prior_mat / prior_mat.sum(dim=1, keepdim=True) - - if self.prior_temperature != 1.0: - prior_mat = prior_mat ** (1.0 / self.prior_temperature) - prior_mat = prior_mat / prior_mat.sum(dim=1, keepdim=True) - - self.register_buffer("clone_phen_prior", prior_mat) - self.register_buffer("ct_to_c", ct_to_c_array) - self.register_buffer("c_array", c_array_for_cells) - self.register_buffer("ct_array", ct_array_for_cells) - self.register_buffer("_target_phenotypes", target_phenotypes) - - if ct_to_cov_array is not None: - self.register_buffer("ct_to_cov", ct_to_cov_array) - - @property - def use_gate(self) -> bool: - return self.gate_prob is not None - - @staticmethod - def _get_fn_args_from_batch(tensor_dict: Dict[str, torch.Tensor]): - x = tensor_dict[REGISTRY_KEYS.X_KEY] - batch_idx = tensor_dict[REGISTRY_KEYS.BATCH_KEY].long() - log_library = torch.log(torch.sum(x, dim=1, keepdim=True) + 1e-6) - return (x, batch_idx, log_library), {} - - @auto_move_data - def model( - self, x: torch.Tensor, batch_idx: torch.Tensor, log_library: torch.Tensor - ): - pyro.module("scvi", self) - - kl_weight = self.kl_weight - batch_size = x.shape[0] - - with pyro.plate("clonotypes", self.c_count): - B = self.mixture_concentration.shape[0] - mixture_weights = torch.ones(B, device=x.device) / B - # Expand mixture parameters to add a leading dimension for clonotypes. - # expanded_conc will have shape (self.c_count, B, K) - expanded_conc = self.mixture_concentration.unsqueeze(0).expand( - self.c_count, -1, -1 - ) - # expanded_weights will have shape (self.c_count, B) - expanded_weights = mixture_weights.unsqueeze(0).expand(self.c_count, -1) - mixture_dist = MixtureDirichlet(expanded_weights, expanded_conc) - p_c = pyro.sample("p_c", mixture_dist) - # print("p_c shape:", p_c.shape) - - with pyro.plate("ct_plate", self.ct_count): - base_p = p_c[self.ct_to_c] + self.eps - conc_ct = torch.clamp(self.local_scale * base_p, min=1e-3) - p_ct = pyro.sample("p_ct", dist.Dirichlet(conc_ct)) - - # Encoder - z_loc, z_scale, _ = self.encoder(x, batch_idx) - z_scale = torch.clamp(z_scale, min=1e-3, max=10.0) - - with pyro.plate("data", batch_size) as idx: - - with poutine.scale(scale=kl_weight): - # vamp_mixture = self.vamp_prior.get_mixture().to_event(1) - vamp_mixture = self.vamp_prior.get_mixture() - z = pyro.sample("latent", vamp_mixture) - - ct_idx = self.ct_array[idx] - prior_log = torch.log(p_ct[ct_idx] + 1e-8) # log of local p_ct - cls_logits = self.classifier(z)# + self.phenotype_decoder(z) - - px_scale, px_r_out, px_rate, px_dropout = self.decoder( - "gene", z, log_library, batch_idx - ) - - zi_gate_probs = torch.sigmoid(px_dropout).clamp(min=1e-3, max=1.0 - 1e-3) - nb_logits = (px_rate + self.eps).log() - (self.px_r.exp() + self.eps).log() - nb_logits = torch.clamp(nb_logits, min=-10.0, max=10.0) - total_count = self.px_r.exp().clamp(max=1e4) - - x_dist = dist.ZeroInflatedNegativeBinomial( - gate=zi_gate_probs, - total_count=total_count, - logits=nb_logits, - validate_args=False, - ) - scale_val = torch.tensor(self.reconstruction_loss_scale, device=x.device) - with poutine.scale(scale=scale_val): - pyro.sample("obs", x_dist.to_event(1), obs=x) - - @auto_move_data - def guide( - self, x: torch.Tensor, batch_idx: torch.Tensor, log_library: torch.Tensor - ): - pyro.module("scvi", self) - batch_size = x.shape[0] - - with pyro.plate("clonotypes", self.c_count): - # Start from a scaled version of the prior. - init_mat_c = self.clone_phen_prior * self.guide_init_scale + 1e-3 - init_mat_c = init_mat_c.to(x.device) - - # Learnable raw parameters for q(p_c) - if "q_p_c_raw" not in pyro.get_param_store(): - q_p_c_raw = pyro.param( - "q_p_c_raw", - init_mat_c.clone().detach(), - constraint=dist.constraints.positive - ) - else: - q_p_c_raw = pyro.param("q_p_c_raw") - - bad_c = ~torch.isfinite(q_p_c_raw) - if bad_c.any(): - q_p_c_raw = torch.where(bad_c, init_mat_c.to(q_p_c_raw.device), q_p_c_raw) - - # Apply a sharpening transformation controlled by guide_temperature. - q_p_c_sharp = q_p_c_raw ** (1.0 / self.guide_temperature) - q_p_c_sharp = torch.clamp(q_p_c_sharp, min=1e-8) # ← add this - q_p_c_sharp = q_p_c_sharp / q_p_c_sharp.sum(dim=1, keepdim=True) - conc_c_guide = torch.clamp(self.global_scale * q_p_c_sharp, min=1e-3) - - # Sample p_c from a single learned Dirichlet per clonotype. - pyro.sample("p_c", dist.Dirichlet(conc_c_guide)) - - with pyro.plate("ct_plate", self.ct_count): - init_mat = self.clone_phen_prior[self.ct_to_c, :] - init_mat = init_mat * self.guide_init_scale + 1e-3 - init_mat = init_mat.to(x.device) - if "q_p_ct_raw" not in pyro.get_param_store(): - q_p_ct_raw = pyro.param( - "q_p_ct_raw", - init_mat.clone().detach(), # Make sure it's not a leaf - constraint=dist.constraints.positive, - ) - else: - q_p_ct_raw = pyro.param("q_p_ct_raw") - - bad_ct = ~torch.isfinite(q_p_ct_raw) - if bad_ct.any(): - q_p_ct_raw = torch.where(bad_ct, init_mat.to(q_p_ct_raw.device), q_p_ct_raw) - - q_p_ct_sharp = q_p_ct_raw ** (1.0 / self.guide_temperature) - q_p_ct_sharp = torch.clamp(q_p_ct_sharp, min=1e-8) - q_p_ct_sharp = q_p_ct_sharp / q_p_ct_sharp.sum(dim=1, keepdim=True) - conc_ct_guide = torch.clamp(self.local_scale * q_p_ct_sharp, min=1e-3) - pyro.sample("p_ct", dist.Dirichlet(conc_ct_guide)) - - z_loc, z_scale, _ = self.encoder(x, batch_idx) - z_scale = torch.clamp(z_scale, min=1e-3, max=10.0) - - with pyro.plate("data", batch_size) as idx: - latent_posterior = dist.Normal(z_loc, z_scale) - with pyro.poutine.scale(scale=self.kl_weight): - z = pyro.sample("latent", latent_posterior.to_event(1)) - - @auto_move_data - def get_latent(self, tensor_dict: Dict[str, torch.Tensor]): - x = tensor_dict[REGISTRY_KEYS.X_KEY] - batch_idx = tensor_dict[REGISTRY_KEYS.BATCH_KEY].long() - z_loc, _, _ = self.encoder(x, batch_idx) - if z_loc.ndim == 3: - z_loc = z_loc.mean(dim=1) - return z_loc.cpu() - - @torch.no_grad() - def get_p_ct(self): - from pyro import get_param_store - - param_store = get_param_store() - q_p_ct_raw = param_store["q_p_ct_raw"] - bad = ~torch.isfinite(q_p_ct_raw) - if bad.any(): - n_phen = q_p_ct_raw.shape[1] - q_p_ct_raw = torch.where(bad, torch.ones_like(q_p_ct_raw) / n_phen, q_p_ct_raw) - if self.guide_temperature != 1.0: - q_p_ct_sharp = q_p_ct_raw ** (1.0 / self.guide_temperature) - q_p_ct_sharp = q_p_ct_sharp / q_p_ct_sharp.sum(dim=1, keepdim=True) - else: - q_p_ct_sharp = q_p_ct_raw / q_p_ct_raw.sum(dim=1, keepdim=True) - return q_p_ct_sharp - - -############################################################################### -# 2) Unified Training Plan with Validation Step for scvi Early Stopping -############################################################################### -class UnifiedTrainingPlan(PyroTrainingPlan): - """ - Training plan that includes classification, reconstruction losses, - KL warmup, plus a validation_step that logs 'elbo_validation' so scvi's - early stopping can monitor it. - """ - - def __init__( - self, - module: TCRIModule, - n_steps_kl_warmup: int = 1000, - reconstruction_loss_scale: float = 1e-2, - num_particles: int = 5, - optimizer_config: dict = None, - class_weights: torch.Tensor = None, - **kwargs, - ): - self.num_particles = num_particles - if module.use_enumeration: - print("Using Enumeration") - self._loss_fn = TraceEnum_ELBO( - max_plate_nesting=3, num_particles=self.num_particles - ) - else: - self._loss_fn = Trace_ELBO() - - super().__init__(module, n_steps_kl_warmup=n_steps_kl_warmup, **kwargs) - - self.n_steps_kl_warmup = n_steps_kl_warmup - self.reconstruction_loss_scale = reconstruction_loss_scale - self._my_global_step = 0 - self.class_weights = class_weights - self.optimizer_config = optimizer_config - - if optimizer_config is None: - optimizer_config = {"lr":1e-3,"betas":(0.9,0.999),"eps":1e-5,"weight_decay":1e-4} - self.optimizer_config = optimizer_config - - @property - def loss(self): - return self._loss_fn - - def configure_optimizers(self): - optimizer = torch.optim.Adam( - self.module.parameters(), - lr=self.optimizer_config["lr"], - betas=self.optimizer_config["betas"], - eps=self.optimizer_config["eps"], - weight_decay=self.optimizer_config["weight_decay"], - ) - return {"optimizer": optimizer} - - def training_step(self, batch, batch_idx): - # ── KL warmup ──────────────────────────────────────────── - if self.n_steps_kl_warmup > 0 and self._my_global_step < self.n_steps_kl_warmup: - kl_weight = max(1e-6, self.module.kl_weight_max * (self._my_global_step / self.n_steps_kl_warmup)) - else: - kl_weight = self.module.kl_weight_max - self.module.kl_weight = kl_weight - - # ── Pyro ELBO step ─────────────────────────────────────── - loss_dict = super().training_step(batch, batch_idx) - device = next(self.module.parameters()).device - - if not isinstance(loss_dict["loss"], torch.Tensor): - loss_dict["loss"] = torch.tensor(loss_dict["loss"], device=device, requires_grad=True) - else: - loss_dict["loss"] = loss_dict["loss"].to(device) - - # ── Diagnostics (no gradient contribution) ─────────────── - with torch.no_grad(): - z_diag = self.module.get_latent(batch).to(device) - idx_diag = batch["indices"].long().view(-1).to(device) - cls_logits = self.module.classifier(z_diag) - ct_idx = self.module.ct_array[idx_diag] - p_ct_prior = self.module.get_p_ct()[ct_idx].to(device) - prior_log = torch.log(p_ct_prior + 1e-8) - - if self.module.use_gate: - local_logits = self.module.gate_prob * cls_logits + (1.0 - self.module.gate_prob) * prior_log - else: - local_logits = cls_logits + prior_log - - probs = F.softmax(local_logits, dim=-1) - kl_div = F.kl_div(probs.log(), p_ct_prior, reduction='batchmean') - entropy = -torch.sum(probs * torch.log(probs + 1e-8), dim=-1).mean() - confidence = (probs**2).sum(dim=-1).mean() - - self.log("kl_divergence_with_prior_train", kl_div, prog_bar=False, on_epoch=True) - self.log("entropy_train", entropy, prog_bar=False, on_epoch=True) - self.log("confidence_train", confidence, prog_bar=False, on_epoch=True) - - self._my_global_step += 1 - return loss_dict - - def validation_step(self, batch, batch_idx): - with torch.no_grad(): - self.module.eval() - val_dict = super().training_step(batch, batch_idx) - self.module.train() - - device = next(self.module.parameters()).device - - if not isinstance(val_dict["loss"], torch.Tensor): - val_dict["loss"] = torch.tensor(val_dict["loss"], device=device) - else: - val_dict["loss"] = val_dict["loss"].to(device) - - # ── Diagnostic only ────────────────────────────────────── - z_batch = self.module.get_latent(batch).to(device) - idx = batch["indices"].long().view(-1).to(device) - cls_logits = self.module.classifier(z_batch) - ct_idx = self.module.ct_array[idx] - p_ct_prior = self.module.get_p_ct()[ct_idx].to(device) - prior_log = torch.log(p_ct_prior + 1e-8) - - if self.module.use_gate: - local_logits = self.module.gate_prob * cls_logits + (1.0 - self.module.gate_prob) * prior_log - else: - local_logits = cls_logits + prior_log - - probs = F.softmax(local_logits, dim=-1) - kl_divergence = F.kl_div(probs.log(), p_ct_prior, reduction='batchmean') - self.log("kl_divergence_with_prior_val", kl_divergence, prog_bar=False, on_epoch=True) - - self.log("elbo_validation", val_dict["loss"], prog_bar=True, on_epoch=True) - return val_dict - - -############################################################################### -# 3) High-Level scVI Model with scvi Early Stopping -############################################################################### class TCRIModel(BaseModelClass): @classmethod def setup_anndata( cls, adata: AnnData, + *, layer: Optional[str] = None, clonotype_key: str = "unique_clone_id", phenotype_key: str = "phenotype_col", covariate_key: str = "timepoint", batch_key: str = "patient", **kwargs, - ): + ) -> None: + """Register clonotype/phenotype/covariate/batch/count fields with scvi. + + Registration only. Writes ``obs['indices']`` (scvi glue that the + training/validation steps consume via ``batch['indices']``) and records the + layer, but performs **no** analysis/label ``obs`` mutation and does **not** + stash the ``AnnDataManager`` in ``uns`` (the retired ``tcri_manager`` hack) — + learned outputs are written solely by :meth:`to_anndata`. + """ for col in [clonotype_key, phenotype_key, covariate_key, batch_key]: if col not in adata.obs: raise ValueError(f"{col} not in adata.obs!") @@ -694,12 +91,10 @@ def setup_anndata( adata_manager.registry["covariate_col"] = covariate_key adata_manager.registry["batch_col"] = batch_key cls.register_manager(adata_manager) - adata.uns["tcri_manager"] = adata_manager if layer is None: adata.uns.pop("tcri_layer", None) else: adata.uns["tcri_layer"] = layer - return adata def __init__( self, @@ -718,14 +113,33 @@ def __init__( classifier_dropout: float = 0.1, n_pseudo_obs: int = 10, K: int = 10, - phenotype_weights: Optional[Dict[str, float]] = None, - gate_prob: Optional[float] = None, + gate_prob: Optional[float] = 0.5, # π (gating weight, methods §Generative Model); None = additive kl_weight_max: float = 1.0, guide_init_scale: float = 10.0, classifier_temperature: float = 1.0, + phenotype_kl_weight: float = 1.0, **kwargs, ): super().__init__(adata) + + # Pyro's param store is PROCESS-GLOBAL: a second TCRIModel in the same session + # silently inherits the first model's fitted q_p_c_raw/q_p_ct_raw and network + # weights, so it starts from the previous fit instead of from scratch. We warn + # rather than clearing, because clearing here would destroy the params of a + # model loaded earlier in the session (load_tcri_session restores the store + # after construction). Proper per-instance namespacing is a design change. + _tcri_params = [k for k in pyro.get_param_store().keys() if k.startswith(("q_p_c", "q_p_ct", "scvi$$$"))] + if _tcri_params: + warnings.warn( + "The global Pyro param store already holds TCRI parameters " + f"({len(_tcri_params)} entries). This model will CONTINUE that fit " + "rather than start fresh. Call `pyro.clear_param_store()` before " + "constructing a new model (note this invalidates any model already " + "loaded in this session).", + UserWarning, + stacklevel=2, + ) + n_vars = self.summary_stats["n_vars"] clonotype_col = self.adata_manager.registry["clonotype_col"] phenotype_col = self.adata_manager.registry["phenotype_col"] @@ -742,13 +156,25 @@ def __init__( c_count = len(cvals.cat.categories) c_array_np = cvals.cat.codes.values pvals_np = ph_series.cat.codes.values - c2p_mat = np.zeros((c_count, P), dtype=np.float32) + clone_phenotype_prior = np.zeros((c_count, P), dtype=np.float32) for i in range(len(c_array_np)): - c2p_mat[c_array_np[i], pvals_np[i]] += 1 - c2p_mat += 1e-6 - c2p_mat = c2p_mat / c2p_mat.sum(axis=1, keepdims=True) - self.c2p_mat = c2p_mat - self.centers, self.labels = build_archetypes(self.c2p_mat, K=K) + clone_phenotype_prior[c_array_np[i], pvals_np[i]] += 1 + clone_phenotype_prior += 1e-6 + clone_phenotype_prior = clone_phenotype_prior / clone_phenotype_prior.sum(axis=1, keepdims=True) + self.clone_phenotype_prior = clone_phenotype_prior + # K archetypes are k-means centroids over clonotypes, so K > n_clonotypes is + # not satisfiable (sklearn raises "n_samples < n_clusters"). Clamp instead of + # crashing — a dataset with few clones is legitimate (and is exactly what the + # synthetic examples use). + if K > c_count: + warnings.warn( + f"K={K} archetypes requested but the data has only {c_count} " + f"clonotype(s); using K={c_count}.", + UserWarning, + stacklevel=2, + ) + K = c_count + self.centers, self.labels = build_archetypes(self.clone_phenotype_prior, K=K) cov_series = self.adata.obs[covariate_col].astype("category") cov_array_np = cov_series.cat.codes.values df_ct = pd.DataFrame({"c": c_array_np, "t": cov_array_np}) @@ -769,24 +195,6 @@ def __init__( batch_series = self.adata.obs[batch_col].astype("category") n_batch = len(batch_series.cat.categories) - if phenotype_weights is None: - # Automatically compute inverse-frequency weights for each phenotype - freq_count = ph_series.value_counts(sort=False) - class_weights_arr = [] - for cat_name in ph_series.cat.categories: - c = freq_count[cat_name] - # inverse-frequency weight - weight = 1.0 / c - class_weights_arr.append(weight) - class_weights = torch.tensor(class_weights_arr, dtype=torch.float32) - else: - class_weights_arr = [] - for cat_name in ph_series.cat.categories: - weight = phenotype_weights.get(cat_name, 1.0) - class_weights_arr.append(weight) - class_weights = torch.tensor(class_weights_arr, dtype=torch.float32) - - self.class_weights = class_weights self.module = TCRIModule( n_input=n_vars, n_latent=n_latent, @@ -803,15 +211,15 @@ def __init__( use_enumeration=use_enumeration, classifier_hidden=classifier_hidden, classifier_dropout=classifier_dropout, - class_weights=self.class_weights, gate_prob=gate_prob, kl_weight_max=kl_weight_max, n_pseudo_obs=n_pseudo_obs, guide_init_scale=guide_init_scale, classifier_temperature=classifier_temperature, + phenotype_kl_weight=phenotype_kl_weight, ) self.init_params_ = self._get_init_params(locals()) - c2p_torch = torch.tensor(c2p_mat, dtype=torch.float32) + c2p_torch = torch.tensor(clone_phenotype_prior, dtype=torch.float32) c_array_torch = torch.tensor(c_array_np, dtype=torch.long) ct_array_torch = torch.tensor(ct_array_np, dtype=torch.long) ct_to_c_torch = torch.tensor(ct_to_c_list, dtype=torch.long) @@ -838,7 +246,7 @@ def train( max_epochs: int = 1000, batch_size: int = 1000, lr: float = 1e-3, - reconstruction_loss_scale: float = 1e-3, + reconstruction_loss_scale: float = 1e-2, n_steps_kl_warmup: int = 2000, **kwargs, ): @@ -849,6 +257,21 @@ def train( """ # Create a train/val split self.module.reconstruction_loss_scale = reconstruction_loss_scale + + # batch_size >= n_obs means ONE optimizer step per epoch, so the fixed + # per-epoch overhead is paid per gradient update — the pathology behind the + # "9-hour" synthetic run (1000 cells, batch_size=20000, max_epochs=1e6). + n_obs = self.adata.n_obs + if batch_size >= n_obs: + warnings.warn( + f"batch_size={batch_size} >= n_obs={n_obs}: each epoch is a SINGLE " + "optimizer step, so per-epoch overhead dominates and `max_epochs` " + "becomes the number of gradient updates. Use a smaller batch_size " + "(e.g. 256-1024) for a comparable number of updates in far less time.", + UserWarning, + stacklevel=2, + ) + splitter = DataSplitter( self.adata_manager, train_size=0.9, @@ -860,7 +283,6 @@ def train( module=self.module, n_steps_kl_warmup=n_steps_kl_warmup, reconstruction_loss_scale=reconstruction_loss_scale, - class_weights=self.class_weights, optimizer_config={ "lr": lr, "betas": (0.9, 0.999), @@ -869,18 +291,22 @@ def train( }, ) + # Defaults the caller can override: setdefault (not hard-coded keywords) so + # passing e.g. early_stopping_patience=10 or accelerator="gpu" through + # train(**kwargs) works instead of raising "got multiple values for keyword". + kwargs.setdefault("early_stopping", True) + kwargs.setdefault("early_stopping_monitor", "elbo_validation") + kwargs.setdefault("early_stopping_mode", "min") + kwargs.setdefault("early_stopping_patience", self.patience) + kwargs.setdefault("check_val_every_n_epoch", 5) + kwargs.setdefault("accelerator", "auto") + kwargs.setdefault("devices", "auto") + runner = TrainRunner( self, training_plan=plan, data_splitter=splitter, max_epochs=max_epochs, - early_stopping=True, - early_stopping_monitor="elbo_validation", - early_stopping_mode="min", - early_stopping_patience=self.patience, - check_val_every_n_epoch=5, - accelerator="auto", - devices="auto", **kwargs, ) @@ -888,7 +314,7 @@ def train( return @torch.no_grad() - def get_latent_representation(self, adata=None, indices=None, batch_size=None): + def get_latent_representation(self, adata=None, indices=None, batch_size=4096): adata = self._validate_anndata(adata) scdl = self._make_data_loader( adata=adata, indices=indices, batch_size=batch_size @@ -905,71 +331,132 @@ def get_p_ct(self): return self.module.get_p_ct().cpu().numpy() @torch.no_grad() - def get_cell_phenotype_probs( - self, adata=None, batch_size: int = 256, eps: float = 1e-8 - ) -> np.ndarray: - """ - Computes the cell-level phenotype probabilities in the same way as training. - - If ``self.module.gate_prob`` is set (i.e. ``use_gate`` is True), uses: - local_logits = gate_prob * cls_logits + (1 - gate_prob) * log(prior) - Otherwise uses the additive (Bayesian product) rule: - local_logits = cls_logits + log(prior) - - Parameters - ---------- - adata - If None, defaults to the AnnData used in training. - batch_size : int - Mini-batch size for data loader. - eps : float - Small epsilon for numerical stability in logs. - - Returns - ------- - probs : np.ndarray - Array of shape (n_cells, P) of phenotype probabilities. + def predict(self, adata=None, *, batch_size: int = 4096, eps: float = 1e-8) -> pd.DataFrame: + """Per-cell phenotype-probability ``DataFrame`` (index ``adata.obs_names``, + columns = phenotypes) — the single source of phenotype probabilities. + + Combines classifier logits with ``log p_ct`` exactly as in training: if + ``self.module.gate_prob`` is set (``use_gate``), + ``gate_prob * cls_logits + (1 - gate_prob) * log(prior)``; otherwise the + additive rule ``cls_logits + log(prior)``. Renamed from + ``get_cell_phenotype_probs`` (which returned a bare ``ndarray``). The module + is put in ``eval`` mode so classifier dropout is off and the result is + deterministic; the sequential loader keeps the row order aligned to + ``obs_names``. """ adata = self._validate_anndata(adata) + self.module.eval() device = next(self.module.parameters()).device scdl = self._make_data_loader(adata=adata, batch_size=batch_size) - # The learned posterior p_ct -> shape (ct_count, P) p_ct = self.module.get_p_ct().to(device) - # Map each cell to its clonotype-covariate index -> shape (n_cells,) ct_array = self.module.ct_array.to(device) all_probs = [] current_idx = 0 - for tensors in scdl: x = tensors[REGISTRY_KEYS.X_KEY].to(device) b = tensors[REGISTRY_KEYS.BATCH_KEY].long().to(device) - this_batch_size = x.shape[0] - - # Which (clonotype, covariate) does each cell belong to? - ct_indices = ct_array[current_idx : current_idx + this_batch_size] - clone_cov_posterior = p_ct[ct_indices] # (batch_size, P) - - # Encode to get latent z + n = x.shape[0] + clone_cov_posterior = p_ct[ct_array[current_idx : current_idx + n]] z_loc, _, _ = self.module.encoder(x, b) - - # ----- 1) Compute classifier logits (same as training) ----- cls_logits = self.module.classifier(z_loc) - prior_log = torch.log(clone_cov_posterior + eps) if self.module.use_gate: local_logits = self.module.gate_prob * cls_logits + (1.0 - self.module.gate_prob) * prior_log else: local_logits = cls_logits + prior_log + all_probs.append(F.softmax(local_logits, dim=-1).cpu()) + current_idx += n - probs = F.softmax(local_logits, dim=-1) + probs = torch.cat(all_probs, dim=0).numpy() + phenotype_col = self.adata_manager.registry["phenotype_col"] + pheno_cats = self.adata.obs[phenotype_col].astype("category").cat.categories.tolist() + return pd.DataFrame(probs, index=adata.obs_names, columns=pheno_cats) - all_probs.append(probs.cpu()) - current_idx += this_batch_size + @torch.no_grad() + def to_anndata(self, adata=None, *, batch_size: int = 4096, compute_umap: bool = False) -> AnnData: + """Write the model's learned state onto ``adata`` under the canonical + ``tcri_*`` keys (from :mod:`tcri._keys`) and return it. Replaces the old + ``preprocessing.register_model``; writes no manager stash. + + Writes — ``uns``: ``METADATA`` + covariate/clonotype/phenotype categories, + ``P_CT`` (posterior-mean ``p_ct``), ``CT_TO_COV``/``CT_TO_C``, per-cell + ``CT_ARRAY``/``COV_ARRAY``, ``LOCAL_SCALE``, ``GATE_PROB``, + ``CLASSIFIER_TEMPERATURE``; ``obsm``: ``X_TCRI`` latent, ``X_LOGITS``, + ``X_LOGPOSTERIOR``, ``X_PROBABILITIES`` (from :meth:`predict`); ``obs``: + ``PHENOTYPE`` argmax hard label. + """ + from .. import _keys as K - # Concatenate into final array of shape (n_cells, P) - return torch.cat(all_probs, dim=0).numpy() + adata = self._validate_anndata(adata) + self.module.eval() + device = next(self.module.parameters()).device + reg = self.adata_manager.registry + + # 1) metadata + category orders (order = training) -------------------- + meta = { + K.COVARIATE_COL: reg["covariate_col"], + K.CLONE_COL: reg["clonotype_col"], + K.PHENOTYPE_COL: reg["phenotype_col"], + K.BATCH_COL: reg["batch_col"], + } + adata.uns[K.METADATA] = meta + for col_key, cat_key in ( + (K.COVARIATE_COL, K.COVARIATE_CATEGORIES), + (K.CLONE_COL, K.CLONOTYPE_CATEGORIES), + (K.PHENOTYPE_COL, K.PHENOTYPE_CATEGORIES), + ): + adata.uns[cat_key] = adata.obs[meta[col_key]].astype("category").cat.categories.tolist() + + # 2) learned priors + per-cell index arrays -------------------------- + ct_arr = self.module.ct_array.cpu().numpy() + adata.uns[K.P_CT] = self.module.get_p_ct().cpu().numpy() + adata.uns[K.CT_TO_COV] = self.module.ct_to_cov.cpu().numpy() + adata.uns[K.CT_TO_C] = self.module.ct_to_c.cpu().numpy() + adata.uns[K.CT_ARRAY] = ct_arr + adata.uns[K.COV_ARRAY] = self.module.ct_to_cov.cpu().numpy()[ct_arr] + adata.uns[K.LOCAL_SCALE] = float(self.module.local_scale) + gp = self.module.gate_prob + adata.uns[K.GATE_PROB] = float(gp) if gp is not None else float("nan") + adata.uns[K.CLASSIFIER_TEMPERATURE] = float(self.module.classifier_temperature) + + # 3) latent mean ----------------------------------------------------- + adata.obsm[K.X_TCRI] = self.get_latent_representation( + adata=adata, batch_size=batch_size + ).astype("float32") + + # 4) per-cell logits + additive log-posterior (folds _compute_logits_and_prior) + loader = self._make_data_loader(adata=adata, batch_size=batch_size) + p_ct_t = self.module.get_p_ct().to(device) + ct_arr_t = self.module.ct_array.to(device) + logits_buf, prior_buf = [], [] + start = 0 + for tensors in loader: + x = tensors[REGISTRY_KEYS.X_KEY].to(device) + b = tensors[REGISTRY_KEYS.BATCH_KEY].long().to(device) + n = x.shape[0] + z_loc, _, _ = self.module.encoder(x, b) + logits_buf.append(self.module.classifier(z_loc).cpu()) + prior_buf.append(torch.log(p_ct_t[ct_arr_t[start : start + n]] + 1e-8).cpu()) + start += n + cls_logits = torch.cat(logits_buf).numpy().astype("float32") + prior_log = torch.cat(prior_buf).numpy().astype("float32") + adata.obsm[K.X_LOGITS] = cls_logits + adata.obsm[K.X_LOGPOSTERIOR] = cls_logits + prior_log + + # 5) probabilities (gate-aware, canonical) + argmax hard labels ------ + probs_df = self.predict(adata, batch_size=batch_size) + adata.obsm[K.X_PROBABILITIES] = probs_df.values.astype("float32") + adata.obs[K.PHENOTYPE] = pd.Categorical.from_codes( + probs_df.values.argmax(1), categories=list(probs_df.columns) + ) + + if compute_umap: + import umap + adata.obsm[K.X_UMAP] = umap.UMAP(random_state=42).fit_transform(adata.obsm[K.X_TCRI]) + + return adata def boost_phenotype_prior( self, @@ -986,11 +473,11 @@ def _ok(m): print(f"{GRN}✅ {m}{RST}") raise ValueError(f"phenotype '{phenotype_name}' not found. Choices: {list(cats)}") p_idx = list(cats).index(phenotype_name) - # 2) clone-level prior (numpy array stored in model.c2p_mat) - mat = self.c2p_mat.copy() + # 2) clone-level prior (numpy array stored in model.clone_phenotype_prior) + mat = self.clone_phenotype_prior.copy() mat[:, p_idx] *= boost_factor mat /= mat.sum(axis=1, keepdims=True) - self.c2p_mat = mat # keep external copy + self.clone_phenotype_prior = mat # keep external copy with torch.no_grad(): new_clone_prior = torch.tensor(mat, dtype=torch.float32, @@ -1014,61 +501,4 @@ def _ok(m): print(f"{GRN}✅ {m}{RST}") _ok("Read to train.") - def plot_archetypes(self): - order = np.argsort(self.labels) - ordered_mat = self.c2p_mat[order, :] - - # Plot heatmap of the clone phenotype distributions - plt.figure(figsize=(10, 6)) - plt.imshow(ordered_mat, aspect='auto', cmap='viridis') - plt.colorbar(label='Phenotype Distribution') - plt.title('Heatmap of Clone Phenotype Distributions (Ordered by Cluster)') - plt.xlabel('Phenotype') - plt.ylabel('Clone (ordered by cluster)') - plt.show() - - # Plot heatmap of the archetype centroids - plt.figure(figsize=(6, 4)) - plt.imshow(self.centers, aspect='auto', cmap='viridis') - plt.colorbar(label='Centroid Value') - plt.title('Heatmap of Archetype Centroids') - plt.xlabel('Phenotype') - plt.ylabel('Archetype') - plt.show() - - def plot_loss(self, log_scale=False): - # Retrieve loss and accuracy history - loss_history = self.history_.get("elbo_train", []) - loss_validation = self.history_.get("elbo_validation", []) - train_accuracy = self.history_.get("kl_divergence_with_prior_train_epoch", []) - val_accuracy = self.history_.get("kl_divergence_with_prior_val", []) - - # Create subplots - fig, axes = plt.subplots(2, 1, figsize=(10, 12)) - - # Plot ELBO loss - axes[0].plot(loss_history, label="Training ELBO Loss") - axes[0].plot(loss_validation, label="Validation ELBO Loss") - axes[0].set_xlabel("Epoch") - axes[0].set_ylabel("ELBO Loss") - axes[0].set_title("ELBO Loss Over Epochs") - axes[0].legend() - - # Plot Accuracy - if len(train_accuracy) > 0 or len(val_accuracy) > 0: - if len(train_accuracy) > 0: - axes[1].plot(train_accuracy, label="Training Accuracy") - if len(val_accuracy) > 0: - axes[1].plot(val_accuracy, label="Validation Accuracy") - axes[1].set_xlabel("Epoch") - axes[1].set_ylabel("dKL") - axes[1].set_title("DKL Over Epochs") - axes[1].legend() - - # Apply log scale if requested - if log_scale: - for ax in axes: - ax.set_yscale("log") - plt.tight_layout() - plt.show() diff --git a/tcri/model/_model_contract.py b/tcri/model/_model_contract.py new file mode 100644 index 0000000..0d48268 --- /dev/null +++ b/tcri/model/_model_contract.py @@ -0,0 +1,209 @@ +"""TCRI **model contract** (frozen) — the machine-checkable probabilistic structure. + +The API contract (``tcri/_contract.pyi``) freezes the public *interface*; this file +freezes the *model* — the generative program and variational family of +**Supplementary Note 1** (``docs/contract/MODEL_CONTRACT.md``, prose; the PDF is the +source of truth). The conformance test +(``tests/test_model_contract_conformance.py``) traces ``TCRIModule.model``/``.guide`` +and asserts the live program matches this manifest **exactly** — no missing sites and +**no extra sites**. + +RULES this file encodes: +- Every stochastic site in ``model()``/``guide()`` is declared here, with the + equation of the note it realizes. A site NOT declared here must NOT exist. +- Changing the model's mathematics **requires updating this contract first** + (and ``docs/contract/MODEL_CONTRACT.md``). The conformance test is the forcing + function: it fails until the contract and the code agree. Do not "fix" a failure + by loosening the manifest to match new code — update the contract deliberately, + with the note reference, so the change is reviewed as a model change. +- Sanctioned departures from the note live in ``SANCTIONED_DEVIATIONS`` with a + rationale. An unlisted departure is a defect, not a feature. + +Symbols (note -> code): ω_c->``p_c``, ϕ_m->``p_ct``, z_i->``latent``, x_i->``obs``, +f_cls->``classifier``, π->``gate_prob``, α->``global_scale``, β->``local_scale``, +γ->``phenotype_kl_weight``, g(i)->``ct_array``, h(m)->``ct_to_c``. +""" +from __future__ import annotations + +from typing import Optional + +__all__ = [ + "GENERATIVE_SITES", + "GUIDE_SITES", + "GUIDE_PARAMS", + "FORBIDDEN_GUIDE_SITES", + "PLATES", + "SEMANTIC_INVARIANTS", + "SANCTIONED_DEVIATIONS", + "SiteSpec", +] + + +class SiteSpec: + """One declared stochastic site: what distribution, in which plate, per the note. + + ``dist`` is the *unwrapped* distribution class name — the checker strips Pyro's + ``Independent``/``ExpandedDistribution`` wrappers (``.to_event()``/``.expand()`` + are implementation details, not model semantics). ``event_dim`` pins how many + trailing dims are dependent, which IS semantics. + """ + + def __init__( + self, + name: str, + dist: str, + plate: Optional[str], + eq: str, + observed: bool = False, + event_dim: Optional[int] = None, + note: str = "", + ): + self.name = name + self.dist = dist + self.plate = plate + self.eq = eq + self.observed = observed + self.event_dim = event_dim + self.note = note + + def __repr__(self) -> str: # pragma: no cover - debug aid + return f"SiteSpec({self.name!r}, dist={self.dist!r}, plate={self.plate!r}, eq={self.eq!r})" + + +# ── plates ─────────────────────────────────────────────────────────────────── +# name -> what one index of the plate is (the note's index set). +PLATES = { + "clonotypes": "c = 1..C (clonotype)", + "ct_plate": "m = 1..M (clonotype x covariate group)", + "data": "i = 1..N (cell; subsampled minibatch)", +} + + +# ── the generative program: p(Ω, Φ, z, x) ──────────────────────────────────── +GENERATIVE_SITES = [ + SiteSpec( + "p_c", "MixtureDirichlet", "clonotypes", eq="1", event_dim=1, + note="ω_c ~ (1/B_c) Σ_b Dir(α·ψ_b); ψ_b = archetype vectors, α = global_scale.", + ), + SiteSpec( + "p_ct", "Dirichlet", "ct_plate", eq="2", event_dim=1, + note="ϕ_m | ω_h(m) ~ Dir(β·ω_h(m)); β = local_scale, h(m) = ct_to_c.", + ), + SiteSpec( + "latent", "MixtureSameFamily", "data", eq="3", event_dim=1, + note="z_i ~ (1/B_z) Σ_k q(z|u_k) — VampPrior over learnable pseudo-inputs.", + ), + SiteSpec( + "phenotype_alignment", "Unit", "data", eq="Inference Details", observed=True, + note=( + "The surrogate replacing the discrete z^ϕ (eq 4): a pyro.factor carrying " + "−γ·KL(probs_i ‖ ϕ_g(i)), probs_i = softmax(ℓ_i), " + "ℓ_i = π·f_cls(z_i) + (1−π)·log ϕ_g(i). This term is what trains f_cls." + ), + ), + SiteSpec( + "obs", "ZeroInflatedNegativeBinomial", "data", eq="5", observed=True, event_dim=1, + note="x_i ~ ZINB(g'_i, r_i, μ_i) from the scVI decoder (+ library size).", + ), +] + + +# ── the variational family q(Ω, Φ, z | x) (eq 6) ──────────────────────────── +GUIDE_SITES = [ + SiteSpec("p_c", "Dirichlet", "clonotypes", eq="6", event_dim=1, + note="q(ω_c) = Dir(λ_c); concentration = α · normalized q_p_c_raw."), + SiteSpec("p_ct", "Dirichlet", "ct_plate", eq="6", event_dim=1, + note="q(ϕ_m) = Dir(λ'_m); concentration = β · normalized q_p_ct_raw."), + SiteSpec("latent", "Normal", "data", eq="6", event_dim=1, + note="q(z_i|x_i) = N(μ_i, diag(σ_i²)) from the encoder."), +] + +# Learnable variational parameters the guide must register (λ_c, λ'_m). +GUIDE_PARAMS = ["q_p_c_raw", "q_p_ct_raw"] + +# The discrete phenotype latent z^ϕ is NOT sampled: the note's "Inference Details" +# replaces it with the surrogate KL penalty. A q(z^ϕ) categorical site reappearing +# in the guide means someone re-introduced the enumerated path without updating the +# contract — which changes the objective. +FORBIDDEN_GUIDE_SITES = ["z_phi", "z_phenotype", "phenotype", "phenotype_alignment"] + + +# ── semantic invariants (behavior the structure alone can't pin) ───────────── +# Each is asserted by the conformance test; the string is the failure explanation. +SEMANTIC_INVARIANTS = { + "alpha_scales_clonotype_prior": ( + "eq 1: the p_c prior concentration must scale with α (global_scale). " + "Without it the prior is Dir(ψ_b) (sums to ~1, U-shaped: mass at the simplex " + "corners) and is scaled inconsistently with the guide, which does apply α." + ), + "beta_scales_covariate_prior": ( + "eq 2: the p_ct prior concentration must scale with β (local_scale), i.e. " + "conc = β·ω_h(m), so the total concentration is ~β." + ), + "hierarchy_ct_depends_on_c": ( + "eq 2 is HIERARCHICAL: p_ct's concentration must be built from the SAMPLED " + "ω_c (site 'p_c') indexed by ct_to_c = h(m) — not from the static empirical " + "`clone_phen_prior`, and not under any other index map. Severing this makes " + "ϕ_m independent of its clonotype (p_c becomes a dangling latent contributing " + "only its own KL) and no clonotype-level information reaches the covariate " + "level, which is the entire point of the two-level model." + ), + "factor_is_negative_kl": ( + "Inference Details: the phenotype_alignment factor must be −γ·KL(probs‖ϕ) " + "(≤ 0). SVI MAXIMIZES the log-joint and the note's γ·KL is a PENALTY, so the " + "sign must be negative; a positive factor would push probs AWAY from ϕ." + ), + "gate_mixes_classifier_and_prior": ( + "eq 4: ℓ_i = π·f_cls(z_i) + (1−π)·log ϕ_g(i). π=1 ⇒ pure classifier; " + "π=0 ⇒ pure clonotype prior; π=None ⇒ the additive rule f_cls + log ϕ." + ), + "alignment_target_uses_global_indices": ( + "The per-cell target ϕ_g(i) must be indexed by GLOBAL cell indices, never the " + "local pyro plate index (0..batch_size−1), which scrambles targets across " + "shuffled minibatches and collapses f_cls to a constant." + ), +} + + +# ── departures from the note that are accepted, with rationale ─────────────── +# Anything NOT listed here that departs from the note is a defect. +SANCTIONED_DEVIATIONS = { + "E_reconstruction_loss_scale": ( + "eq 7 weights E[log p(x|z)] at 1; the code scales the `obs` site by " + "`reconstruction_loss_scale`, a beta-VAE-style reweighting. Default RAISED " + "1e-3 -> 1e-2 after re-measurement: on real data (yost subset, 2259 cells x " + "1000 genes) the posterior-predictive library ratio was 1.40 at 1e-3 and " + "0.99 at 1e-2, with dropout matching throughout. Recovery (1.000) and latent " + "separation (7.19) are unchanged, so the calibration is free. NOTE the " + "original ~6x over-generation was mostly the phantom second optimizer " + "shrinking the decoder (see `optimizer_weight_decay`); removing it took the " + "ratio 6x -> 1.40, and this default closes the rest. Full weight (1.0) " + "over-corrects: ratio 0.91 on synthetic." + ), + "kl_warmup_z_only": ( + "Training-only: `kl_weight` anneals the `latent` (z) KL over " + "`n_steps_kl_warmup`; the two Dirichlet KLs are unscaled. Standard annealing, " + "not part of eq 7." + ), + "num_particles_enumeration_only": ( + "`num_particles` is honored only on the TraceEnum_ELBO path; the default " + "Trace_ELBO uses a single MC particle." + ), + "F_perturbation_not_implemented": ( + "In-silico perturbation (eqs 8-12) is not implemented. Additive feature; " + "explicitly out of scope for this release." + ), + "optimizer_weight_decay": ( + "Training-only: the SVI optimizer applies Adam weight decay (default 1e-4) " + "to the network parameters. The note specifies the objective (eq 7 + the " + "surrogate) but not the optimizer, so this is an L2 regularizer on top of " + "it. Applied inside Pyro's optimizer, where it acts on the ELBO gradients. " + "NOTE (history): this used to be applied by a second torch Adam installed " + "over `configure_optimizers`, which overrode scvi's deliberate no-op shim " + "and stepped AFTER `SVI.step()` had already zeroed the gradients. With zero " + "gradients Adam's normalization turns weight decay into a scale-free " + "shrink of ~lr per step (not proportional L2), which held the networks at " + "~2.4x smaller weights, and `train(lr=)` never reached the real optimizer. " + "Removed; see MODEL_CONTRACT.md." + ), +} diff --git a/tcri/model/_module.py b/tcri/model/_module.py new file mode 100644 index 0000000..78024e0 --- /dev/null +++ b/tcri/model/_module.py @@ -0,0 +1,366 @@ +"""The TCRI Pyro module: a CVAE (encoder/decoder over gene expression) coupled to +two-level hierarchical Dirichlet priors (clonotype -> clonotype x covariate) and a +phenotype classifier head.""" +from typing import Dict, Optional + +import torch +import pyro +import pyro.distributions as dist +import pyro.poutine as poutine + +from scvi import REGISTRY_KEYS +from scvi.nn import Encoder, DecoderSCVI +from scvi.module.base import PyroBaseModuleClass, auto_move_data + +from ._classifier import PhenotypeClassifier +from ._priors import VampPrior, MixtureDirichlet + +__all__ = ["TCRIModule"] + + +class TCRIModule(PyroBaseModuleClass): + """ + Two-level model that incorporates hierarchical priors (clonotype-level) + and a CVAE structure that explicitly conditions gene expression on the + observed cell-level phenotype. + """ + + def __init__( + self, + n_input: int, + n_latent: int, + P: int, + n_batch: int, + global_scale: float = 10.0, + local_scale: float = 5.0, + prior_temperature: float = 1.0, + guide_temperature: float = 1.0, + gate_prob: Optional[float] = 0.5, # None = additive (no gating) + mixture_concentration: torch.Tensor = None, + n_pseudo_obs: int = 10, + use_enumeration: bool = False, + classifier_hidden: int = 128, + classifier_dropout: float = 0.1, + classifier_n_layers: int = 3, + n_hidden: int = 128, + n_layers: int = 3, + kl_weight_max: float = 1.0, + guide_init_scale: float = 10.0, + classifier_temperature: float = 1.0, + phenotype_kl_weight: float = 1.0, + ): + super().__init__() + self.n_input = n_input + self.n_latent = n_latent + self.P = P + self.n_hidden = n_hidden + self.n_layers = n_layers + self.global_scale = global_scale + self.local_scale = local_scale + self.prior_temperature = prior_temperature + self.guide_temperature = guide_temperature + # register_buffer (not a plain attribute) so module.to(device) moves it with + # the rest of the module — as a bare Tensor it stays on CPU and the eq-1 prior + # then needs an ad-hoc .to() at every use, or fails outright on GPU. + if mixture_concentration is not None and not torch.is_tensor(mixture_concentration): + mixture_concentration = torch.as_tensor(mixture_concentration) + self.register_buffer("mixture_concentration", mixture_concentration) + self.n_pseudo_obs = n_pseudo_obs + self.gate_prob = gate_prob + # Assert that it is not None + assert ( + self.mixture_concentration is not None + ), "mixture_concentration must be provided" + self.use_enumeration = use_enumeration + self.eps = 1e-6 + self.classifier_hidden = classifier_hidden + self.classifier_dropout = classifier_dropout + self.kl_weight_max = kl_weight_max + self.classifier_n_layers = classifier_n_layers + self.guide_init_scale = guide_init_scale + self.classifier_temperature = classifier_temperature + self.phenotype_kl_weight = phenotype_kl_weight # γ (methods §Inference Details) + + # Defaults so model()/guide() work before train() sets them + self.kl_weight = 1e-6 + self.reconstruction_loss_scale = 1e-2 + + self.encoder = Encoder( + n_input=n_input, + n_output=n_latent, + n_layers=n_layers, + n_hidden=n_hidden, + n_cat_list=[n_batch], + use_layer_norm=True, + ) + + # VampPrior + pseudo_inputs = torch.randn(self.n_pseudo_obs, self.n_input) + self.vamp_prior = VampPrior(pseudo_inputs, self.encoder) + + self.decoder_input_dim = self.n_latent + self.decoder = DecoderSCVI( + self.decoder_input_dim, + n_input, + n_layers=n_layers, + n_hidden=n_hidden, + n_cat_list=[n_batch], + scale_activation="softplus", + use_layer_norm=True, + ) + + self.px_r = torch.nn.Parameter(torch.ones(n_input)) + + self.classifier = PhenotypeClassifier( + n_latent=self.n_latent, + classifier_hidden=self.classifier_hidden, + P=self.P, + num_layers=self.classifier_n_layers, + dropout_rate=self.classifier_dropout, + temperature=self.classifier_temperature, + ) + + self.register_buffer("clone_phen_prior", torch.empty(0)) + self.register_buffer("ct_to_c", torch.empty(0, dtype=torch.long)) + self.register_buffer("c_array", torch.empty(0, dtype=torch.long)) + self.register_buffer("ct_array", torch.empty(0, dtype=torch.long)) + self.register_buffer("ct_to_cov", torch.empty(0, dtype=torch.long)) + self.c_count = 0 + self.ct_count = 0 + self.n_cells = 0 + + self.register_buffer("_target_phenotypes", torch.empty(0, dtype=torch.long)) + + def prepare_two_level_params( + self, + c_count: int, + ct_count: int, + clone_phen_prior_mat: torch.Tensor, + ct_to_c_array: torch.Tensor, + c_array_for_cells: torch.Tensor, + ct_array_for_cells: torch.Tensor, + target_phenotypes: torch.Tensor, + ct_to_cov_array: torch.Tensor = None, + ): + self.c_count = c_count + self.ct_count = ct_count + self.n_cells = c_array_for_cells.shape[0] + + prior_mat = clone_phen_prior_mat + self.eps + prior_mat = prior_mat / prior_mat.sum(dim=1, keepdim=True) + + if self.prior_temperature != 1.0: + prior_mat = prior_mat ** (1.0 / self.prior_temperature) + prior_mat = prior_mat / prior_mat.sum(dim=1, keepdim=True) + + self.register_buffer("clone_phen_prior", prior_mat) + self.register_buffer("ct_to_c", ct_to_c_array) + self.register_buffer("c_array", c_array_for_cells) + self.register_buffer("ct_array", ct_array_for_cells) + self.register_buffer("_target_phenotypes", target_phenotypes) + + if ct_to_cov_array is not None: + self.register_buffer("ct_to_cov", ct_to_cov_array) + + @property + def use_gate(self) -> bool: + return self.gate_prob is not None + + @staticmethod + def _get_fn_args_from_batch(tensor_dict: Dict[str, torch.Tensor]): + x = tensor_dict[REGISTRY_KEYS.X_KEY] + batch_idx = tensor_dict[REGISTRY_KEYS.BATCH_KEY].long() + log_library = torch.log(torch.sum(x, dim=1, keepdim=True) + 1e-6) + # Global cell indices for this minibatch. Needed so model()/guide() can map + # each cell to its (clonotype x covariate) group via ``ct_array``; the pyro + # data-plate index is LOCAL (0..batch_size-1) and must NOT be used for this. + indices = tensor_dict["indices"].long().view(-1) + return (x, batch_idx, log_library, indices), {} + + @auto_move_data + def model( + self, + x: torch.Tensor, + batch_idx: torch.Tensor, + log_library: torch.Tensor, + indices: torch.Tensor = None, + ): + pyro.module("scvi", self) + + kl_weight = self.kl_weight + batch_size = x.shape[0] + + with pyro.plate("clonotypes", self.c_count): + B = self.mixture_concentration.shape[0] + mixture_weights = torch.ones(B, device=x.device) / B + # Expand mixture parameters to add a leading dimension for clonotypes. + # expanded_conc will have shape (self.c_count, B, K). + # eq 1: ω_c ~ (1/B) Σ_b Dir(α·ψ_b) — scale the archetype vectors ψ_b by + # α (global_scale), mirroring eq 2's β on the covariate prior. Without α + # the concentration sums to ~1 (U-shaped, mass at the simplex corners) and + # is scaled inconsistently with the guide q(ω_c), which does apply α. + expanded_conc = self.global_scale * self.mixture_concentration.unsqueeze( + 0 + ).expand(self.c_count, -1, -1) + # expanded_weights will have shape (self.c_count, B) + expanded_weights = mixture_weights.unsqueeze(0).expand(self.c_count, -1) + mixture_dist = MixtureDirichlet(expanded_weights, expanded_conc) + p_c = pyro.sample("p_c", mixture_dist) + # print("p_c shape:", p_c.shape) + + with pyro.plate("ct_plate", self.ct_count): + base_p = p_c[self.ct_to_c] + self.eps + conc_ct = torch.clamp(self.local_scale * base_p, min=1e-3) + p_ct = pyro.sample("p_ct", dist.Dirichlet(conc_ct)) + + with pyro.plate("data", batch_size) as idx: + + with poutine.scale(scale=kl_weight): + # vamp_mixture = self.vamp_prior.get_mixture().to_event(1) + vamp_mixture = self.vamp_prior.get_mixture() + z = pyro.sample("latent", vamp_mixture) + + # Map each cell to its (clonotype x covariate) group using GLOBAL indices, + # not the local plate index `idx` (which is 0..batch_size-1 and would + # scramble the alignment target across shuffled minibatches). Fail loudly + # rather than silently falling back to the (wrong) local index. + assert indices is not None, ( + "model() requires global cell indices (supplied by " + "_get_fn_args_from_batch); the local plate index must not be used " + "for the clonotype x covariate lookup." + ) + ct_idx = self.ct_array[indices] + cls_logits = self.classifier(z) # l_i = f_cls(z_i) (eq. 4) + + # Phenotype-alignment surrogate (Supplementary Note, "Inference Details"): + # ℓ_i = π·f_cls(z_i) + (1-π)·log φ_{g(i)}, probs_i = softmax(ℓ_i); + # add -γ·KL(probs_i ‖ φ_{g(i)}) to the log-joint so the ELBO trains the + # classifier f_cls (η_cls). φ (the covariate-level distribution) is the + # DETACHED alignment target. Without this factor cls_logits never enters + # the ELBO and f_cls receives no gradient. + phi = p_ct[ct_idx].detach() + log_phi = torch.log(phi + 1e-8) + if self.gate_prob is not None: + ell = self.gate_prob * cls_logits + (1.0 - self.gate_prob) * log_phi + else: + ell = cls_logits + log_phi + probs = torch.softmax(ell, dim=-1) + pheno_kl = (probs * (torch.log(probs + 1e-8) - log_phi)).sum(dim=-1) + pyro.factor("phenotype_alignment", -self.phenotype_kl_weight * pheno_kl) + + px_scale, px_r_out, px_rate, px_dropout = self.decoder( + "gene", z, log_library, batch_idx + ) + + zi_gate_probs = torch.sigmoid(px_dropout).clamp(min=1e-3, max=1.0 - 1e-3) + nb_logits = (px_rate + self.eps).log() - (self.px_r.exp() + self.eps).log() + nb_logits = torch.clamp(nb_logits, min=-10.0, max=10.0) + total_count = self.px_r.exp().clamp(max=1e4) + + x_dist = dist.ZeroInflatedNegativeBinomial( + gate=zi_gate_probs, + total_count=total_count, + logits=nb_logits, + validate_args=False, + ) + # plain float, not torch.tensor(..., device=x.device): poutine.scale takes a + # scalar, and materializing one on the device is a host->device copy (and a + # sync point) on every SVI step for a value that never changes mid-epoch. + with poutine.scale(scale=float(self.reconstruction_loss_scale)): + pyro.sample("obs", x_dist.to_event(1), obs=x) + + @auto_move_data + def guide( + self, + x: torch.Tensor, + batch_idx: torch.Tensor, + log_library: torch.Tensor, + indices: torch.Tensor = None, + ): + pyro.module("scvi", self) + batch_size = x.shape[0] + + with pyro.plate("clonotypes", self.c_count): + # Start from a scaled version of the prior. + init_mat_c = self.clone_phen_prior * self.guide_init_scale + 1e-3 + init_mat_c = init_mat_c.to(x.device) + + # Learnable raw parameters for q(p_c) + if "q_p_c_raw" not in pyro.get_param_store(): + q_p_c_raw = pyro.param( + "q_p_c_raw", + init_mat_c.clone().detach(), + constraint=dist.constraints.positive + ) + else: + q_p_c_raw = pyro.param("q_p_c_raw") + + bad_c = ~torch.isfinite(q_p_c_raw) + if bad_c.any(): + q_p_c_raw = torch.where(bad_c, init_mat_c.to(q_p_c_raw.device), q_p_c_raw) + + # Apply a sharpening transformation controlled by guide_temperature. + q_p_c_sharp = q_p_c_raw ** (1.0 / self.guide_temperature) + q_p_c_sharp = torch.clamp(q_p_c_sharp, min=1e-8) # ← add this + q_p_c_sharp = q_p_c_sharp / q_p_c_sharp.sum(dim=1, keepdim=True) + conc_c_guide = torch.clamp(self.global_scale * q_p_c_sharp, min=1e-3) + + # Sample p_c from a single learned Dirichlet per clonotype. + pyro.sample("p_c", dist.Dirichlet(conc_c_guide)) + + with pyro.plate("ct_plate", self.ct_count): + init_mat = self.clone_phen_prior[self.ct_to_c, :] + init_mat = init_mat * self.guide_init_scale + 1e-3 + init_mat = init_mat.to(x.device) + if "q_p_ct_raw" not in pyro.get_param_store(): + q_p_ct_raw = pyro.param( + "q_p_ct_raw", + init_mat.clone().detach(), # Make sure it's not a leaf + constraint=dist.constraints.positive, + ) + else: + q_p_ct_raw = pyro.param("q_p_ct_raw") + + bad_ct = ~torch.isfinite(q_p_ct_raw) + if bad_ct.any(): + q_p_ct_raw = torch.where(bad_ct, init_mat.to(q_p_ct_raw.device), q_p_ct_raw) + + q_p_ct_sharp = q_p_ct_raw ** (1.0 / self.guide_temperature) + q_p_ct_sharp = torch.clamp(q_p_ct_sharp, min=1e-8) + q_p_ct_sharp = q_p_ct_sharp / q_p_ct_sharp.sum(dim=1, keepdim=True) + conc_ct_guide = torch.clamp(self.local_scale * q_p_ct_sharp, min=1e-3) + pyro.sample("p_ct", dist.Dirichlet(conc_ct_guide)) + + z_loc, z_scale, _ = self.encoder(x, batch_idx) + z_scale = torch.clamp(z_scale, min=1e-3, max=10.0) + + with pyro.plate("data", batch_size) as idx: + latent_posterior = dist.Normal(z_loc, z_scale) + with pyro.poutine.scale(scale=self.kl_weight): + z = pyro.sample("latent", latent_posterior.to_event(1)) + + @auto_move_data + def get_latent(self, tensor_dict: Dict[str, torch.Tensor]): + x = tensor_dict[REGISTRY_KEYS.X_KEY] + batch_idx = tensor_dict[REGISTRY_KEYS.BATCH_KEY].long() + z_loc, _, _ = self.encoder(x, batch_idx) + if z_loc.ndim == 3: + z_loc = z_loc.mean(dim=1) + return z_loc.cpu() + + @torch.no_grad() + def get_p_ct(self): + from pyro import get_param_store + + param_store = get_param_store() + q_p_ct_raw = param_store["q_p_ct_raw"] + bad = ~torch.isfinite(q_p_ct_raw) + if bad.any(): + n_phen = q_p_ct_raw.shape[1] + q_p_ct_raw = torch.where(bad, torch.ones_like(q_p_ct_raw) / n_phen, q_p_ct_raw) + if self.guide_temperature != 1.0: + q_p_ct_sharp = q_p_ct_raw ** (1.0 / self.guide_temperature) + q_p_ct_sharp = q_p_ct_sharp / q_p_ct_sharp.sum(dim=1, keepdim=True) + else: + q_p_ct_sharp = q_p_ct_raw / q_p_ct_raw.sum(dim=1, keepdim=True) + return q_p_ct_sharp diff --git a/tcri/model/_priors.py b/tcri/model/_priors.py new file mode 100644 index 0000000..efa05ef --- /dev/null +++ b/tcri/model/_priors.py @@ -0,0 +1,149 @@ +"""Prior distributions for the TCRI generative model. + +`VampPrior` is a learnable mixture-of-encoders prior over the latent space; +`MixtureDirichlet` is the per-clonotype mixture-of-Dirichlets used as the +clonotype-level phenotype prior. +""" +import torch +import pyro.distributions as dist + +__all__ = ["MixtureDirichlet", "VampPrior"] + + +class VampPrior(torch.nn.Module): + def __init__(self, pseudo_inputs, encoder): + """ + Args: + pseudo_inputs (torch.Tensor): Initial pseudo-inputs of shape (K, input_dim). + encoder (torch.nn.Module): Encoder that takes an input and returns (mean, log_var) + for the approximate posterior q(z|x). + """ + super(VampPrior, self).__init__() + # Learnable pseudo-inputs; these are optimized during training. + self.pseudo_inputs = torch.nn.Parameter(pseudo_inputs) + self.encoder = encoder + + def get_mixture(self): + """ + Constructs the VampPrior as a uniform mixture of q(z|u_k) for each pseudo-input u_k. + """ + # Compute the approximate posterior parameters for each pseudo-input. + # Expected output shapes: means and log_vars: (K, latent_dim) + K = self.pseudo_inputs.size(0) + # Create a dummy categorical argument; unsqueeze to have shape (K, 1) + dummy_batch = torch.zeros(K, dtype=torch.long, device=self.pseudo_inputs.device).unsqueeze(1) + means, log_vars, _ = self.encoder(self.pseudo_inputs, dummy_batch) + scales = torch.sqrt(torch.exp(log_vars)) + component_dist = dist.Independent(dist.Normal(means, scales), 1) + mixture_weights = torch.ones(K, device=self.pseudo_inputs.device) / K + mixture = dist.MixtureSameFamily( + dist.Categorical(mixture_weights), + component_dist + ) + return mixture + + def log_prob(self, z): + """ + Computes log p(z) under the VampPrior. + """ + return self.get_mixture().log_prob(z) + + def sample(self, sample_shape=torch.Size()): + """ + Draws samples from the VampPrior. + """ + return self.get_mixture().sample(sample_shape) + + +class MixtureDirichlet(dist.TorchDistribution): + """ + Mixture of Dirichlet distributions parameterized by mixture weights and concentration parameters." + """ + arg_constraints = { + "mixture_weights": dist.constraints.simplex, # shape: batch_shape + (B,) + "concentration": dist.constraints.positive, # shape: batch_shape + (B, K) + } + support = dist.constraints.simplex # each sample is a simplex over K categories + has_rsample = False + + def __init__( + self, + mixture_weights: torch.Tensor, + concentration: torch.Tensor, + validate_args=None, + ): + """ + mixture_weights: Tensor of shape batch_shape + (B,), with each row summing to 1. + concentration: Tensor of shape batch_shape + (B, K), where K is the number of categories. + """ + self.mixture_weights = mixture_weights + # Clamp concentrations to ensure positivity. + self.concentration = torch.clamp(concentration, min=1e-3) + # Determine batch shape, B, and K. + batch_shape = self.mixture_weights.shape[:-1] + self.B = self.mixture_weights.size(-1) + self.K = self.concentration.size(-1) + event_shape = (self.K,) + super(MixtureDirichlet, self).__init__( + batch_shape, event_shape, validate_args=validate_args + ) + + def sample(self, sample_shape=torch.Size()): + """ + Returns a sample of shape: sample_shape + batch_shape + (K,). + For each batch element, first sample a mixture component, then sample from the corresponding Dirichlet. + """ + # Create categorical for mixture weights. + cat = dist.Categorical(self.mixture_weights) + # Sample mixture indices; shape: sample_shape + batch_shape. + mixture_idx = cat.sample(sample_shape) + full_shape = mixture_idx.shape # sample_shape + batch_shape + + # Expand concentration to shape: sample_shape + batch_shape + (B, K). + target_shape = sample_shape + self.concentration.shape + expanded_concentration = self.concentration.expand(target_shape) + + # Flatten the sample and batch dimensions. + flat_shape = (-1, self.B, self.K) + flat_concentration = expanded_concentration.reshape(flat_shape) + flat_idx = mixture_idx.reshape(-1) # shape: (num_samples,) + + # Select the concentration parameters corresponding to the sampled mixture index. + selected_concentration = flat_concentration[ + torch.arange(flat_idx.size(0)), flat_idx + ] + + # Sample from the Dirichlet for each sample. + flat_samples = dist.Dirichlet(selected_concentration).sample() + # Reshape to sample_shape + batch_shape + (K,). + return flat_samples.reshape(full_shape + (self.K,)) + + def log_prob(self, value): + device = value.device # get the device from input tensor + + # Move tensors explicitly to the same device + value_expanded = value.unsqueeze(-2).to(device) + expanded_concentration = self.concentration.expand( + value.shape[:-1] + (self.B, self.K) + ).to(device) + + d = dist.Dirichlet(expanded_concentration) + + component_log_probs = d.log_prob( + value_expanded.expand(expanded_concentration.shape) + ) + + expanded_weights = self.mixture_weights.expand(value.shape[:-1] + (self.B,)).to(device) + mixture_log = torch.log(expanded_weights) + + return torch.logsumexp(mixture_log + component_log_probs, dim=-1) + + def score_parts(self, value): + # Compute log probability. + lp = self.log_prob(value) + # Return dummy zeros for the score function and entropy terms. + zeros = torch.zeros_like(lp) + return lp, zeros, zeros + + def __call__(self, *args, **kwargs): + return self.sample(*args, **kwargs) diff --git a/tcri/model/_training.py b/tcri/model/_training.py new file mode 100644 index 0000000..85b0746 --- /dev/null +++ b/tcri/model/_training.py @@ -0,0 +1,169 @@ +"""Training plan for the TCRI model plus the archetype initializer. + +`UnifiedTrainingPlan` adds classification/reconstruction diagnostics and a +`validation_step` (logs ``elbo_validation`` for scvi early stopping) on top of +Pyro's ELBO step. `build_archetypes` K-means-clusters the clone x phenotype +matrix to seed the Dirichlet mixture (returns centers AND labels). +""" +import numpy as np +import torch +import torch.nn.functional as F + +from scvi.train import PyroTrainingPlan +from pyro.infer import TraceEnum_ELBO, Trace_ELBO +from sklearn.cluster import KMeans + +from ._module import TCRIModule + +__all__ = ["UnifiedTrainingPlan", "build_archetypes"] + + +def build_archetypes(clone_phenotype_prior, K=4): + kmeans = KMeans(n_clusters=K, random_state=42) + labels = kmeans.fit_predict(clone_phenotype_prior) + centers = kmeans.cluster_centers_ + centers = np.clip(centers, 1e-8, None) + centers = centers / centers.sum(axis=1, keepdims=True) + return centers, labels + + +class UnifiedTrainingPlan(PyroTrainingPlan): + """ + Training plan that includes classification, reconstruction losses, + KL warmup, plus a validation_step that logs 'elbo_validation' so scvi's + early stopping can monitor it. + """ + + def __init__( + self, + module: TCRIModule, + n_steps_kl_warmup: int = 1000, + reconstruction_loss_scale: float = 1e-2, + num_particles: int = 5, + optimizer_config: dict = None, + **kwargs, + ): + self.num_particles = num_particles + if module.use_enumeration: + print("Using Enumeration") + self._loss_fn = TraceEnum_ELBO( + max_plate_nesting=3, num_particles=self.num_particles + ) + else: + self._loss_fn = Trace_ELBO() + + if optimizer_config is None: + optimizer_config = {"lr": 1e-3, "betas": (0.9, 0.999), "eps": 1e-5, + "weight_decay": 1e-4} + self.optimizer_config = optimizer_config + + # Hand the optimizer settings to PYRO's optimizer — the one inside SVI that + # actually descends the ELBO gradients. Previously this class overrode + # `configure_optimizers` with a real torch Adam over every module parameter; + # that override replaced scvi's deliberate no-op shim ("a shim optimizer ... + # to keep Lightning happy") and ran AFTER SVI.step() had already stepped and + # ZEROED the gradients. Stepping Adam on zero gradients is not a no-op: the + # weight-decay term becomes the whole gradient, and Adam's own normalization + # (g/sqrt(g^2)) strips its magnitude, so the update degenerates to ~lr*sign(p) + # — a scale-free shrink, not proportional L2. Measured effect: network weights + # held at ~2.4x smaller than without it. It also meant `lr` never reached the + # real optimizer (Pyro always used scvi's hard-coded 1e-3). + super().__init__( + module, + n_steps_kl_warmup=n_steps_kl_warmup, + optim_kwargs={ + "lr": optimizer_config["lr"], + "betas": optimizer_config["betas"], + "eps": optimizer_config["eps"], + "weight_decay": optimizer_config["weight_decay"], + }, + **kwargs, + ) + + self.n_steps_kl_warmup = n_steps_kl_warmup + self.reconstruction_loss_scale = reconstruction_loss_scale + self._my_global_step = 0 + + @property + def loss(self): + return self._loss_fn + + # NOTE: configure_optimizers is deliberately NOT overridden — scvi's base class + # returns a shim over a single dummy parameter purely to advance Lightning's step + # counter. All real optimization happens in Pyro's SVI (see __init__). + + def training_step(self, batch, batch_idx): + # ── KL warmup ──────────────────────────────────────────── + if self.n_steps_kl_warmup > 0 and self._my_global_step < self.n_steps_kl_warmup: + kl_weight = max(1e-6, self.module.kl_weight_max * (self._my_global_step / self.n_steps_kl_warmup)) + else: + kl_weight = self.module.kl_weight_max + self.module.kl_weight = kl_weight + + # ── Pyro ELBO step ─────────────────────────────────────── + loss_dict = super().training_step(batch, batch_idx) + device = next(self.module.parameters()).device + + if not isinstance(loss_dict["loss"], torch.Tensor): + loss_dict["loss"] = torch.tensor(loss_dict["loss"], device=device, requires_grad=True) + else: + loss_dict["loss"] = loss_dict["loss"].to(device) + + # ── Diagnostics (no gradient contribution) ─────────────── + with torch.no_grad(): + z_diag = self.module.get_latent(batch).to(device) + idx_diag = batch["indices"].long().view(-1).to(device) + cls_logits = self.module.classifier(z_diag) + ct_idx = self.module.ct_array[idx_diag] + p_ct_prior = self.module.get_p_ct()[ct_idx].to(device) + prior_log = torch.log(p_ct_prior + 1e-8) + + if self.module.use_gate: + local_logits = self.module.gate_prob * cls_logits + (1.0 - self.module.gate_prob) * prior_log + else: + local_logits = cls_logits + prior_log + + probs = F.softmax(local_logits, dim=-1) + kl_div = F.kl_div(probs.log(), p_ct_prior, reduction='batchmean') + entropy = -torch.sum(probs * torch.log(probs + 1e-8), dim=-1).mean() + confidence = (probs**2).sum(dim=-1).mean() + + self.log("kl_divergence_with_prior_train", kl_div, prog_bar=False, on_epoch=True) + self.log("entropy_train", entropy, prog_bar=False, on_epoch=True) + self.log("confidence_train", confidence, prog_bar=False, on_epoch=True) + + self._my_global_step += 1 + return loss_dict + + def validation_step(self, batch, batch_idx): + with torch.no_grad(): + self.module.eval() + val_dict = super().training_step(batch, batch_idx) + self.module.train() + + device = next(self.module.parameters()).device + + if not isinstance(val_dict["loss"], torch.Tensor): + val_dict["loss"] = torch.tensor(val_dict["loss"], device=device) + else: + val_dict["loss"] = val_dict["loss"].to(device) + + # ── Diagnostic only ────────────────────────────────────── + z_batch = self.module.get_latent(batch).to(device) + idx = batch["indices"].long().view(-1).to(device) + cls_logits = self.module.classifier(z_batch) + ct_idx = self.module.ct_array[idx] + p_ct_prior = self.module.get_p_ct()[ct_idx].to(device) + prior_log = torch.log(p_ct_prior + 1e-8) + + if self.module.use_gate: + local_logits = self.module.gate_prob * cls_logits + (1.0 - self.module.gate_prob) * prior_log + else: + local_logits = cls_logits + prior_log + + probs = F.softmax(local_logits, dim=-1) + kl_divergence = F.kl_div(probs.log(), p_ct_prior, reduction='batchmean') + self.log("kl_divergence_with_prior_val", kl_divergence, prog_bar=False, on_epoch=True) + + self.log("elbo_validation", val_dict["loss"], prog_bar=True, on_epoch=True) + return val_dict diff --git a/tcri/plotting/__init__.py b/tcri/plotting/__init__.py index 7fed5f3..a718e95 100644 --- a/tcri/plotting/__init__.py +++ b/tcri/plotting/__init__.py @@ -1 +1,16 @@ -from ..plotting._plotting import * +"""``tcri.pl`` — plotting. The four ``tl``↔``pl`` metric twins (cache renderers over the +engine-backed metrics) plus the shared colors. Explicit ``__all__``; no ``import *``. +""" +from ._entropy import clonotypic_entropy, phenotypic_entropy +from ._mutual_information import mutual_information +from ._flux import phenotypic_flux +from ._colors import tcri_colors, resolve_palette + +__all__ = [ + "clonotypic_entropy", + "phenotypic_entropy", + "mutual_information", + "phenotypic_flux", + "tcri_colors", + "resolve_palette", +] diff --git a/tcri/plotting/_base.py b/tcri/plotting/_base.py new file mode 100644 index 0000000..531ca1d --- /dev/null +++ b/tcri/plotting/_base.py @@ -0,0 +1,78 @@ +"""``pl`` private plotting engine (§8.5) — the generic tidy-DataFrame box/strip renderer +shared by the metric twins, and the save/show finisher. + +The twins are **cache renderers**: they call the ``tl`` metric (which already does its own +``groupby`` via full-space ``clones=`` restriction), get a tidy DataFrame, and hand it here. +Nothing here slices the AnnData or re-computes metric math (fixes the old ``tcri_boxplot`` +slice-and-call pattern that tripped the engine alignment guard). +""" +from __future__ import annotations + +import matplotlib.pyplot as plt + +__all__: list[str] = [] # private module + + +def _finish(fig, ax, *, save=None, show=None): + if save: + fig.savefig(save, bbox_inches="tight", dpi=150) + if show: + plt.show() + return ax + + +def _metric_boxplot(df, *, x, y, hue=None, order=None, hue_order=None, palette=None, + ax=None, figsize=(8, 4), s=20, ylabel=None, rotation=90): + """Box + strip of a tidy metric DataFrame: box over the ``x`` categories, optional + ``hue`` split, individual units as strip dots. Returns ``(fig, ax)``.""" + import seaborn as sns + + if ax is None: + fig, ax = plt.subplots(1, 1, figsize=figsize) + else: + fig = ax.figure + + if df is None or len(df) == 0 or y not in getattr(df, "columns", []): + ax.text(0.5, 0.5, f"no data for {y}", ha="center", va="center", transform=ax.transAxes) + ax.set_ylabel(ylabel if ylabel is not None else y) + return fig, ax + d = df.dropna(subset=[y]) + if len(d) == 0: + ax.text(0.5, 0.5, f"no finite {y}", ha="center", va="center", transform=ax.transAxes) + ax.set_ylabel(ylabel if ylabel is not None else y) + return fig, ax + if order is None and x in d: + order = (d.groupby(x)[y].median().sort_values(ascending=False).index.tolist()) + + common = dict(data=d, x=x, y=y, order=order, ax=ax) + if hue is not None and hue in d.columns: + common.update(hue=hue, hue_order=hue_order) + sns.boxplot(**common, palette=palette, showfliers=False, + boxprops=dict(alpha=0.5)) + sns.stripplot(**common, palette=palette, dodge=hue is not None, size=s / 3, + edgecolor="black", linewidth=0.3) + ax.set_ylabel(ylabel if ylabel is not None else y) + ax.set_xlabel(x) + for label in ax.get_xticklabels(): + label.set_rotation(rotation) + if hue is not None and ax.get_legend() is not None: + ax.legend(bbox_to_anchor=(1.02, 1.0), loc="upper left", frameon=False, fontsize=8) + return fig, ax + + +def _barplot(series, *, ylabel, palette=None, ax=None, figsize=(8, 4), rotation=90): + """Fallback for a no-groupby metric (a Series over phenotypes/clones): a simple bar.""" + if ax is None: + fig, ax = plt.subplots(1, 1, figsize=figsize) + else: + fig = ax.figure + s = series.dropna() + ax.bar(range(len(s)), s.values, + color=(palette if isinstance(palette, str) else None) or tcri_bar_color) + ax.set_xticks(range(len(s))) + ax.set_xticklabels(list(s.index), rotation=rotation) + ax.set_ylabel(ylabel) + return fig, ax + + +tcri_bar_color = "#66D9EF" diff --git a/tcri/plotting/_colors.py b/tcri/plotting/_colors.py new file mode 100644 index 0000000..4c4da56 --- /dev/null +++ b/tcri/plotting/_colors.py @@ -0,0 +1,28 @@ +"""``pl`` colors — the shared categorical palette and `resolve_palette` (§8.6).""" +from __future__ import annotations + +__all__ = ["tcri_colors", "resolve_palette"] + +tcri_colors = [ + "#AE81FF", "#FD971F", "#66D9EF", "#A6E22E", "#F92672", "#E6DB74", "#75715E", + "#D65F0E", "#004d47", "#D291BC", "#3A506B", "#5D8A5E", "#A6A1E2", "#E97451", + "#6C8D67", "#832232", "#1E1E1E", "#F92659", "#272822", "#8B4513", +] + + +def resolve_palette(adata, columns, *, palette=None): + """Assign `tcri_colors` to each `obs` column's categories, store in + ``uns["