ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution.
The 24 matches
- [1] § STAR★Methods › Method details › Pseudo-spatial known-composition benchmark ↔ scripts/run_revision_known_composition_benchmark.py, lines 970–1033 · score 0.99 · training reference h5ad, diffuse mixture, low depth, binomial thinning, uniform mixture, pseudo bulk
- [2] § STAR★Methods › Method details › Pseudo-spatial known-composition benchmark ↔ scripts/run_revision_known_composition_benchmark.py, lines 35–116 · score 0.99 · BrNum, donor disjoint split, cellType_k, known composition benchmark, smooth pseudo spatial, random seed
- [3] § Results › Known-composition pseudo-spots directly link ReliST risk to true deconvolution error ↔ scripts/run_revision_known_composition_benchmark.py, lines 35–116 · score 0.94 · BrNum, cellType_k, known composition benchmark, smooth pseudo spatial, minimum cell, DLPFC snRNA
- [4] § STAR★Methods › Method details › Reference perturbation stress tests ↔ scripts/run_revision_reference_perturbation.py, lines 268–347 · score 0.94 · reference composition column, coarsened truth, gene dropout, reference contamination, reference expression profile, Reference perturbation
- [5] § STAR★Methods › Method details › Revision analysis reproducibility ↔ scripts/build_revision_figures.py, lines 83–134 · score 0.92 · revision component ablation, revision threshold sensitivity, multimodel eval, revision reference perturbation, revision known composition, build revision
- [6] § STAR★Methods › Method details › Component ablation ↔ scripts/run_revision_component_ablation.py, lines 176–218 · score 0.90 · Component ablation, ablation score, single component, full risk, keep fractions, phi_local
- [7] § STAR★Methods › Method details › Risk score calculation ↔ scripts/run_revision_known_composition_benchmark.py, lines 910–968 · score 0.89 · local_uncertainty_risk_score, reference_risk_score, revision known composition, phi_local, phi_uncertainty, phi_reference
- [8] § STAR★Methods › Method details › Risk score calculation ↔ scripts/run_revision_known_composition_multimodel_eval.py, lines 486–601 · score 0.89 · local_uncertainty_risk_score, reference_risk_score, revision known composition, phi_local, phi_uncertainty, phi_reference
- [9] § STAR★Methods › Method details › Component ablation ↔ scripts/run_revision_reference_perturbation_component_ablation.py, lines 111–205 · score 0.83 · Component ablation, phi_local, phi_uncertainty, phi_reference, full risk, Delta
- [10] § STAR★Methods › Method details › Threshold sensitivity and use-case guidance ↔ scripts/run_revision_threshold_sensitivity.py, lines 203–246 · score 0.80 · Threshold sensitivity, coverage risk curves, illustrative review budgets, risk fractions, policies, error reduction
- [11] § Results › Known-composition pseudo-spots directly link ReliST risk to true deconvolution error ↔ scripts/run_revision_threshold_sensitivity.py, lines 203–246 · score 0.80 · threshold sensitivity, coverage risk curve, illustrative review budgets, risk_score, keep fractions, cutoffs
- [12] § Results › DLPFC supports risk scores as proxy-anchored diagnostic signals ↔ src/st_risk/reporting/anchored_validation.py, lines 399–455 · score 0.74 · Retained proxy ratios, signature_residual, marker_discordance, coverage risk curve, layer_guess, reliability curve
- [13] § STAR★Methods › Method details › Confidence and uncertainty baseline comparison ↔ scripts/build_revision_figures.py, lines 1–80 · score 0.73 · Cross model disagreement, abundance entropy risk, inverse top, ReliST, ambiguity, margin
- [14] § STAR★Methods › Method details › Revision analysis reproducibility ↔ scripts/run_revision_known_composition_multimodel_eval.py, lines 55–96 · score 0.73 · multimodel eval, revision known composition, seed, benchmark
- [15] § Results › Common-feature controls and goal-based decision use define support tiers ↔ src/st_risk/reporting/decision_support.py, lines 266–361 · score 0.70 · decision support bundle, low disagreement, high disagreement, risk maps, trusted, filters
- [16] § Results › Known-composition pseudo-spots directly link ReliST risk to true deconvolution error ↔ scripts/run_revision_reference_perturbation.py, lines 268–347 · score 0.66 · gene dropout, reference contamination, reference perturbation, coarsening, excitatory, inhibitory
- [17] § Results › DLPFC supports risk scores as proxy-anchored diagnostic signals ↔ src/st_risk/reporting/anchored_validation.py, lines 1–31 · score 0.66 · Maynard prototype distance, anchored validation, signature residual, discordance, proxies, layer
- [18] § Results › DLPFC supports risk scores as proxy-anchored diagnostic signals ↔ scripts/run_revision_uncertainty_baseline_eval.py, lines 155–179 · score 0.64 · signature_residual, marker_discordance, layer_guess, Maynard, AUCs, anchored
- [19] § STAR★Methods › Quantification and statistical analysis › Validation metrics, resampling, and artifact-aware interpretation ↔ src/st_risk/reporting/anchored_validation.py, lines 1–31 · score 0.63 · Maynard prototype distance, signature residual, discordance, anchored, validation, layer
- [20] § STAR★Methods › Method details › Base models and canonical output contract ↔ scripts/build_revision_figures.py, lines 389–430 · score 0.57 · risk axis score, contract fairness, AUC, proxy
- [21] § STAR★Methods › Method details › Reproducible risk scoring ↔ scripts/run_risk_scoring.py, lines 163–236 · score 0.54 · reference_subsampling_instability, phi_reference, discordance, subsets, score, risk
- [22] § STAR★Methods › Method details › Reproducible risk scoring ↔ src/st_risk/eval/reference_eval.py, lines 180–207 · score 0.54 · reference_subsampling_instability, phi_reference, discordance, subsets, score, risk
- [23] § Results › Known-composition pseudo-spots directly link ReliST risk to true deconvolution error ↔ scripts/run_revision_component_ablation.py, lines 176–218 · score 0.52 · component ablation, known composition benchmark, phi_reference, reference perturbation, matched, pseudo
- [24] § STAR★Methods › Method details › Reproducible risk scoring ↔ scripts/run_revision_known_composition_benchmark.py, lines 848–908 · score 0.51 · inverse distance weighting, matrix, neighborhoods, abundance, spot, scoring
Paper
Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC
The paper is loaded when this pane is shown.
The authors' code
Python · 1,037 lines · 45 KB · MIT · 5 matches
- from __future__ import annotations
- import argparse
- import json
- from pathlib import Path
- import anndata as ad
- import numpy as np
- import pandas as pd
- from scipy import sparse
- from scipy.optimize import nnls
- from scipy.stats import pearsonr, spearmanr
- from sklearn.decomposition import TruncatedSVD
- from sklearn.metrics import average_precision_score, roc_auc_score
- from st_risk.eval.reference_eval import (
- compute_reference_marker_scores,
- reference_marker_discordance_proxy,
- reference_signature_residual_proxy,
- reference_subsampling_instability,
- select_signature_markers,
- subset_markers,
- )
- from st_risk.models.base import BaseSpatialModelOutput
- from st_risk.paths import ensure_results_layout, project_root, results_file, set_selected_run
- from st_risk.risk.features import ambiguity_score, build_feature_table
- from st_risk.risk.neighbors import inverse_distance_weights, knn_indices
- from st_risk.risk.score import grouped_zscore, sigmoid
- from st_risk.risk.stability import gene_subsample_stability, ridge_project_celltype_proportions, row_normalize
- DEFAULT_RUN_ID = "2026-06-20-dlpfc-known-composition-v2-donor-disjoint"
- def parse_args() -> argparse.Namespace:
- default_source_root = project_root() / "results" / "dlpfc_rctd"
- selected_run = (default_source_root / "selected_run.txt").read_text(encoding="utf-8").strip()
- default_source_run = default_source_root / "runs" / selected_run
- parser = argparse.ArgumentParser(
- description="Build a DLPFC pseudo-spot known-composition benchmark for revision analyses."
- )
- parser.add_argument(
- "--reference-h5ad",
- type=Path,
- default=project_root() / "data" / "Human DLPFC" / "ready" / "dlpfc_snrna_ref.h5ad",
- help="snRNA reference h5ad used to draw pseudo-spots.",
- )
- parser.add_argument(
- "--reference-label-column",
- default="cellType_k",
- help="Reference cell-type column used as the known composition label.",
- )
- parser.add_argument(
- "--split-column",
- default="BrNum",
- help="Reference obs column used for donor-disjoint train/simulation split.",
- )
- parser.add_argument(
- "--split-mode",
- default="donor_disjoint",
- choices=("donor_disjoint", "cell_random"),
- help="How to split reference cells into signature-building and held-out simulation pools.",
- )
- parser.add_argument(
- "--min-split-cells-per-type",
- type=int,
- default=20,
- help="Minimum cells per retained type on each side of a donor-disjoint split.",
- )
- parser.add_argument(
- "--source-run-dir",
- type=Path,
- default=default_source_run,
- help="Existing DLPFC run providing the selected gene list and optional cell-type order.",
- )
- parser.add_argument(
- "--output-root",
- type=Path,
- default=project_root() / "results" / "revision_known_composition_benchmark",
- help="Result root for this revision benchmark.",
- )
- parser.add_argument("--run-id", default=DEFAULT_RUN_ID, help="Run id under output-root/runs/.")
- parser.add_argument("--n-spots", type=int, default=1200, help="Number of pseudo-spots to generate.")
- parser.add_argument("--n-regions", type=int, default=6, help="Number of smooth pseudo-spatial regions.")
- parser.add_argument("--cells-per-spot-min", type=int, default=4, help="Minimum cells per pseudo-spot.")
- parser.add_argument("--cells-per-spot-max", type=int, default=10, help="Maximum cells per pseudo-spot.")
- parser.add_argument("--min-cells-per-type", type=int, default=80, help="Minimum reference cells per retained type.")
- parser.add_argument("--train-fraction", type=float, default=0.5, help="Fraction of cells used to build signatures.")
- parser.add_argument("--layer", default="counts", help="Reference h5ad layer containing raw counts.")
- parser.add_argument("--marker-top-k", type=int, default=25, help="Top signature markers per cell type.")
- parser.add_argument("--min-positive-markers", type=int, default=10, help="Minimum positive markers per cell type.")
- parser.add_argument(
- "--marker-subset-mode",
- default="odd",
- choices=("all", "odd", "even", "top_half", "bottom_half"),
- help="Marker subset used for reference_subsampling_instability.",
- )
- parser.add_argument("--reference-repeats", type=int, default=8, help="Marker subsampling repeats.")
- parser.add_argument("--reference-fraction", type=float, default=0.5, help="Marker subsampling fraction.")
- parser.add_argument(
- "--projection-method",
- default="nnls",
- choices=("nnls", "ridge"),
- help="Nonnegative projection method used as the lightweight deconvolution baseline.",
- )
- parser.add_argument(
- "--stability-repeats",
- type=int,
- default=0,
- help="Optional gene-subsampling stability repeats. Default 0 keeps this revision benchmark reference-centered.",
- )
- parser.add_argument("--stability-gene-fraction", type=float, default=0.8, help="Gene fraction if stability is enabled.")
- parser.add_argument("--ridge-lambda", type=float, default=1e-3, help="Ridge penalty for signature projection.")
- parser.add_argument("--random-state", type=int, default=20260620, help="Random seed.")
- return parser.parse_args()
- def _read_gene_list(source_run_dir: Path) -> list[str]:
- used_genes_path = source_run_dir / "tables" / "base_model_used_genes.csv"
- if not used_genes_path.exists():
- used_genes_path = source_run_dir / "tables" / "cell2location_used_genes.csv"
- if used_genes_path.exists():
- return pd.read_csv(used_genes_path)["gene"].astype(str).tolist()
- signatures_path = source_run_dir / "tables" / "reference_signatures_means.csv"
- if not signatures_path.exists():
- raise FileNotFoundError(
- f"Could not find base_model_used_genes.csv or reference_signatures_means.csv under {source_run_dir}"
- )
- return pd.read_csv(signatures_path, index_col=0).index.astype(str).tolist()
- def _read_source_celltype_order(source_run_dir: Path) -> list[str] | None:
- signatures_path = source_run_dir / "tables" / "reference_signatures_means.csv"
- if not signatures_path.exists():
- return None
- signatures = pd.read_csv(signatures_path, index_col=0, nrows=1)
- return signatures.columns.astype(str).tolist()
- def _normalize_log_cp10k(matrix: np.ndarray | sparse.spmatrix) -> np.ndarray:
- if sparse.issparse(matrix):
- matrix = matrix.toarray()
- values = np.asarray(matrix, dtype=np.float32)
- library = values.sum(axis=1, keepdims=True)
- safe_library = np.where(np.isclose(library, 0.0), 1.0, library)
- return np.log1p((values / safe_library) * 1e4).astype(np.float32)
- def _as_dense_vector(matrix: np.ndarray | sparse.spmatrix) -> np.ndarray:
- if sparse.issparse(matrix):
- matrix = matrix.toarray()
- return np.asarray(matrix, dtype=np.float32).reshape(-1)
- def _select_reference_matrix(
- reference_h5ad: Path,
- *,
- layer: str,
- label_col: str,
- split_col: str | None,
- requested_genes: list[str],
- source_celltype_order: list[str] | None,
- min_cells_per_type: int,
- ) -> tuple[pd.DataFrame, sparse.spmatrix | np.ndarray, list[str], list[str], pd.DataFrame]:
- adata = ad.read_h5ad(reference_h5ad, backed="r")
- if label_col not in adata.obs.columns:
- raise KeyError(f"{label_col} is not present in {reference_h5ad}")
- if split_col and split_col not in adata.obs.columns:
- raise KeyError(f"{split_col} is not present in {reference_h5ad}")
- if layer not in adata.layers:
- raise KeyError(f"{layer} is not present in layers of {reference_h5ad}")
- var_lookup = {str(gene).lower(): str(gene) for gene in adata.var_names.astype(str)}
- genes = [var_lookup[str(gene).lower()] for gene in requested_genes if str(gene).lower() in var_lookup]
- if len(genes) < 50:
- raise ValueError(f"Only {len(genes)} requested genes were found in {reference_h5ad}; need at least 50.")
- labels_all = adata.obs[label_col].astype(str)
- counts_by_type = labels_all.value_counts()
- source_order = pd.Index(source_celltype_order or [], dtype=str)
- if source_celltype_order is None:
- celltypes = counts_by_type.loc[counts_by_type >= min_cells_per_type].index.astype(str).tolist()
- else:
- celltypes = [
- celltype
- for celltype in source_celltype_order
- if celltype in counts_by_type.index and int(counts_by_type[celltype]) >= min_cells_per_type
- ]
- if len(celltypes) < 3:
- raise ValueError("At least three retained cell types are required for this benchmark.")
- inclusion_table = (
- counts_by_type.rename_axis("celltype")
- .reset_index(name="n_reference_cells")
- .assign(
- in_source_celltype_order=lambda frame: frame["celltype"].isin(source_order).astype(bool),
- passes_min_cells=lambda frame: frame["n_reference_cells"].ge(min_cells_per_type),
- retained_before_split=lambda frame: frame["celltype"].isin(celltypes).astype(bool),
- )
- .sort_values(["retained_before_split", "n_reference_cells"], ascending=[False, False])
- .reset_index(drop=True)
- )
- keep_mask = labels_all.isin(celltypes).to_numpy()
- obs_columns = [label_col]
- if split_col:
- obs_columns.append(split_col)
- obs = adata.obs.loc[keep_mask, obs_columns].copy()
- obs[label_col] = obs[label_col].astype(str)
- if split_col:
- obs[split_col] = obs[split_col].astype(str)
- matrix = adata[keep_mask, genes].layers[layer]
- if sparse.issparse(matrix):
- matrix = matrix.tocsr()
- else:
- matrix = np.asarray(matrix, dtype=np.float32)
- if hasattr(adata, "file") and adata.file is not None:
- adata.file.close()
- return obs, matrix, genes, celltypes, inclusion_table
- def _split_reference_cells(
- obs: pd.DataFrame,
- *,
- label_col: str,
- celltypes: list[str],
- train_fraction: float,
- rng: np.random.Generator,
- ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
- train: dict[str, np.ndarray] = {}
- simulate: dict[str, np.ndarray] = {}
- fraction = float(np.clip(train_fraction, 0.2, 0.8))
- labels = obs[label_col].astype(str).to_numpy()
- for celltype in celltypes:
- positions = np.flatnonzero(labels == celltype)
- shuffled = rng.permutation(positions)
- n_train = int(round(len(shuffled) * fraction))
- n_train = min(max(n_train, 20), len(shuffled) - 1)
- train[celltype] = np.sort(shuffled[:n_train])
- simulate[celltype] = np.sort(shuffled[n_train:])
- if simulate[celltype].size == 0:
- simulate[celltype] = train[celltype]
- return train, simulate
- def _split_reference_cells_donor_disjoint(
- obs: pd.DataFrame,
- *,
- label_col: str,
- split_col: str,
- celltypes: list[str],
- train_fraction: float,
- min_cells_per_side: int,
- rng: np.random.Generator,
- ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], list[str], dict[str, object]]:
- labels = obs[label_col].astype(str).to_numpy()
- units = obs[split_col].astype(str).to_numpy()
- unique_units = np.asarray(pd.Index(units).unique().astype(str), dtype=object)
- if unique_units.size < 2:
- raise ValueError(f"Need at least two unique {split_col} values for donor-disjoint split.")
- fraction = float(np.clip(train_fraction, 0.2, 0.8))
- n_train_units = int(round(unique_units.size * fraction))
- n_train_units = min(max(n_train_units, 1), unique_units.size - 1)
- best: tuple[list[str], set[str], set[str]] | None = None
- best_score = (-1, -1)
- for _ in range(500):
- shuffled = rng.permutation(unique_units)
- train_units = set(map(str, shuffled[:n_train_units]))
- simulate_units = set(map(str, shuffled[n_train_units:]))
- train_mask = np.asarray([unit in train_units for unit in units], dtype=bool)
- simulate_mask = np.asarray([unit in simulate_units for unit in units], dtype=bool)
- retained: list[str] = []
- total_cells = 0
- for celltype in celltypes:
- label_mask = labels == celltype
- n_train = int((label_mask & train_mask).sum())
- n_sim = int((label_mask & simulate_mask).sum())
- if n_train >= min_cells_per_side and n_sim >= min_cells_per_side:
- retained.append(celltype)
- total_cells += n_train + n_sim
- score = (len(retained), total_cells)
- if score > best_score:
- best = (retained, train_units, simulate_units)
- best_score = score
- if len(retained) == len(celltypes):
- break
- if best is None:
- raise RuntimeError("Could not construct a donor-disjoint split.")
- retained_celltypes, train_units, simulate_units = best
- if len(retained_celltypes) < 3:
- raise ValueError(
- "Fewer than three cell types passed the donor-disjoint split filters; "
- "try lowering --min-split-cells-per-type or using --split-mode cell_random."
- )
- train: dict[str, np.ndarray] = {}
- simulate: dict[str, np.ndarray] = {}
- train_mask = np.asarray([unit in train_units for unit in units], dtype=bool)
- simulate_mask = np.asarray([unit in simulate_units for unit in units], dtype=bool)
- for celltype in retained_celltypes:
- label_mask = labels == celltype
- train[celltype] = np.flatnonzero(label_mask & train_mask)
- simulate[celltype] = np.flatnonzero(label_mask & simulate_mask)
- metadata = {
- "split_mode": "donor_disjoint",
- "split_column": split_col,
- "train_units": sorted(train_units),
- "simulate_units": sorted(simulate_units),
- "n_train_units": int(len(train_units)),
- "n_simulate_units": int(len(simulate_units)),
- "min_split_cells_per_type": int(min_cells_per_side),
- "n_retained_celltypes_after_split": int(len(retained_celltypes)),
- "dropped_after_split": [celltype for celltype in celltypes if celltype not in retained_celltypes],
- }
- return train, simulate, retained_celltypes, metadata
- def _augment_inclusion_table(
- inclusion_table: pd.DataFrame,
- *,
- celltypes_after_split: list[str],
- train_cells: dict[str, np.ndarray],
- simulate_cells: dict[str, np.ndarray],
- ) -> pd.DataFrame:
- table = inclusion_table.copy()
- train_counts = {celltype: int(len(indices)) for celltype, indices in train_cells.items()}
- simulate_counts = {celltype: int(len(indices)) for celltype, indices in simulate_cells.items()}
- table["n_train_signature_cells"] = table["celltype"].map(train_counts).fillna(0).astype(int)
- table["n_heldout_simulation_cells"] = table["celltype"].map(simulate_counts).fillna(0).astype(int)
- table["retained_after_split"] = table["celltype"].isin(celltypes_after_split).astype(bool)
- table["exclusion_reason"] = "retained"
- table.loc[~table["passes_min_cells"], "exclusion_reason"] = "below_min_cells_per_type"
- table.loc[
- table["retained_before_split"] & ~table["retained_after_split"],
- "exclusion_reason",
- ] = "insufficient_train_or_heldout_cells_after_split"
- table.loc[~table["retained_before_split"] & table["passes_min_cells"], "exclusion_reason"] = "not_in_source_celltype_order"
- return table
- def _compute_signatures(
- matrix: sparse.spmatrix | np.ndarray,
- *,
- train_cells: dict[str, np.ndarray],
- genes: list[str],
- celltypes: list[str],
- ) -> pd.DataFrame:
- columns = {}
- for celltype in celltypes:
- normalized = _normalize_log_cp10k(matrix[train_cells[celltype], :])
- columns[celltype] = normalized.mean(axis=0)
- signatures = pd.DataFrame(columns, index=genes, dtype=float)
- return signatures
- def _build_region_prototypes(celltypes: list[str], *, n_regions: int, rng: np.random.Generator) -> pd.DataFrame:
- n_types = len(celltypes)
- rows = []
- for region_id in range(n_regions):
- alpha = np.full(n_types, 0.08, dtype=float)
- dominant_count = min(4, n_types)
- dominant = rng.choice(n_types, size=dominant_count, replace=False)
- alpha[dominant] = 3.0
- prototype = rng.dirichlet(alpha)
- rows.append(prototype)
- return pd.DataFrame(rows, columns=celltypes, index=[f"region_{i + 1}" for i in range(n_regions)])
- def _smooth_region_composition(
- *,
- y: int,
- height: int,
- prototypes: pd.DataFrame,
- rng: np.random.Generator,
- concentration: float,
- ) -> tuple[str, np.ndarray]:
- n_regions = prototypes.shape[0]
- scaled = ((y + 0.5) / max(height, 1)) * n_regions
- lower = int(np.floor(scaled))
- lower = min(max(lower, 0), n_regions - 1)
- upper = min(lower + 1, n_regions - 1)
- mix = scaled - lower
- base = (1.0 - mix) * prototypes.iloc[lower].to_numpy(dtype=float) + mix * prototypes.iloc[upper].to_numpy(dtype=float)
- alpha = np.clip(base * concentration, 0.02, None)
- composition = rng.dirichlet(alpha)
- return str(prototypes.index[lower]), composition
- def _sample_pseudo_spots(
- matrix: sparse.spmatrix | np.ndarray,
- *,
- simulate_cells: dict[str, np.ndarray],
- celltypes: list[str],
- genes: list[str],
- markers: dict[str, list[str]],
- n_spots: int,
- n_regions: int,
- cells_per_spot_min: int,
- cells_per_spot_max: int,
- rng: np.random.Generator,
- ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, np.ndarray]:
- width = int(np.ceil(np.sqrt(n_spots)))
- height = int(np.ceil(n_spots / width))
- prototypes = _build_region_prototypes(celltypes, n_regions=n_regions, rng=rng)
- gene_to_idx = {gene: idx for idx, gene in enumerate(genes)}
- scenario_names = np.asarray(["clean", "low_depth", "marker_dropout", "diffuse_mixture"], dtype=object)
- scenario_probs = np.asarray([0.55, 0.15, 0.15, 0.15], dtype=float)
- spot_rows: list[dict[str, object]] = []
- true_rows: list[np.ndarray] = []
- pseudo_counts = np.zeros((n_spots, len(genes)), dtype=np.float32)
- for spot_id in range(n_spots):
- x = spot_id % width
- y = spot_id // width
- scenario = str(rng.choice(scenario_names, p=scenario_probs))
- concentration = 12.0 if scenario == "diffuse_mixture" else 80.0
- region_name, composition = _smooth_region_composition(
- y=y,
- height=height,
- prototypes=prototypes,
- rng=rng,
- concentration=concentration,
- )
- if scenario == "diffuse_mixture":
- composition = row_normalize((0.65 * composition + 0.35 / len(celltypes))[None, :])[0]
- n_cells = int(rng.integers(cells_per_spot_min, cells_per_spot_max + 1))
- type_counts = rng.multinomial(n_cells, composition)
- if type_counts.sum() == 0:
- type_counts[int(np.argmax(composition))] = n_cells
- true_fraction = type_counts / max(type_counts.sum(), 1)
- selected_cell_positions: list[int] = []
- for celltype, count in zip(celltypes, type_counts, strict=True):
- if count <= 0:
- continue
- pool = simulate_cells[celltype]
- selected = rng.choice(pool, size=int(count), replace=True)
- selected_cell_positions.extend(int(value) for value in selected.tolist())
- if selected_cell_positions:
- spot_counts = _as_dense_vector(matrix[selected_cell_positions, :].sum(axis=0))
- else:
- spot_counts = np.zeros(len(genes), dtype=np.float32)
- if scenario == "low_depth":
- keep_probability = float(rng.uniform(0.15, 0.45))
- spot_counts = rng.binomial(np.maximum(spot_counts, 0).astype(np.int64), keep_probability).astype(np.float32)
- elif scenario == "marker_dropout":
- dominant_type = celltypes[int(np.argmax(true_fraction))]
- marker_genes = [gene for gene in markers.get(dominant_type, []) if gene in gene_to_idx]
- if marker_genes:
- marker_idx = np.asarray([gene_to_idx[gene] for gene in marker_genes], dtype=int)
- spot_counts[marker_idx] = rng.binomial(
- np.maximum(spot_counts[marker_idx], 0).astype(np.int64),
- 0.25,
- ).astype(np.float32)
- pseudo_counts[spot_id, :] = spot_counts
- spot_name = f"pseudo_spot_{spot_id + 1:05d}"
- spot_rows.append(
- {
- "spot_id": spot_name,
- "sample_id": "dlpfc_known_composition",
- "x_spatial": float(x),
- "y_spatial": float(y),
- "pseudo_region": region_name,
- "scenario": scenario,
- "n_cells": int(n_cells),
- "library_size": float(spot_counts.sum()),
- }
- )
- true_rows.append(true_fraction)
- spot_table = pd.DataFrame(spot_rows).set_index("spot_id")
- true_abundance = pd.DataFrame(true_rows, index=spot_table.index, columns=celltypes)
- return spot_table, true_abundance, prototypes, pseudo_counts
- def _compute_local_heterogeneity(expression: np.ndarray, neighbors: np.ndarray, *, n_components: int = 12) -> np.ndarray:
- n_components = min(n_components, max(2, expression.shape[1] - 1), max(2, expression.shape[0] - 1))
- svd = TruncatedSVD(n_components=n_components, random_state=0)
- embedding = svd.fit_transform(expression)
- heterogeneity = np.zeros(embedding.shape[0], dtype=float)
- for i, row in enumerate(neighbors):
- valid = row[row >= 0]
- if len(valid) == 0:
- heterogeneity[i] = 1.0
- continue
- local = embedding[valid]
- center = local.mean(axis=0, keepdims=True)
- heterogeneity[i] = float(np.mean(np.sum((local - center) ** 2, axis=1))) + 1e-6
- return heterogeneity
- def _project_abundance(
- expression: np.ndarray,
- signatures: pd.DataFrame,
- *,
- method: str,
- ridge_lambda: float,
- ) -> np.ndarray:
- normalized_method = method.strip().lower()
- signature_values = signatures.to_numpy(dtype=np.float32)
- if normalized_method == "ridge":
- return ridge_project_celltype_proportions(expression, signature_values, ridge_lambda=ridge_lambda)
- if normalized_method != "nnls":
- raise ValueError(f"Unsupported projection method: {method}")
- projected = np.zeros((expression.shape[0], signature_values.shape[1]), dtype=np.float32)
- for idx, y in enumerate(expression):
- weights, _ = nnls(signature_values, np.asarray(y, dtype=np.float64), maxiter=1000)
- projected[idx, :] = weights.astype(np.float32)
- return row_normalize(projected)
- def _combine_any_features(
- table: pd.DataFrame,
- weights: dict[str, float],
- *,
- groups: pd.Series | None = None,
- ) -> pd.Series:
- linear = np.zeros(table.shape[0], dtype=float)
- total_weight = 0.0
- for name, weight in weights.items():
- if name not in table.columns or np.isclose(float(weight), 0.0):
- continue
- linear += float(weight) * grouped_zscore(table[name].to_numpy(dtype=float), groups=groups)
- total_weight += abs(float(weight))
- if np.isclose(total_weight, 0.0):
- raise ValueError("At least one non-zero available feature is required.")
- return pd.Series(sigmoid(linear / total_weight), index=table.index)
- def _abundance_baselines(predicted: pd.DataFrame) -> pd.DataFrame:
- values = predicted.to_numpy(dtype=float)
- row_sums = values.sum(axis=1, keepdims=True)
- probs = np.divide(values, row_sums, out=np.zeros_like(values), where=row_sums > 0)
- sorted_probs = np.sort(probs, axis=1)
- top1 = sorted_probs[:, -1] if probs.shape[1] else np.zeros(probs.shape[0], dtype=float)
- top2 = sorted_probs[:, -2] if probs.shape[1] > 1 else np.zeros(probs.shape[0], dtype=float)
- if probs.shape[1] <= 1:
- entropy = np.zeros(probs.shape[0], dtype=float)
- else:
- entropy = -np.sum(probs * np.log(probs + 1e-12), axis=1) / np.log(probs.shape[1])
- return pd.DataFrame(
- {
- "abundance_entropy_risk": entropy,
- "inverse_top1_margin": 1.0 - (top1 - top2),
- "inverse_max_abundance": 1.0 - top1,
- },
- index=predicted.index,
- )
- def _error_table(predicted: pd.DataFrame, truth: pd.DataFrame) -> pd.DataFrame:
- pred = predicted.loc[truth.index, truth.columns].to_numpy(dtype=float)
- true = truth.to_numpy(dtype=float)
- absolute = np.abs(pred - true)
- rmse = np.sqrt(np.mean((pred - true) ** 2, axis=1))
- numerator = (pred * true).sum(axis=1)
- denominator = np.linalg.norm(pred, axis=1) * np.linalg.norm(true, axis=1)
- cosine = np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator > 0)
- return pd.DataFrame(
- {
- "l1_error": absolute.sum(axis=1),
- "total_variation_error": 0.5 * absolute.sum(axis=1),
- "rmse_error": rmse,
- "cosine_distance": 1.0 - np.clip(cosine, -1.0, 1.0),
- "dominant_mismatch": predicted.idxmax(axis=1).ne(truth.idxmax(axis=1)).astype(int).to_numpy(),
- },
- index=truth.index,
- )
- def _safe_corr(score: pd.Series, error: pd.Series, *, method: str) -> tuple[float, float]:
- valid = pd.concat([score, error], axis=1).dropna()
- if valid.shape[0] < 3 or valid.iloc[:, 0].nunique() <= 1 or valid.iloc[:, 1].nunique() <= 1:
- return np.nan, np.nan
- if method == "spearman":
- stat, pvalue = spearmanr(valid.iloc[:, 0], valid.iloc[:, 1])
- elif method == "pearson":
- stat, pvalue = pearsonr(valid.iloc[:, 0], valid.iloc[:, 1])
- else:
- raise ValueError(method)
- return float(stat), float(pvalue)
- def _safe_auc(score: pd.Series, labels: pd.Series) -> tuple[float, float]:
- valid = pd.concat([score, labels], axis=1).dropna()
- if valid.shape[0] < 3 or valid.iloc[:, 1].nunique() < 2 or valid.iloc[:, 0].nunique() <= 1:
- return np.nan, np.nan
- y_true = valid.iloc[:, 1].astype(int).to_numpy()
- y_score = valid.iloc[:, 0].astype(float).to_numpy()
- return float(roc_auc_score(y_true, y_score)), float(average_precision_score(y_true, y_score))
- def _score_error_summary(table: pd.DataFrame, *, score_cols: list[str], error_col: str) -> pd.DataFrame:
- error = table[error_col].astype(float)
- high20 = (error >= error.quantile(0.80)).astype(int)
- high10 = (error >= error.quantile(0.90)).astype(int)
- rows = []
- for score_col in score_cols:
- score = table[score_col].astype(float)
- spearman, spearman_p = _safe_corr(score, error, method="spearman")
- pearson, pearson_p = _safe_corr(score, error, method="pearson")
- auc20, ap20 = _safe_auc(score, high20)
- auc10, ap10 = _safe_auc(score, high10)
- low_mask = score <= score.quantile(0.20)
- high_mask = score >= score.quantile(0.80)
- rows.append(
- {
- "score_name": score_col,
- "n_spots": int(score.notna().sum()),
- "error_col": error_col,
- "spearman_error": spearman,
- "spearman_pvalue": spearman_p,
- "pearson_error": pearson,
- "pearson_pvalue": pearson_p,
- "auroc_top20_error": auc20,
- "average_precision_top20_error": ap20,
- "auroc_top10_error": auc10,
- "average_precision_top10_error": ap10,
- "bottom20_score_mean_error": float(error.loc[low_mask].mean()),
- "top20_score_mean_error": float(error.loc[high_mask].mean()),
- "top_minus_bottom20_error": float(error.loc[high_mask].mean() - error.loc[low_mask].mean()),
- }
- )
- return pd.DataFrame(rows).sort_values(["auroc_top20_error", "spearman_error"], ascending=[False, False])
- def _selective_error_curve(
- table: pd.DataFrame,
- *,
- score_cols: list[str],
- error_col: str,
- keep_fractions: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9, 1.0),
- ) -> pd.DataFrame:
- full_mean = float(table[error_col].mean())
- high_error_threshold = float(table[error_col].quantile(0.8))
- rows = []
- for score_col in score_cols:
- ordered = table.sort_values(score_col, ascending=True)
- for keep_fraction in keep_fractions:
- n_keep = max(1, int(round(ordered.shape[0] * keep_fraction)))
- kept = ordered.head(n_keep)
- mean_error = float(kept[error_col].mean())
- rows.append(
- {
- "score_name": score_col,
- "keep_fraction": float(keep_fraction),
- "abstain_fraction": float(1.0 - keep_fraction),
- "n_kept": int(n_keep),
- "mean_error": mean_error,
- "median_error": float(kept[error_col].median()),
- "error_reduction_vs_full": float(1.0 - (mean_error / full_mean)) if full_mean > 0 else np.nan,
- "high_error_fraction": float((kept[error_col] >= high_error_threshold).mean()),
- "full_mean_error": full_mean,
- }
- )
- return pd.DataFrame(rows)
- def _scenario_summary(table: pd.DataFrame, *, score_col: str = "risk_score") -> pd.DataFrame:
- grouped = table.groupby("scenario", sort=True)
- return (
- grouped.agg(
- n_spots=("scenario", "size"),
- mean_true_error=("total_variation_error", "mean"),
- median_true_error=("total_variation_error", "median"),
- mean_risk_score=(score_col, "mean"),
- mean_phi_local=("phi_local", "mean"),
- mean_phi_uncertainty=("phi_uncertainty", "mean"),
- mean_phi_reference=("phi_reference", "mean"),
- )
- .reset_index()
- .sort_values("mean_true_error", ascending=False)
- )
- def _write_benchmark_h5ad_inputs(
- run_dir: Path,
- *,
- obs: pd.DataFrame,
- matrix: sparse.spmatrix | np.ndarray,
- genes: list[str],
- train_cells: dict[str, np.ndarray],
- celltypes: list[str],
- spot_table: pd.DataFrame,
- pseudo_counts: np.ndarray,
- label_col: str,
- layer: str,
- ) -> tuple[Path, Path]:
- train_indices = np.sort(np.concatenate([train_cells[celltype] for celltype in celltypes]))
- reference_obs = obs.iloc[train_indices].copy()
- reference_var = pd.DataFrame(index=pd.Index(genes, name=None).astype(str))
- reference_counts = matrix[train_indices, :]
- reference = ad.AnnData(X=reference_counts.copy(), obs=reference_obs, var=reference_var)
- reference.layers[layer] = reference_counts.copy()
- pseudo_obs = spot_table.copy()
- pseudo_var = pd.DataFrame(index=pd.Index(genes, name=None).astype(str))
- pseudo = ad.AnnData(X=pseudo_counts.copy(), obs=pseudo_obs, var=pseudo_var)
- pseudo.layers[layer] = pseudo_counts.copy()
- pseudo.obsm["spatial"] = pseudo_obs[["x_spatial", "y_spatial"]].to_numpy(dtype=float)
- pseudo.obs["sample_id"] = pseudo.obs["sample_id"].astype(str)
- reference_path = results_file(run_dir, "artifacts", "known_composition_train_reference.h5ad")
- pseudo_path = results_file(run_dir, "artifacts", "known_composition_pseudo_visium.h5ad")
- reference.write_h5ad(reference_path)
- pseudo.write_h5ad(pseudo_path)
- return reference_path, pseudo_path
- def _write_report(
- run_dir: Path,
- *,
- summary: pd.DataFrame,
- selective: pd.DataFrame,
- scenario: pd.DataFrame,
- metadata: dict[str, object],
- ) -> None:
- best = summary.iloc[0]
- risk_row = summary.loc[summary["score_name"] == "risk_score"]
- risk_text = "not available"
- if not risk_row.empty:
- row = risk_row.iloc[0]
- risk_text = (
- f"Spearman={row['spearman_error']:.3f}, "
- f"AUROC(top20 error)={row['auroc_top20_error']:.3f}, "
- f"top-bottom20 error gap={row['top_minus_bottom20_error']:.3f}"
- )
- keep80 = selective.loc[(selective["score_name"] == "risk_score") & (selective["keep_fraction"] == 0.8)]
- keep80_text = "not available"
- if not keep80.empty:
- row = keep80.iloc[0]
- keep80_text = (
- f"mean error={row['mean_error']:.3f}, "
- f"error reduction={row['error_reduction_vs_full']:.3f}, "
- f"high-error fraction={row['high_error_fraction']:.3f}"
- )
- lines = [
- "# Revision Known-Composition Benchmark",
- "",
- "## Purpose",
- "",
- "本运行生成 DLPFC pseudo-spots(伪空间点),保留 known cell-type composition(已知细胞类型组成),用于直接评估 ReliST risk score(ReliST 风险分数)与 true deconvolution error(真实反卷积误差)的关系。",
- "",
- "## Main Result Snapshot",
- "",
- f"- pseudo-spots(伪空间点)数量:`{metadata['n_spots']}`",
- f"- retained cell types(保留细胞类型):`{metadata['n_celltypes']}`",
- f"- split mode(拆分方式):`{metadata['split']['split_mode']}`,split column(拆分列):`{metadata['split'].get('split_column', 'none')}`。",
- f"- primary `risk_score(风险分数)`:{risk_text}",
- f"- 低风险 keep 80%(保留 80% 低风险点)后:{keep80_text}",
- f"- 当前最佳 score(分数):`{best['score_name']}`,AUROC(top20 error)={best['auroc_top20_error']:.3f}",
- "",
- "## Caveats",
- "",
- "- 这是 pseudo-spatial known-composition benchmark(伪空间已知组成基准),可直接回答审稿人关于 true error(真实误差)的核心问题,但仍不是自然组织中的 spot-level ground truth(空间点级真实标签)。",
- "- 当前默认不启用 base-model perturbation stability(基础模型扰动稳定性);`phi_stability`(稳定性特征)保留为零列,避免偏离当前 manuscript boundary(手稿边界)。",
- "- `risk_score`(风险分数)在本运行中定义为 `phi_local(局部特征)`、`phi_uncertainty(输出模糊性)` 和 `phi_reference(参考特征)` 的等权标准化组合;高分表示更不可靠。",
- "",
- "## Output Tables",
- "",
- "- `tables/known_composition_spot_table.csv`",
- "- `tables/known_composition_true_abundance.csv`",
- "- `tables/known_composition_predicted_abundance.csv`",
- "- `tables/known_composition_risk_error_table.csv`",
- "- `tables/known_composition_celltype_inclusion.csv`",
- "- `tables/known_composition_score_error_summary.csv`",
- "- `tables/known_composition_selective_error_curve.csv`",
- "- `tables/known_composition_scenario_summary.csv`",
- "- `tables/reference_signatures_means.csv`",
- "- `tables/reference_signature_markers.csv`",
- "- `artifacts/known_composition_train_reference.h5ad`",
- "- `artifacts/known_composition_pseudo_visium.h5ad`",
- ]
- (run_dir / "revision_known_composition_benchmark.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
- def main() -> int:
- args = parse_args()
- rng = np.random.default_rng(args.random_state)
- run_dir = args.output_root / "runs" / args.run_id
- ensure_results_layout(run_dir)
- set_selected_run(args.output_root, args.run_id)
- requested_genes = _read_gene_list(args.source_run_dir)
- source_celltype_order = _read_source_celltype_order(args.source_run_dir)
- obs, matrix, genes, celltypes, inclusion_table = _select_reference_matrix(
- args.reference_h5ad,
- layer=args.layer,
- label_col=args.reference_label_column,
- split_col=args.split_column if args.split_mode == "donor_disjoint" else None,
- requested_genes=requested_genes,
- source_celltype_order=source_celltype_order,
- min_cells_per_type=args.min_cells_per_type,
- )
- if args.split_mode == "donor_disjoint":
- train_cells, simulate_cells, celltypes, split_metadata = _split_reference_cells_donor_disjoint(
- obs,
- label_col=args.reference_label_column,
- split_col=args.split_column,
- celltypes=celltypes,
- train_fraction=args.train_fraction,
- min_cells_per_side=args.min_split_cells_per_type,
- rng=rng,
- )
- else:
- train_cells, simulate_cells = _split_reference_cells(
- obs,
- label_col=args.reference_label_column,
- celltypes=celltypes,
- train_fraction=args.train_fraction,
- rng=rng,
- )
- split_metadata = {
- "split_mode": "cell_random",
- "split_column": None,
- "train_fraction": float(args.train_fraction),
- "n_retained_celltypes_after_split": int(len(celltypes)),
- "dropped_after_split": [],
- }
- inclusion_table = _augment_inclusion_table(
- inclusion_table,
- celltypes_after_split=celltypes,
- train_cells=train_cells,
- simulate_cells=simulate_cells,
- )
- signatures = _compute_signatures(matrix, train_cells=train_cells, genes=genes, celltypes=celltypes)
- markers_all, marker_table = select_signature_markers(
- signatures,
- top_k=args.marker_top_k,
- min_positive_markers=args.min_positive_markers,
- )
- markers = subset_markers(markers_all, mode=args.marker_subset_mode)
- spot_table, true_abundance, region_prototypes, pseudo_counts = _sample_pseudo_spots(
- matrix,
- simulate_cells=simulate_cells,
- celltypes=celltypes,
- genes=genes,
- markers=markers_all,
- n_spots=args.n_spots,
- n_regions=args.n_regions,
- cells_per_spot_min=args.cells_per_spot_min,
- cells_per_spot_max=args.cells_per_spot_max,
- rng=rng,
- )
- reference_h5ad, pseudo_visium_h5ad = _write_benchmark_h5ad_inputs(
- run_dir,
- obs=obs,
- matrix=matrix,
- genes=genes,
- train_cells=train_cells,
- celltypes=celltypes,
- spot_table=spot_table,
- pseudo_counts=pseudo_counts,
- label_col=args.reference_label_column,
- layer=args.layer,
- )
- expression = _normalize_log_cp10k(pseudo_counts)
- expression_df = pd.DataFrame(expression, index=spot_table.index, columns=genes)
- predicted_values = _project_abundance(
- expression,
- signatures,
- method=args.projection_method,
- ridge_lambda=args.ridge_lambda,
- )
- predicted_abundance = pd.DataFrame(predicted_values, index=spot_table.index, columns=celltypes)
- error = _error_table(predicted_abundance, true_abundance)
- coords = spot_table[["x_spatial", "y_spatial"]].to_numpy(dtype=float)
- neighbors = knn_indices(coords, k=8)
- weights = inverse_distance_weights(coords, neighbors)
- heterogeneity = _compute_local_heterogeneity(expression, neighbors)
- stability_predictions = None
- if args.stability_repeats > 0:
- stability_predictions = gene_subsample_stability(
- expression,
- signatures.to_numpy(dtype=np.float32),
- repeats=args.stability_repeats,
- gene_fraction=args.stability_gene_fraction,
- ridge_lambda=args.ridge_lambda,
- random_state=args.random_state,
- )
- ambiguity = pd.Series(ambiguity_score(predicted_abundance), index=predicted_abundance.index, name="phi_uncertainty")
- model_output = BaseSpatialModelOutput(abundance=predicted_abundance, uncertainty=ambiguity)
- features = build_feature_table(
- model_output,
- neighbors=neighbors,
- weights=weights,
- heterogeneity=heterogeneity,
- stability_predictions=stability_predictions,
- confidence_proxy_precomputed=True,
- )
- marker_scores = compute_reference_marker_scores(expression_df, markers)
- features["phi_reference"] = reference_subsampling_instability(
- predicted_abundance,
- expression_df,
- markers,
- repeats=args.reference_repeats,
- subset_fraction=args.reference_fraction,
- random_state=args.random_state,
- )
- reference_marker = reference_marker_discordance_proxy(predicted_abundance, marker_scores)
- reference_residual = reference_signature_residual_proxy(
- predicted_abundance,
- expression_df,
- signatures,
- genes=marker_table["gene"].astype(str).tolist(),
- )
- groups = spot_table["sample_id"].astype(str)
- risk_table = features.copy()
- risk_table["risk_score"] = _combine_any_features(
- risk_table,
- {"phi_local": 1.0, "phi_uncertainty": 1.0, "phi_reference": 1.0},
- groups=groups,
- )
- risk_table["reference_risk_score"] = _combine_any_features(
- risk_table,
- {"phi_uncertainty": 2.0, "phi_reference": 2.0},
- groups=groups,
- )
- risk_table["local_uncertainty_risk_score"] = _combine_any_features(
- risk_table,
- {"phi_local": 1.0, "phi_uncertainty": 1.0},
- groups=groups,
- )
- for column, values in _abundance_baselines(predicted_abundance).items():
- risk_table[column] = values
- risk_table["snrna_marker_discordance"] = reference_marker
- risk_table["snrna_signature_residual"] = reference_residual
- risk_table = pd.concat([spot_table, risk_table, error], axis=1)
- score_cols = [
- "risk_score",
- "reference_risk_score",
- "local_uncertainty_risk_score",
- "abundance_entropy_risk",
- "inverse_top1_margin",
- "inverse_max_abundance",
- "phi_local",
- "phi_uncertainty",
- "phi_reference",
- "snrna_marker_discordance",
- "snrna_signature_residual",
- ]
- if args.stability_repeats > 0:
- score_cols.append("phi_stability")
- score_summary = _score_error_summary(risk_table, score_cols=score_cols, error_col="total_variation_error")
- selective_curve = _selective_error_curve(risk_table, score_cols=score_cols, error_col="total_variation_error")
- scenario_summary = _scenario_summary(risk_table)
- spot_table.to_csv(results_file(run_dir, "tables", "known_composition_spot_table.csv"))
- true_abundance.to_csv(results_file(run_dir, "tables", "known_composition_true_abundance.csv"))
- predicted_abundance.to_csv(results_file(run_dir, "tables", "known_composition_predicted_abundance.csv"))
- risk_table.to_csv(results_file(run_dir, "tables", "known_composition_risk_error_table.csv"))
- inclusion_table.to_csv(results_file(run_dir, "tables", "known_composition_celltype_inclusion.csv"), index=False)
- score_summary.to_csv(results_file(run_dir, "tables", "known_composition_score_error_summary.csv"), index=False)
- selective_curve.to_csv(results_file(run_dir, "tables", "known_composition_selective_error_curve.csv"), index=False)
- scenario_summary.to_csv(results_file(run_dir, "tables", "known_composition_scenario_summary.csv"), index=False)
- region_prototypes.to_csv(results_file(run_dir, "tables", "known_composition_region_prototypes.csv"))
- signatures.to_csv(results_file(run_dir, "tables", "reference_signatures_means.csv"))
- marker_table.to_csv(results_file(run_dir, "tables", "reference_signature_markers.csv"), index=False)
- expression_df.to_csv(results_file(run_dir, "tables", "known_composition_expression_log_cp10k.csv"))
- metadata = {
- "run_id": args.run_id,
- "reference_h5ad": str(args.reference_h5ad),
- "reference_label_column": args.reference_label_column,
- "source_run_dir": str(args.source_run_dir),
- "n_spots": int(args.n_spots),
- "n_genes": int(len(genes)),
- "n_celltypes": int(len(celltypes)),
- "n_original_labels": int(inclusion_table.shape[0]),
- "n_retained_before_split": int(inclusion_table["retained_before_split"].sum()),
- "n_retained_after_split": int(inclusion_table["retained_after_split"].sum()),
- "celltypes": celltypes,
- "random_state": int(args.random_state),
- "train_fraction": float(args.train_fraction),
- "split": split_metadata,
- "train_reference_h5ad": str(reference_h5ad),
- "pseudo_visium_h5ad": str(pseudo_visium_h5ad),
- "cells_per_spot_min": int(args.cells_per_spot_min),
- "cells_per_spot_max": int(args.cells_per_spot_max),
- "pseudo_spot_scenarios": {
- "clean": "No extra degradation beyond pseudo-bulk sampling.",
- "low_depth": "Binomial thinning of counts with keep_probability sampled uniformly from 0.15 to 0.45.",
- "marker_dropout": "Dominant-type marker counts thinned to 25% keep probability.",
- "diffuse_mixture": "Composition smoothed toward a uniform mixture before cell sampling.",
- },
- "spatial_coordinate_generation": (
- "Pseudo-spots are placed on a regular grid. Smooth region prototypes vary along the y coordinate; "
- "therefore local-structure features should be interpreted with the shuffled-coordinate/null controls "
- "added in downstream revision scripts."
- ),
- "risk_score_definition": "equal-weight grouped-zscore combination of phi_local, phi_uncertainty, and phi_reference",
- "projection_method": args.projection_method,
- "stability_repeats": int(args.stability_repeats),
- "reference_feature_mode": "reference_subsampling_instability",
- "reference_marker_subset_mode": args.marker_subset_mode,
- "primary_error_col": "total_variation_error",
- "score_columns": score_cols,
- "manuscript_boundary": (
- "This benchmark is a known-composition validation of risk-error association. "
- "It does not make natural tissue spot-level truth claims."
- ),
- }
- results_file(run_dir, "metadata", "known_composition_benchmark.json").write_text(
- json.dumps(metadata, indent=2, ensure_ascii=False),
- encoding="utf-8",
- )
- _write_report(run_dir, summary=score_summary, selective=selective_curve, scenario=scenario_summary, metadata=metadata)
- print(f"Wrote known-composition benchmark to {run_dir}")
- print(json.dumps({"run_id": args.run_id, "n_spots": args.n_spots, "n_celltypes": len(celltypes)}, indent=2))
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
run_revision_known_composition_benchmark.py at commit ae4af50, under MIT · at the source
Overview
- Institute of Gastroenterology, Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
- Department of Oncology, Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
- Science and Technology Innovation Center, Guangzhou University of Chinese Medicine, Guangzhou, Guangdong, China
- Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
Abstract
The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.
Repository
Its files are read in the Code ↔ Paper reader above, with 24 matches between paragraphs and lines of code.
tp5353/ReliST
ae4af503c975f3bfad45ab89658ad92fd25c3465, 20 June 2026Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
60 files
- scripts/
build_revision_figures.p , Python, 809 lines, 3 matchesy - scripts/
export_reference_signatu , Python, 66 linesres.py - scripts/
run_base_model.py , Python, 47 lines - scripts/
run_rctd_native.R , R, 95 lines - scripts/
run_revision_component_a , Python, 271 lines, 2 matchesblation.py - scripts/
run_revision_known_compo , Python, 1,037 lines, 5 matchessition_benchmark.py - scripts/
run_revision_known_compo , Python, 820 lines, 2 matchessition_multimodel_eval.p y - scripts/
run_revision_reference_p , Python, 490 lines, 2 matcheserturbation.py - scripts/
run_revision_reference_p , Python, 209 lines, 1 matcherturbation_component_ab lation.py - scripts/
run_revision_threshold_s , Python, 291 lines, 2 matchesensitivity.py - scripts/
run_revision_uncertainty , Python, 308 lines, 1 match_baseline_eval.py - scripts/
run_risk_scoring.py , Python, 515 lines, 1 match - scripts/
run_stereoscope_native.p , Python, 118 linesy - scripts/
summarize_revision_known , Python, 228 lines_composition_seed_repeat s.py - scripts/
validate_ready_data.py , Python, 36 lines - src/
st_risk/ , Python, 5 lines__init__.py - src/
st_risk/ , Python, 15 linesconfig.py - src/
st_risk/ , Python, 31 linesdata/ harmonize.py - src/
st_risk/ , Python, 19 linesdata/ io.py - src/
st_risk/ , Python, 87 linesdata/ validate.py - src/
st_risk/ , Python, 275 lineseval/ dual_axis.py - src/
st_risk/ , Python, 1,075 lineseval/ layer_eval.py - src/
st_risk/ , Python, 284 lines, 1 matcheval/ reference_eval.py - src/
st_risk/ , Python, 23 linesmodels/ base.py - src/
st_risk/ , Python, 243 linesmodels/ cell2location_model.py - src/
st_risk/ , Python, 449 linesmodels/ destvi_model.py - src/
st_risk/ , Python, 92 linesmodels/ io.py - src/
st_risk/ , Python, 85 linesmodels/ precomputed_model.py - src/
st_risk/ , Python, 278 linesmodels/ rctd_model.py - src/
st_risk/ , Python, 28 linesmodels/ registry.py - src/
st_risk/ , Python, 296 linesmodels/ stereoscope_model.py - src/
st_risk/ , Python, 253 linesmodels/ tangram_model.py - src/
st_risk/ , Python, 94 linespaths.py - src/
st_risk/ , Python, 81 linesreporting/ __init__.py - src/
st_risk/ , Python, 615 lines, 3 matchesreporting/ anchored_validation.py - src/
st_risk/ , Python, 416 lines, 1 matchreporting/ decision_support.py - src/
st_risk/ , Python, 340 linesreporting/ gallery.py - src/
st_risk/ , Python, 142 linesrisk/ features.py - src/
st_risk/ , Python, 42 linesrisk/ neighbors.py - src/
st_risk/ , Python, 102 linesrisk/ score.py - src/
st_risk/ , Python, 159 linesrisk/ stability.py - tests/
conftest.py , Python, 9 lines - tests/
data/ , Python, 15 linestest_harmonize.py - tests/
data/ , Python, 65 linestest_validate_ready_data .py - tests/
eval/ , Python, 154 linestest_dual_axis.py - tests/
eval/ , Python, 552 linestest_layer_eval.py - tests/
eval/ , Python, 132 linestest_reference_eval.py - tests/
models/ , Python, 222 linestest_base_model_interfac e.py - tests/
reporting/ , Python, 248 linestest_anchored_validation .py - tests/
reporting/ , Python, 367 linestest_decision_support.py - tests/
reporting/ , Python, 362 linestest_gallery.py - tests/
risk/ , Python, 83 linestest_features.py - tests/
risk/ , Python, 16 linestest_neighbors.py - tests/
risk/ , Python, 59 linestest_score.py - tests/
risk/ , Python, 37 linestest_stability.py - tests/
test_config.py , Python, 14 lines - tests/
test_pipeline_smoke.py , Python, 5 lines - tests/
test_results_layout.py , Python, 54 lines - LICENSE, License, 21 lines
- README.md, Text, 95 lines
The paper's code and data availability statement is in the Data section.
Tracing map
Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.
What the map holds:
- 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
- 58 scripts, each with its path and the digest of its content;
- 24 matches between paragraphs of the paper and lines of the code (method lexical-v1);
- neither the text of the paper nor the code itself.
Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.
Data
No dataset and no data link were found in the paper.
Code and data availability statement
The paper has a code and data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:
- it points to the authors' code: tp5353/
ReliST - it says that the data are available on request
- it says that the code is available on request
Read it in the paper: doi.org/10.1016/j.isci.2026.117206.
Versions
The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.
Version 2, 28 September 2026
- Authors: added Xinyu Zhang (0000-0001-7077-3314); removed Xinyu Zhang
- Funding: added Sanming Project of Medicine in Shenzhen: SZZYSM202211003
Version 1, 27 September 2026: the first record
Recorded: type, language, journal, volume, issue, pages, dates, 8 authors, 9 keywords, 47 references.
Cite
This paper
Zhang, X., He, L., Peng, Y., Li, Y., Kang, J., Peng, L., Xu, Y., & Lin, S. (2026). ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution. iScience, 29(9), 117206. https://
BibTeX
@article{zhang2026relist
author = {Zhang, Xinyu and He, Li and Peng, Yu and Li, Yijia and Kang, Jianyuan and Peng, Lisheng and Xu, Yifei and Lin, Sen},
title = {{ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution}},
journal = {iScience},
year = {2026},
month = aug,
volume = {29},
number = {9},
pages = {117206},
publisher = {Elsevier},
issn = {2589-0042},
doi = {10.1016/
url = {https://
pmid = {42643167},
pmcid = {PMC13503129}
}
RIS
TY - JOUR
AU - Zhang, Xinyu
AU - He, Li
AU - Peng, Yu
AU - Li, Yijia
AU - Kang, Jianyuan
AU - Peng, Lisheng
AU - Xu, Yifei
AU - Lin, Sen
TI - ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution
T2 - iScience
J2 - iScience
PY - 2026
DA - 2026/
VL - 29
IS - 9
SP - 117206
SN - 2589-0042
PB - Elsevier
DO - 10.1016/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1016/
"type": "article-journal",
"title": "ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution",
"container-title": "iScience",
"author": [
{
"family": "Zhang",
"given": "Xinyu"
},
{
"family": "He",
"given": "Li"
},
{
"family": "Peng",
"given": "Yu"
},
{
"family": "Li",
"given": "Yijia"
},
{
"family": "Kang",
"given": "Jianyuan"
},
{
"family": "Peng",
"given": "Lisheng"
},
{
"family": "Xu",
"given": "Yifei"
},
{
"family": "Lin",
"given": "Sen"
}
],
"container-title-short":
"volume": "29",
"issue": "9",
"page": "117206",
"DOI": "10.1016/
"PMID": "42643167",
"PMCID": "PMC13503129",
"ISSN": "2589-0042",
"publisher": "Elsevier",
"URL": "https://
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
14
]
]
}
}
The tracing map gets a citation of its own once an author has validated it and it has a DOI.
Similar papers
The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.
- [1] doi:10.21203/rs.3.rs-9676637/v1 [code]
- A Comprehensive Benchmarking of Spatial Deconvolution and Domain Detection Methods across Diverse Tissues and Spatial Transcriptomic TechnologiesJournal: Research Square (preprint)In common: anndata, Scanpy, Pillow, 7 other tools, genetics / omics, 14 references
- [2] doi:10.1093/bioinformatics/btag578 [code]
- NicheDeSig: niche-aware deconvolution and adaptive signature analysis for spatial transcriptomics.Journal: Bioinformatics (Oxford, England)In common: anndata, Scanpy, PyTorch, 4 other tools, genetics / omics, 12 references
- [3] doi:10.1002/advs.77003 [code]
- SemanticST: A Scalable Multi-Contextual Graph Learning Framework for Uncovering Spatial Niches and Robust Multi-Sample Integration in Spatial Transcriptomics.Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)In common: anndata, Scanpy, PyTorch, 6 other tools, genetics / omics, 8 references
- [4] doi:10.1093/bib/bbag404 [code]
- Navigating cell maps by deep learning integration of single-cell and spatially resolved transcriptomics.Journal: Briefings in bioinformaticsIn common: anndata, Scanpy, PyTorch, 5 other tools, genetics / omics, mouse, 8 references
- [5] doi:10.1093/bioinformatics/btag515 [code]
- PRISM: Prior-enhanced Inference for Spatial Transcriptomic Cell Type Mapping.Journal: Bioinformatics (Oxford, England)In common: anndata, Scanpy, PyTorch, 5 other tools, genetics / omics, 8 references
- [6] doi:10.1093/bib/bbag298 [code]
- Empowering multifaceted analysis of spatial transcriptomics data with RGAST.Journal: Briefings in bioinformaticsIn common: anndata, Scanpy, PyTorch, 6 other tools, genetics / omics, mouse, 7 references
- [7] doi:10.1093/bioinformatics/btag424 [code]
- SlotDeconv: spatial transcriptomics deconvolution via diversity-constrained prototype learning and spatial refinement.Journal: Bioinformatics (Oxford, England)In common: anndata, Scanpy, PyTorch, 6 other tools, genetics / omics, mouse, 6 references
- [8] doi:10.1038/s41592-026-03211-w [code]
- Spatial isoform sequencing at single-cell resolution reveals cell-type-specific spatial isoform variability in multiple brain cell types.Journal: Nature methodsIn common: Scanpy, Pillow, seaborn, 5 other tools, genetics / omics, mouse, 7 references
- [9] doi:10.1038/s41592-026-03194-8 [code]
- Beyond benchmarking: an expert-guided consensus approach to spatially aware clustering.Journal: Nature methodsIn common: anndata, Scanpy, Pillow, 7 other tools, genetics / omics, 5 references
- [10] doi:10.1038/s41593-026-02293-1 [code]
- Optics-free spatial genomics for mapping mammalian brain aging by IRISeq.Journal: Nature neuroscienceIn common: Scanpy, seaborn, scikit-learn, 4 other tools, genetics / omics, mouse, 8 references
Contribute
The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.
Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 58 scripts, and 24 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:5a0068ee875cde3a…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
Request its removal
To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).
Discussion, reproductions, activity
Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.
Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
