Add GATv2 attention-weight extraction - #37
Merged
Conversation
Adds CellinaGCN.get_attention_weights(), which returns the learned GATv2 attention coefficients as one sparse (n_obs, n_obs) cell-by-cell matrix per GAT layer, in the same storage format as adata.obsp["spatial_connectivities"]. Coefficients come from the official PyG API (GATv2Conv.forward(..., return_attention_weights=True)); alpha is never re-derived by hand. With a SparseTensor input that call returns a SparseTensor whose .coo() yields (dst, src, alpha), matching the adj_t convention GCNLayers already uses. Threading: - GCNLayers.forward / GraphEncoder.forward gain return_attention_weights, default False. When False the code path and return signature are unchanged, so nothing on the existing training or inference path moves. - CellinaGCNModule.get_attention() is a dedicated method; inference() is untouched. The spatial-encoder input construction both need is factored into the shared _build_spatial_input() helper so the two cannot drift. - GraphBatchLoader batches additionally expose n_id (the PyG local -> global map for every sampled node), needed to place per-edge quantities back on adata's cell indexing. Purely additive. - create_inference_loader() accepts a per-call num_neighbors override so extraction can force the exact full neighbourhood without mutating the cached splitter. Correctness: - Only edges whose destination is a seed node carry exact attention under neighbour sampling, so non-seed destinations are dropped per batch. Every requested cell is a seed exactly once, so the result stays complete; a nnz-vs-entry-count check catches any duplicate that coo -> csr would otherwise silently sum. - Extraction defaults to num_neighbors=[-1] * n_layers. A finite fan-out below the graph's maximum in-degree raises ValueError unless allow_sampling=True, because sampled coefficients still sum to 1 per row and therefore look correct. - give_mean=True (default) substitutes qzm for the reparameterised z that feeds the GAT input under condition_on_intrinsic=True; module.eval() stops dropout but not the draw. - Non-GAT convolutions raise NotImplementedError naming the actual type. - key_added=None (default) never mutates adata. Orientation: A[i, j] = alpha(j -> i), i.e. row = destination, rows sum to 1. The loader maps a nonzero spatial_connectivities[i, j] to a message i -> j, so the attention pattern is a subset of spatial_connectivities.T, not of the connectivity matrix itself. These coincide only for symmetric graphs; the kNN graphs from spatial_neighbors are not symmetric. Documented and covered by a dedicated test on an asymmetric graph. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SLYXz8HRt6GUjyLjsdWKNm
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
Restore the module's prior train/eval mode after get_attention_weights() (via try/finally, so it also holds when extraction raises). This matters because get_latent_representation() never sets the mode itself and GCNLayers carries an nn.Dropout, so a leaked eval() would silently change its output depending on call order. Reject boolean masks in `indices` with a named error instead of letting them fall through to the uniqueness check, which reported them as duplicated seed cells. Correct two docstring claims to match actual behaviour: `adata` is not honoured as a query object (the loader is always rebuilt from the registered training data), and the documented zero-row behaviour for isolated cells is unreachable in the corner case where a batch's sampled subgraph has no edges at all, which aborts inside GATv2Conv. Both are pre-existing issues reachable via get_latent_representation() and are left for a separate fix. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SLYXz8HRt6GUjyLjsdWKNm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts the learned GATv2 attention coefficients into
adata.obsp-shaped, cell-by-cell sparse matrices — one per GAT layer, no averaging across layers.Coefficients come from the official PyG API (
GATv2Conv.forward(..., return_attention_weights=True)); alpha is never re-derived by hand.API
Returns
{layer_index: csr_matrix}, each(n_obs, n_obs),float32. Withindicesa subset the shape is still(n_obs, n_obs); unselected rows are all-zero.key_added(opt-in) additionally writesadata.obsp[f"{key_added}_l{i}"]; the default never mutatesadata.Orientation:
row = destinationA[i, j] = alpha(j -> i)— the weight celliplaces on its neighbourj. Rows sum to 1 for non-isolated cells and to 0 for isolated ones. This matches theadj_tconvention (row=dst, col=src) already used inGCNLayers.forward.One finding worth flagging. The sparsity pattern is a subset of
spatial_connectivities.T, not ofspatial_connectivitiesitself. The data loader maps a nonzerospatial_connectivities[i, j]to a PyG edgei -> j(edge_index[0] = obsp.row,edge_index[1] = obsp.col), so it is celljthat attends over celli. For a symmetric graph the two patterns coincide, but the kNN graphs fromspatial_neighborsare not symmetric — on crc_210 out-degree is capped atGRAPH_K=20while in-degree reaches 39. This is pre-existing message-passing behaviour, unchanged here, but it is now documented and pinned by a test on a deliberately asymmetric graph (a transposed implementation passes every other test in the section).Determinism (
give_mean)Under
condition_on_intrinsic=True,inference()feeds the reparameterised samplez.detach()into the GCN input.module.eval()disables dropout but not the draw, so attention would differ between two identical calls.give_mean=True(default) substitutesqzm.give_mean=Falsereproduces the sampled behaviour.Seed-slice correctness
Under
NeighborLoader, only edges whose destination is a seed node carry exact attention: a 1-hop nodev's layer-1 alpha depends on the layer-0 outputs ofv's neighbours, which need a 3-hop neighbourhood that was never sampled. So each batch keeps onlydst_local < batch_size. Since every requested cell is a seed exactly once across the loader, every edge is emitted exactly once and the matrix is complete. Annz == accumulated_entriescheck guards this:coo -> csrsilently sums duplicates, so a seed-masking orn_id-remapping bug would inflate values rather than raise.Extraction defaults to
num_neighbors = [-1] * n_layers. A finite fan-out below the graph's max in-degree raisesValueError(naming the degree) unlessallow_sampling=True— sampled coefficients still sum to 1 per row and therefore look correct.Changes
_spatial_encoder.py—return_attention_weights: bool = FalseonGCNLayers.forwardandGraphEncoder.forward. WhenFalse, code path and return signature are identical to before; the encoder appendsattentionsas one trailing element only whenTrue, leaving bothreturn_distshapes untouched. Non-GAT raisesNotImplementedError._cellina_gcn_module.py— newget_attention().inference()is untouched; the spatial-encoder input construction both need is factored into a shared_build_spatial_input()helper so the two cannot drift._edge_data_splitter.py— batches additionally exposen_id(PyG local -> global map for every sampled node, vsnode_indiceswhich is seeds only).create_inference_loaderaccepts a per-callnum_neighborsoverride, so extraction forces the exact neighbourhood without mutating the shared cached splitter._cellina_gcn_model.py—get_attention_weights().batch_size=512is a deliberate default: with[-1, -1]on a k=20 graph a batch expands to ~1+20+400 nodes per seed before dedup and the loader densifies features.Tests
tests/test_cellina_gcn.py, all CPU/synthetic, built on the existing fixtures:test_attention_shape_and_pattern—n_layersentries,(n_obs, n_obs), pattern within the graph; also assertskey_added=Noneleavesadata.obspuntouched andkey_added="…"adds one entry per layer.test_attention_orientation— strictly asymmetric graph; pattern must equal the connectivity transpose exactly.test_attention_rows_sum_to_one— rows sum to 1 (non-isolated) / 0 (isolated), every layer.test_attention_batching_invariance—batch_size=8vsbatch_size=n_obselementwiseallclose, on acondition_on_intrinsic=Truemodel. Also contrastsgive_mean=Truereproducibility againstgive_mean=Falsedrift.test_attention_raises_for_non_gat—NotImplementedErrorat both model and encoder level.test_attention_raises_on_sampling—ValueErroron a finite fan-out below max in-degree; passes withallow_sampling=True; wrong-length fan-out also rejected.test_attention_subset_indices— subset keeps full shape and matches the all-cells result on selected rows.Full suite green on both GPU and CPU (
CUDA_VISIBLE_DEVICES=""), including all 50 pre-existing tests — the default-off kwarg disturbs nothing.Read-only smoke test — crc_210 GAT checkpoint
epoch=19-step=8322-vae_loss_validation=306.0075378417969, adata rebuilt exactly asapplication/02_microenv_gat.ipynbdoes (rawcrc_210.h5ad,preprocess_crc,spatial_neighbors(bandwidth=100/0.12028, max_neighbours=20), drop-NaN ontyp_clean). 5000 cells viaindices,batch_size=256, single GPU.nnz = 99821for 5000 seeds is ~19.96 in-edges/cell, as expected forGRAPH_K=20. The1.04e-07residual is CUDA scatter float non-associativity, not sampling — this checkpoint hascondition_on_intrinsic=False, sogive_meanis inert for it; thegive_meanpath is covered by the syntheticcondition_on_intrinsic=Truetest instead. Note the checkpoint's ownnum_neighbors=[20, 20]is below this graph's max in-degree of 39, so extraction defaulting to[-1, -1](rather than inheriting the model's fan-out) is what makes these coefficients exact.Nothing was written: no
adatamutation, no files touched underapplication/.🤖 Generated with Claude Code
https://claude.ai/code/session_01SLYXz8HRt6GUjyLjsdWKNm
Independent audit
An independent audit checked this implementation against the PyTorch Geometric API. No bugs were found in the extraction logic.
Strongest evidence:
.coo() = (dst, src, alpha)to 1e-6 while the transposed hypothesis matched nothing;MessagePassing._collectmapsedge_index_i = rowforSparseTensor; and on an asymmetric fixture 484/498 entries fall outsidespatial_connectivitiesand 0 fall outside its transpose.batch_size=17reproduced a full-graph reference to1.2e-07with an identical sparsity pattern — validating orientation,n_idremapping, seed masking and completeness at once.n_idmap, off-by-one mask, mask on src, ignoregive_mean, transpose theSparseTensorconstruction).test_attention_orientationis load-bearing — without it four of these survive every other test._build_spatial_inputnon-regression: both commits extracted viagit archive, identical seeds and init, 40/40inference()output tensors bitwise identical across four configs.Fixed in this PR as a result
get_attention_weightsnow restores the module's prior train/eval mode (try/finally).indicesraise a named error rather than being reported as duplicated seeds.Pre-existing issues found, deliberately NOT fixed here
Both are reachable via
get_latent_representation()onrebuttaland belong in their own PR:adata=is silently ignored by_make_data_loader: it is validated and then discarded, with the loader always rebuilt from the registered training data. Passing an adata with empty connectivities and all-zeroXreturns the training-adata result bitwise. The docstring here now says so explicitly.GATv2Conv(GAT-specific;GCNConvhandles the same emptySparseTensorfine). Repro needs no cellina code:Separately worth noting for whoever picks those up:
get_latent_representation()never callsmodule.eval(), soGCNLayers'nn.Dropout(p=0.1)is live there. Existing results are unaffected, becausescvi.model.base.BaseModelClass.loadcallsmodel.module.eval()and analyses load from a checkpoint — the exposure is the train-then-immediately-infer path.Not verified by the audit
CPU only (the
< 1e-5reproducibility threshold intest_attention_batching_invarianceis CPU-calibrated and may be tight on CUDA); the crc_210 smoke-test numbers were not re-run; and nothing pins the PyG version, though a changedSparseTensorreturn contract would raiseAttributeErroron.coo()rather than fail silently.