03 Molecule set: the subcellular transcript point cloud#

The richest input keeps every transcript as a point: for each cell, the set of molecules within a radius of its centroid, each carrying its gene identity and a subcellular offset (dx, dy) in microns. A permutation-invariant Set-Transformer encoder (MoleculeSetVQVAE) pools this point cloud into a cell embedding, quantized against the same cell codebook, with an aggregated kNN neighborhood branch fused by cross-attention.

We use the bundled real Xenium RCC core; its molecule-set shards were built offline from the transcripts table (radius 7 um).

Demo note: ~8 epochs for speed; real runs use many more. The Fourier positional encoding and gather radius both use max_micron = 7.0.

import numpy as np, scanpy as sc
import torch, torch.nn.functional as F
from torch.utils.data import DataLoader
import nicheverse as nv
from nicheverse.data import MoleculeSetDataset
from nicheverse.models.molecule_set import MoleculeSetVQVAE
import os
DATA = os.path.join('..', 'examples', 'data')
adata = nv.read_spatial(f'{DATA}/xenium_rcc_core.h5ad', sample_col='sample_id')
G = adata.n_vars
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('cells', adata.n_obs, 'genes', G, 'device', device)
cells 7824 genes 366 device cuda

Build the molecule-set dataset#

MoleculeSetDataset aligns the per-cell molecule-set shards to the AnnData’s obs_names and, with with_neighborhood=True, attaches the aggregated kNN neighborhood feature (it needs the log-normalized expression, the spatial coordinates, and the sample labels). Each item is (gene, coords, mask, comp, neigh, idx) where comp is the log1p within-radius gene composition used as the cell reconstruction target.

expr = adata.copy(); sc.pp.normalize_total(expr); sc.pp.log1p(expr)
X_lognorm = expr.X.toarray() if hasattr(expr.X, 'toarray') else np.asarray(expr.X)
X_lognorm = X_lognorm.astype(np.float32)
ds = MoleculeSetDataset(
    adata.obs_names.astype(str),
    shard_dir=f'{DATA}/xenium_rcc_core_molecule_sets',
    spatial_coords=np.asarray(adata.obsm['spatial']),
    sample_ids=adata.obs['sample_id'].astype(str).to_numpy(),
    expr_lognorm=X_lognorm,
    k_neighbors=20, with_neighborhood=True,
)
print('molecules per cell (padded length M):', ds.M, ' genes G:', ds.G, ' cells:', len(ds))
molecules per cell (padded length M): 128  genes G: 366  cells: 7824

Train the molecule-set VQ-VAE#

MoleculeSetVQVAE emits the same cell / neighborhood code indices as the standard model, so downstream annotation is unchanged. We train a short loop by hand to show the interface.

torch.manual_seed(9)  # default seed
model = MoleculeSetVQVAE(n_genes=G, cell_num_embeddings=256, neighborhood_num_embeddings=32,
                         use_neighborhood=True, use_cross_attention=True).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)
loader = DataLoader(ds, batch_size=1024, shuffle=True)
for ep in range(8):
    model.train(); tot = 0.0; nb = 0
    for gene, coords, mask, comp, neigh, _ in loader:
        if gene.shape[0] < 2:
            continue
        gene, coords, mask = gene.to(device), coords.to(device), mask.to(device)
        comp, neigh = comp.to(device), neigh.to(device)
        opt.zero_grad(set_to_none=True)
        cr, nr, cvq, nvq, ci, ni, cp, npp = model(gene, coords, mask, neigh)
        loss = F.mse_loss(cr, comp) + cvq + F.mse_loss(nr, neigh) + nvq
        loss.backward(); opt.step()
        tot += float(loss); nb += 1
    print(f'epoch {ep+1}/8 loss={tot/max(nb,1):.4f} cell_perplexity={float(cp):.1f}')
epoch 1/8 loss=1.9090 cell_perplexity=2.4
epoch 2/8 loss=1.4716 cell_perplexity=2.0
epoch 3/8 loss=1.2657 cell_perplexity=2.8
epoch 4/8 loss=1.0618 cell_perplexity=2.9
epoch 5/8 loss=0.9414 cell_perplexity=3.5
epoch 6/8 loss=0.8637 cell_perplexity=3.8
epoch 7/8 loss=0.8246 cell_perplexity=46.6
epoch 8/8 loss=0.7129 cell_perplexity=26.5

Assign codes to every cell#

model.eval(); cidx = []
with torch.inference_mode():
    for gene, coords, mask, comp, neigh, _ in DataLoader(ds, batch_size=1024, shuffle=False):
        gene, coords, mask, neigh = gene.to(device), coords.to(device), mask.to(device), neigh.to(device)
        _, _, _, _, ci, ni, _, _ = model(gene, coords, mask, neigh)
        cidx.append(ci.cpu().numpy().reshape(-1))
cidx = np.concatenate(cidx)
print('cells coded:', len(cidx), ' cell codes used:', len(np.unique(cidx)), '/ 256')
adata.obs['cell_codebook_idx'] = cidx
print('done')
cells coded: 7824  cell codes used: 80 / 256
done

The subcellular point cloud is the most information-rich input (it keeps molecular position within the cell). On a diverse cohort it produces a healthy, biologically coherent codebook; the fullness on this single homogeneous core is limited by biology, not the method. Important: for any set / attention encoder, pool with concat[masked max, masked mean, PMA] (PMA alone collapses the pooled embedding); MoleculeSetEncoder already does this.