"""Expression cache with explicit partitions and a metadata-only gene vocabulary.""" from __future__ import annotations import json from pathlib import Path import numpy as np import pandas as pd import scipy.sparse as sp from .preprocess import parse class PerturbData: """Load a prepared cache. Arrays retain the row order of obs.parquet.""" def __init__(self, cache_dir: str, embedding="pca"): self.dir = str(cache_dir) p = Path(cache_dir) self.meta = json.loads((p / "meta.json").read_text()) if self.meta.get("protocol") != "split-first-v1": raise ValueError( "Use a split-first-v1 cache. Historical checkpoints require the archived code." ) if embedding != "pca": raise ValueError( "This release implements the manuscript PCA representation" ) self.obs = pd.read_parquet(p / "obs.parquet") self.genes = (p / "genes_hvg.txt").read_text().splitlines() self.Xhvg = sp.load_npz(p / "Xhvg.npz").tocsr() self.emb = np.load(p / "pca_emb.npy") self.d = self.emb.shape[1] self.pca_components = np.load(p / "pca_components.npy") self.pca_mean = np.load(p / "pca_mean.npy") if len(self.obs) != len(self.emb) or self.Xhvg.shape != ( len(self.obs), len(self.genes), ): raise ValueError("Cache row or feature dimensions disagree") self.sep = self.meta["sep"] self.control_label = self.meta["control_label"] self.operation = self.meta["operation"] self.is_control = self.obs.is_control.to_numpy() self.control_idx = np.flatnonzero(self.is_control) self.batch = self.obs.batch.to_numpy() self.celltype = self.obs.celltype.to_numpy() self.pert_to_idx = { p: s.index.to_numpy() for p, s in self.obs.groupby("perturbation") if p != self.control_label } self.perturbations = sorted(self.pert_to_idx) self.genes_vocab = sorted( {g for p in self.perturbations for g in self.parse(p)} ) self.gene_to_id = {g: i for i, g in enumerate(self.genes_vocab)} self.op_vocab = ["none", self.operation] self.op_to_id = {o: i for i, o in enumerate(self.op_vocab)} self.singles = [p for p in self.perturbations if len(self.parse(p)) == 1] self.combos = [p for p in self.perturbations if len(self.parse(p)) == 2] def parse(self, label: str) -> list[str]: return parse(label, self.control_label, self.sep) def indices(self, partition: str, controls: bool | None = None) -> np.ndarray: mask = self.obs.split.eq(partition).to_numpy(copy=True) if controls is not None: mask &= self.is_control if controls else ~self.is_control return np.flatnonzero(mask) def labels(self, partition: str) -> list[str]: return sorted(set(self.obs.iloc[self.indices(partition, False)].perturbation)) def decode_to_genes(self, emb: np.ndarray) -> np.ndarray: """Rank-d affine reconstruction, shape (..., d) to (..., n_hvg).""" return emb @ self.pca_components + self.pca_mean def pert_gene_op_ids(self, label): genes = self.parse(label) unknown = set(genes) - set(self.gene_to_id) if unknown: raise ValueError(f"Genes outside checkpoint vocabulary: {sorted(unknown)}") ids = np.array([self.gene_to_id[g] for g in genes], dtype=np.int64) return ids, np.full(len(ids), 1, dtype=np.int64) def sample_controls(self, target_idx, strategy, rng, control_pool): """Sample only supplied controls, optionally within batch/cell type. Missing matched controls raise an error so cross-context substitutions cannot change the conditioning distribution without an explicit choice. """ pool = np.asarray(control_pool, dtype=int) if not len(pool) or not self.is_control[pool].all(): raise ValueError("Invalid control pool") if strategy == "random": return rng.choice(pool, len(target_idx), replace=True) if strategy == "nearest": from sklearn.neighbors import NearestNeighbors j = ( NearestNeighbors(n_neighbors=1) .fit(self.emb[pool]) .kneighbors(self.emb[target_idx], return_distance=False) ) return pool[j.ravel()] if strategy not in ("batch", "celltype", "batch_celltype"): raise ValueError(strategy) out = [] for i in target_idx: m = np.ones(len(pool), bool) if strategy in ("batch", "batch_celltype"): m &= self.batch[pool] == self.batch[i] if strategy in ("celltype", "batch_celltype"): m &= self.celltype[pool] == self.celltype[i] if not m.any(): raise ValueError( f"No matching control for cell {self.obs.iloc[i].cell_id}" ) out.append(rng.choice(pool[m])) return np.asarray(out, dtype=np.int64)