|
| 1 | +"""Gene-space attribution utilities for scRNA-seq data. |
| 2 | +
|
| 3 | +Maps Jacobians from PCA space to gene space and computes per-gene |
| 4 | +contributions to the UMAP embedding. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | +from typing import Any |
| 9 | + |
| 10 | +import numpy as np |
| 11 | +from numpy.typing import NDArray |
| 12 | + |
| 13 | +try: |
| 14 | + import anndata as ad |
| 15 | +except ImportError: # pragma: no cover |
| 16 | + ad = None # type: ignore[assignment] |
| 17 | + |
| 18 | + |
| 19 | +def compute_gene_contributions( |
| 20 | + J: NDArray[np.floating], |
| 21 | + adata: Any, # anndata.AnnData; full stubs pending scverse/anndata#2173 |
| 22 | +) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.str_]]: |
| 23 | + """Map Jacobian from PCA space to gene space and compute per-gene contributions. |
| 24 | +
|
| 25 | + Uses the chain rule to project the PCA-space Jacobian through the PCA |
| 26 | + loadings matrix, then multiplies by mean-centered HVG expression to get |
| 27 | + each gene's additive contribution to the embedding. |
| 28 | +
|
| 29 | + Args: |
| 30 | + J: Jacobian in PCA space, shape ``(n, out_dim, n_pcs)``. |
| 31 | + adata: AnnData object with ``.var.highly_variable``, ``.varm["PCs"]``, |
| 32 | + and ``adata.uns["pca_mean_hvg"]`` populated by preprocessing. |
| 33 | +
|
| 34 | + Returns: |
| 35 | + A tuple of: |
| 36 | + - ``contributions_gene``: shape ``(n, out_dim, n_hvgs)`` |
| 37 | + - ``importance_gene``: L2 norm over embedding dims, shape ``(n, n_hvgs)`` |
| 38 | + - ``gene_names_hvg``: array of HVG gene names |
| 39 | + """ |
| 40 | + hvg_mask = adata.var.highly_variable.values |
| 41 | + W_hvg = adata.varm["PCs"][hvg_mask, :] # (n_hvgs, n_pcs) |
| 42 | + gene_names_hvg = adata.var_names[hvg_mask].values |
| 43 | + |
| 44 | + # Jacobian w.r.t. HVG expression: J_gene = J_pc @ W_hvg.T |
| 45 | + J_gene = np.einsum("noi,gi->nog", J, W_hvg) # (n, out_dim, n_hvgs) |
| 46 | + |
| 47 | + # Gene-space input (mean-centered HVG expression) |
| 48 | + X_hvg = adata[:, hvg_mask].X |
| 49 | + if hasattr(X_hvg, "toarray"): |
| 50 | + X_hvg = X_hvg.toarray() |
| 51 | + pca_mean = adata.uns["pca_mean_hvg"] |
| 52 | + X_hvg_centered = X_hvg - pca_mean |
| 53 | + |
| 54 | + # Per-gene contributions and importance |
| 55 | + contributions_gene = np.einsum( |
| 56 | + "nog,nog->nog", J_gene, X_hvg_centered[:, np.newaxis, :] |
| 57 | + ) # (n, out_dim, n_hvgs) |
| 58 | + importance_gene = np.linalg.norm(contributions_gene, axis=1) # (n, n_hvgs) |
| 59 | + |
| 60 | + return contributions_gene, importance_gene, gene_names_hvg |
| 61 | + |
| 62 | + |
| 63 | +def verify_gene_reconstruction( |
| 64 | + contributions_gene: NDArray[np.floating], |
| 65 | + Z: NDArray[np.floating], |
| 66 | + tol: float = 1e-3, |
| 67 | +) -> float: |
| 68 | + """Verify that gene-space contributions sum to the embedding. |
| 69 | +
|
| 70 | + Args: |
| 71 | + contributions_gene: shape ``(n, out_dim, n_hvgs)``. |
| 72 | + Z: Embedding, shape ``(n, out_dim)``. |
| 73 | + tol: Relative error threshold for PASS/FAIL. |
| 74 | +
|
| 75 | + Returns: |
| 76 | + Relative max error. |
| 77 | + """ |
| 78 | + Z_reconstructed = contributions_gene.sum(axis=2) |
| 79 | + max_err = np.abs(Z_reconstructed - Z).max() |
| 80 | + mean_err = np.abs(Z_reconstructed - Z).mean() |
| 81 | + rel_err = max_err / (np.abs(Z).max() + 1e-8) |
| 82 | + print("\n── Gene-Space Reconstruction ──") |
| 83 | + print(f" Max error : {max_err:.2e}") |
| 84 | + print(f" Mean error : {mean_err:.2e}") |
| 85 | + print(f" Rel error : {rel_err:.2e}") |
| 86 | + print(f" Verification {'PASSED ✓' if rel_err < tol else 'FAILED ✗'}") |
| 87 | + return rel_err |
0 commit comments