OSCR

ReliST: A model-agnostic risk layer for spatial transcriptomics deconvolution.

Code ↔ Paper

24 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 24 matches
  1. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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. [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

  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. from pathlib import Path
  5. import anndata as ad
  6. import numpy as np
  7. import pandas as pd
  8. from scipy import sparse
  9. from scipy.optimize import nnls
  10. from scipy.stats import pearsonr, spearmanr
  11. from sklearn.decomposition import TruncatedSVD
  12. from sklearn.metrics import average_precision_score, roc_auc_score
  13. from st_risk.eval.reference_eval import (
  14. compute_reference_marker_scores,
  15. reference_marker_discordance_proxy,
  16. reference_signature_residual_proxy,
  17. reference_subsampling_instability,
  18. select_signature_markers,
  19. subset_markers,
  20. )
  21. from st_risk.models.base import BaseSpatialModelOutput
  22. from st_risk.paths import ensure_results_layout, project_root, results_file, set_selected_run
  23. from st_risk.risk.features import ambiguity_score, build_feature_table
  24. from st_risk.risk.neighbors import inverse_distance_weights, knn_indices
  25. from st_risk.risk.score import grouped_zscore, sigmoid
  26. from st_risk.risk.stability import gene_subsample_stability, ridge_project_celltype_proportions, row_normalize
  27. DEFAULT_RUN_ID = "2026-06-20-dlpfc-known-composition-v2-donor-disjoint"
  28. def parse_args() -> argparse.Namespace:
  29. default_source_root = project_root() / "results" / "dlpfc_rctd"
  30. selected_run = (default_source_root / "selected_run.txt").read_text(encoding="utf-8").strip()
  31. default_source_run = default_source_root / "runs" / selected_run
  32. parser = argparse.ArgumentParser(
  33. description="Build a DLPFC pseudo-spot known-composition benchmark for revision analyses."
  34. )
  35. parser.add_argument(
  36. "--reference-h5ad",
  37. type=Path,
  38. default=project_root() / "data" / "Human DLPFC" / "ready" / "dlpfc_snrna_ref.h5ad",
  39. help="snRNA reference h5ad used to draw pseudo-spots.",
  40. )
  41. parser.add_argument(
  42. "--reference-label-column",
  43. default="cellType_k",
  44. help="Reference cell-type column used as the known composition label.",
  45. )
  46. parser.add_argument(
  47. "--split-column",
  48. default="BrNum",
  49. help="Reference obs column used for donor-disjoint train/simulation split.",
  50. )
  51. parser.add_argument(
  52. "--split-mode",
  53. default="donor_disjoint",
  54. choices=("donor_disjoint", "cell_random"),
  55. help="How to split reference cells into signature-building and held-out simulation pools.",
  56. )
  57. parser.add_argument(
  58. "--min-split-cells-per-type",
  59. type=int,
  60. default=20,
  61. help="Minimum cells per retained type on each side of a donor-disjoint split.",
  62. )
  63. parser.add_argument(
  64. "--source-run-dir",
  65. type=Path,
  66. default=default_source_run,
  67. help="Existing DLPFC run providing the selected gene list and optional cell-type order.",
  68. )
  69. parser.add_argument(
  70. "--output-root",
  71. type=Path,
  72. default=project_root() / "results" / "revision_known_composition_benchmark",
  73. help="Result root for this revision benchmark.",
  74. )
  75. parser.add_argument("--run-id", default=DEFAULT_RUN_ID, help="Run id under output-root/runs/.")
  76. parser.add_argument("--n-spots", type=int, default=1200, help="Number of pseudo-spots to generate.")
  77. parser.add_argument("--n-regions", type=int, default=6, help="Number of smooth pseudo-spatial regions.")
  78. parser.add_argument("--cells-per-spot-min", type=int, default=4, help="Minimum cells per pseudo-spot.")
  79. parser.add_argument("--cells-per-spot-max", type=int, default=10, help="Maximum cells per pseudo-spot.")
  80. parser.add_argument("--min-cells-per-type", type=int, default=80, help="Minimum reference cells per retained type.")
  81. parser.add_argument("--train-fraction", type=float, default=0.5, help="Fraction of cells used to build signatures.")
  82. parser.add_argument("--layer", default="counts", help="Reference h5ad layer containing raw counts.")
  83. parser.add_argument("--marker-top-k", type=int, default=25, help="Top signature markers per cell type.")
  84. parser.add_argument("--min-positive-markers", type=int, default=10, help="Minimum positive markers per cell type.")
  85. parser.add_argument(
  86. "--marker-subset-mode",
  87. default="odd",
  88. choices=("all", "odd", "even", "top_half", "bottom_half"),
  89. help="Marker subset used for reference_subsampling_instability.",
  90. )
  91. parser.add_argument("--reference-repeats", type=int, default=8, help="Marker subsampling repeats.")
  92. parser.add_argument("--reference-fraction", type=float, default=0.5, help="Marker subsampling fraction.")
  93. parser.add_argument(
  94. "--projection-method",
  95. default="nnls",
  96. choices=("nnls", "ridge"),
  97. help="Nonnegative projection method used as the lightweight deconvolution baseline.",
  98. )
  99. parser.add_argument(
  100. "--stability-repeats",
  101. type=int,
  102. default=0,
  103. help="Optional gene-subsampling stability repeats. Default 0 keeps this revision benchmark reference-centered.",
  104. )
  105. parser.add_argument("--stability-gene-fraction", type=float, default=0.8, help="Gene fraction if stability is enabled.")
  106. parser.add_argument("--ridge-lambda", type=float, default=1e-3, help="Ridge penalty for signature projection.")
  107. parser.add_argument("--random-state", type=int, default=20260620, help="Random seed.")
  108. return parser.parse_args()
  109. def _read_gene_list(source_run_dir: Path) -> list[str]:
  110. used_genes_path = source_run_dir / "tables" / "base_model_used_genes.csv"
  111. if not used_genes_path.exists():
  112. used_genes_path = source_run_dir / "tables" / "cell2location_used_genes.csv"
  113. if used_genes_path.exists():
  114. return pd.read_csv(used_genes_path)["gene"].astype(str).tolist()
  115. signatures_path = source_run_dir / "tables" / "reference_signatures_means.csv"
  116. if not signatures_path.exists():
  117. raise FileNotFoundError(
  118. f"Could not find base_model_used_genes.csv or reference_signatures_means.csv under {source_run_dir}"
  119. )
  120. return pd.read_csv(signatures_path, index_col=0).index.astype(str).tolist()
  121. def _read_source_celltype_order(source_run_dir: Path) -> list[str] | None:
  122. signatures_path = source_run_dir / "tables" / "reference_signatures_means.csv"
  123. if not signatures_path.exists():
  124. return None
  125. signatures = pd.read_csv(signatures_path, index_col=0, nrows=1)
  126. return signatures.columns.astype(str).tolist()
  127. def _normalize_log_cp10k(matrix: np.ndarray | sparse.spmatrix) -> np.ndarray:
  128. if sparse.issparse(matrix):
  129. matrix = matrix.toarray()
  130. values = np.asarray(matrix, dtype=np.float32)
  131. library = values.sum(axis=1, keepdims=True)
  132. safe_library = np.where(np.isclose(library, 0.0), 1.0, library)
  133. return np.log1p((values / safe_library) * 1e4).astype(np.float32)
  134. def _as_dense_vector(matrix: np.ndarray | sparse.spmatrix) -> np.ndarray:
  135. if sparse.issparse(matrix):
  136. matrix = matrix.toarray()
  137. return np.asarray(matrix, dtype=np.float32).reshape(-1)
  138. def _select_reference_matrix(
  139. reference_h5ad: Path,
  140. *,
  141. layer: str,
  142. label_col: str,
  143. split_col: str | None,
  144. requested_genes: list[str],
  145. source_celltype_order: list[str] | None,
  146. min_cells_per_type: int,
  147. ) -> tuple[pd.DataFrame, sparse.spmatrix | np.ndarray, list[str], list[str], pd.DataFrame]:
  148. adata = ad.read_h5ad(reference_h5ad, backed="r")
  149. if label_col not in adata.obs.columns:
  150. raise KeyError(f"{label_col} is not present in {reference_h5ad}")
  151. if split_col and split_col not in adata.obs.columns:
  152. raise KeyError(f"{split_col} is not present in {reference_h5ad}")
  153. if layer not in adata.layers:
  154. raise KeyError(f"{layer} is not present in layers of {reference_h5ad}")
  155. var_lookup = {str(gene).lower(): str(gene) for gene in adata.var_names.astype(str)}
  156. genes = [var_lookup[str(gene).lower()] for gene in requested_genes if str(gene).lower() in var_lookup]
  157. if len(genes) < 50:
  158. raise ValueError(f"Only {len(genes)} requested genes were found in {reference_h5ad}; need at least 50.")
  159. labels_all = adata.obs[label_col].astype(str)
  160. counts_by_type = labels_all.value_counts()
  161. source_order = pd.Index(source_celltype_order or [], dtype=str)
  162. if source_celltype_order is None:
  163. celltypes = counts_by_type.loc[counts_by_type >= min_cells_per_type].index.astype(str).tolist()
  164. else:
  165. celltypes = [
  166. celltype
  167. for celltype in source_celltype_order
  168. if celltype in counts_by_type.index and int(counts_by_type[celltype]) >= min_cells_per_type
  169. ]
  170. if len(celltypes) < 3:
  171. raise ValueError("At least three retained cell types are required for this benchmark.")
  172. inclusion_table = (
  173. counts_by_type.rename_axis("celltype")
  174. .reset_index(name="n_reference_cells")
  175. .assign(
  176. in_source_celltype_order=lambda frame: frame["celltype"].isin(source_order).astype(bool),
  177. passes_min_cells=lambda frame: frame["n_reference_cells"].ge(min_cells_per_type),
  178. retained_before_split=lambda frame: frame["celltype"].isin(celltypes).astype(bool),
  179. )
  180. .sort_values(["retained_before_split", "n_reference_cells"], ascending=[False, False])
  181. .reset_index(drop=True)
  182. )
  183. keep_mask = labels_all.isin(celltypes).to_numpy()
  184. obs_columns = [label_col]
  185. if split_col:
  186. obs_columns.append(split_col)
  187. obs = adata.obs.loc[keep_mask, obs_columns].copy()
  188. obs[label_col] = obs[label_col].astype(str)
  189. if split_col:
  190. obs[split_col] = obs[split_col].astype(str)
  191. matrix = adata[keep_mask, genes].layers[layer]
  192. if sparse.issparse(matrix):
  193. matrix = matrix.tocsr()
  194. else:
  195. matrix = np.asarray(matrix, dtype=np.float32)
  196. if hasattr(adata, "file") and adata.file is not None:
  197. adata.file.close()
  198. return obs, matrix, genes, celltypes, inclusion_table
  199. def _split_reference_cells(
  200. obs: pd.DataFrame,
  201. *,
  202. label_col: str,
  203. celltypes: list[str],
  204. train_fraction: float,
  205. rng: np.random.Generator,
  206. ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
  207. train: dict[str, np.ndarray] = {}
  208. simulate: dict[str, np.ndarray] = {}
  209. fraction = float(np.clip(train_fraction, 0.2, 0.8))
  210. labels = obs[label_col].astype(str).to_numpy()
  211. for celltype in celltypes:
  212. positions = np.flatnonzero(labels == celltype)
  213. shuffled = rng.permutation(positions)
  214. n_train = int(round(len(shuffled) * fraction))
  215. n_train = min(max(n_train, 20), len(shuffled) - 1)
  216. train[celltype] = np.sort(shuffled[:n_train])
  217. simulate[celltype] = np.sort(shuffled[n_train:])
  218. if simulate[celltype].size == 0:
  219. simulate[celltype] = train[celltype]
  220. return train, simulate
  221. def _split_reference_cells_donor_disjoint(
  222. obs: pd.DataFrame,
  223. *,
  224. label_col: str,
  225. split_col: str,
  226. celltypes: list[str],
  227. train_fraction: float,
  228. min_cells_per_side: int,
  229. rng: np.random.Generator,
  230. ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], list[str], dict[str, object]]:
  231. labels = obs[label_col].astype(str).to_numpy()
  232. units = obs[split_col].astype(str).to_numpy()
  233. unique_units = np.asarray(pd.Index(units).unique().astype(str), dtype=object)
  234. if unique_units.size < 2:
  235. raise ValueError(f"Need at least two unique {split_col} values for donor-disjoint split.")
  236. fraction = float(np.clip(train_fraction, 0.2, 0.8))
  237. n_train_units = int(round(unique_units.size * fraction))
  238. n_train_units = min(max(n_train_units, 1), unique_units.size - 1)
  239. best: tuple[list[str], set[str], set[str]] | None = None
  240. best_score = (-1, -1)
  241. for _ in range(500):
  242. shuffled = rng.permutation(unique_units)
  243. train_units = set(map(str, shuffled[:n_train_units]))
  244. simulate_units = set(map(str, shuffled[n_train_units:]))
  245. train_mask = np.asarray([unit in train_units for unit in units], dtype=bool)
  246. simulate_mask = np.asarray([unit in simulate_units for unit in units], dtype=bool)
  247. retained: list[str] = []
  248. total_cells = 0
  249. for celltype in celltypes:
  250. label_mask = labels == celltype
  251. n_train = int((label_mask & train_mask).sum())
  252. n_sim = int((label_mask & simulate_mask).sum())
  253. if n_train >= min_cells_per_side and n_sim >= min_cells_per_side:
  254. retained.append(celltype)
  255. total_cells += n_train + n_sim
  256. score = (len(retained), total_cells)
  257. if score > best_score:
  258. best = (retained, train_units, simulate_units)
  259. best_score = score
  260. if len(retained) == len(celltypes):
  261. break
  262. if best is None:
  263. raise RuntimeError("Could not construct a donor-disjoint split.")
  264. retained_celltypes, train_units, simulate_units = best
  265. if len(retained_celltypes) < 3:
  266. raise ValueError(
  267. "Fewer than three cell types passed the donor-disjoint split filters; "
  268. "try lowering --min-split-cells-per-type or using --split-mode cell_random."
  269. )
  270. train: dict[str, np.ndarray] = {}
  271. simulate: dict[str, np.ndarray] = {}
  272. train_mask = np.asarray([unit in train_units for unit in units], dtype=bool)
  273. simulate_mask = np.asarray([unit in simulate_units for unit in units], dtype=bool)
  274. for celltype in retained_celltypes:
  275. label_mask = labels == celltype
  276. train[celltype] = np.flatnonzero(label_mask & train_mask)
  277. simulate[celltype] = np.flatnonzero(label_mask & simulate_mask)
  278. metadata = {
  279. "split_mode": "donor_disjoint",
  280. "split_column": split_col,
  281. "train_units": sorted(train_units),
  282. "simulate_units": sorted(simulate_units),
  283. "n_train_units": int(len(train_units)),
  284. "n_simulate_units": int(len(simulate_units)),
  285. "min_split_cells_per_type": int(min_cells_per_side),
  286. "n_retained_celltypes_after_split": int(len(retained_celltypes)),
  287. "dropped_after_split": [celltype for celltype in celltypes if celltype not in retained_celltypes],
  288. }
  289. return train, simulate, retained_celltypes, metadata
  290. def _augment_inclusion_table(
  291. inclusion_table: pd.DataFrame,
  292. *,
  293. celltypes_after_split: list[str],
  294. train_cells: dict[str, np.ndarray],
  295. simulate_cells: dict[str, np.ndarray],
  296. ) -> pd.DataFrame:
  297. table = inclusion_table.copy()
  298. train_counts = {celltype: int(len(indices)) for celltype, indices in train_cells.items()}
  299. simulate_counts = {celltype: int(len(indices)) for celltype, indices in simulate_cells.items()}
  300. table["n_train_signature_cells"] = table["celltype"].map(train_counts).fillna(0).astype(int)
  301. table["n_heldout_simulation_cells"] = table["celltype"].map(simulate_counts).fillna(0).astype(int)
  302. table["retained_after_split"] = table["celltype"].isin(celltypes_after_split).astype(bool)
  303. table["exclusion_reason"] = "retained"
  304. table.loc[~table["passes_min_cells"], "exclusion_reason"] = "below_min_cells_per_type"
  305. table.loc[
  306. table["retained_before_split"] & ~table["retained_after_split"],
  307. "exclusion_reason",
  308. ] = "insufficient_train_or_heldout_cells_after_split"
  309. table.loc[~table["retained_before_split"] & table["passes_min_cells"], "exclusion_reason"] = "not_in_source_celltype_order"
  310. return table
  311. def _compute_signatures(
  312. matrix: sparse.spmatrix | np.ndarray,
  313. *,
  314. train_cells: dict[str, np.ndarray],
  315. genes: list[str],
  316. celltypes: list[str],
  317. ) -> pd.DataFrame:
  318. columns = {}
  319. for celltype in celltypes:
  320. normalized = _normalize_log_cp10k(matrix[train_cells[celltype], :])
  321. columns[celltype] = normalized.mean(axis=0)
  322. signatures = pd.DataFrame(columns, index=genes, dtype=float)
  323. return signatures
  324. def _build_region_prototypes(celltypes: list[str], *, n_regions: int, rng: np.random.Generator) -> pd.DataFrame:
  325. n_types = len(celltypes)
  326. rows = []
  327. for region_id in range(n_regions):
  328. alpha = np.full(n_types, 0.08, dtype=float)
  329. dominant_count = min(4, n_types)
  330. dominant = rng.choice(n_types, size=dominant_count, replace=False)
  331. alpha[dominant] = 3.0
  332. prototype = rng.dirichlet(alpha)
  333. rows.append(prototype)
  334. return pd.DataFrame(rows, columns=celltypes, index=[f"region_{i + 1}" for i in range(n_regions)])
  335. def _smooth_region_composition(
  336. *,
  337. y: int,
  338. height: int,
  339. prototypes: pd.DataFrame,
  340. rng: np.random.Generator,
  341. concentration: float,
  342. ) -> tuple[str, np.ndarray]:
  343. n_regions = prototypes.shape[0]
  344. scaled = ((y + 0.5) / max(height, 1)) * n_regions
  345. lower = int(np.floor(scaled))
  346. lower = min(max(lower, 0), n_regions - 1)
  347. upper = min(lower + 1, n_regions - 1)
  348. mix = scaled - lower
  349. base = (1.0 - mix) * prototypes.iloc[lower].to_numpy(dtype=float) + mix * prototypes.iloc[upper].to_numpy(dtype=float)
  350. alpha = np.clip(base * concentration, 0.02, None)
  351. composition = rng.dirichlet(alpha)
  352. return str(prototypes.index[lower]), composition
  353. def _sample_pseudo_spots(
  354. matrix: sparse.spmatrix | np.ndarray,
  355. *,
  356. simulate_cells: dict[str, np.ndarray],
  357. celltypes: list[str],
  358. genes: list[str],
  359. markers: dict[str, list[str]],
  360. n_spots: int,
  361. n_regions: int,
  362. cells_per_spot_min: int,
  363. cells_per_spot_max: int,
  364. rng: np.random.Generator,
  365. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, np.ndarray]:
  366. width = int(np.ceil(np.sqrt(n_spots)))
  367. height = int(np.ceil(n_spots / width))
  368. prototypes = _build_region_prototypes(celltypes, n_regions=n_regions, rng=rng)
  369. gene_to_idx = {gene: idx for idx, gene in enumerate(genes)}
  370. scenario_names = np.asarray(["clean", "low_depth", "marker_dropout", "diffuse_mixture"], dtype=object)
  371. scenario_probs = np.asarray([0.55, 0.15, 0.15, 0.15], dtype=float)
  372. spot_rows: list[dict[str, object]] = []
  373. true_rows: list[np.ndarray] = []
  374. pseudo_counts = np.zeros((n_spots, len(genes)), dtype=np.float32)
  375. for spot_id in range(n_spots):
  376. x = spot_id % width
  377. y = spot_id // width
  378. scenario = str(rng.choice(scenario_names, p=scenario_probs))
  379. concentration = 12.0 if scenario == "diffuse_mixture" else 80.0
  380. region_name, composition = _smooth_region_composition(
  381. y=y,
  382. height=height,
  383. prototypes=prototypes,
  384. rng=rng,
  385. concentration=concentration,
  386. )
  387. if scenario == "diffuse_mixture":
  388. composition = row_normalize((0.65 * composition + 0.35 / len(celltypes))[None, :])[0]
  389. n_cells = int(rng.integers(cells_per_spot_min, cells_per_spot_max + 1))
  390. type_counts = rng.multinomial(n_cells, composition)
  391. if type_counts.sum() == 0:
  392. type_counts[int(np.argmax(composition))] = n_cells
  393. true_fraction = type_counts / max(type_counts.sum(), 1)
  394. selected_cell_positions: list[int] = []
  395. for celltype, count in zip(celltypes, type_counts, strict=True):
  396. if count <= 0:
  397. continue
  398. pool = simulate_cells[celltype]
  399. selected = rng.choice(pool, size=int(count), replace=True)
  400. selected_cell_positions.extend(int(value) for value in selected.tolist())
  401. if selected_cell_positions:
  402. spot_counts = _as_dense_vector(matrix[selected_cell_positions, :].sum(axis=0))
  403. else:
  404. spot_counts = np.zeros(len(genes), dtype=np.float32)
  405. if scenario == "low_depth":
  406. keep_probability = float(rng.uniform(0.15, 0.45))
  407. spot_counts = rng.binomial(np.maximum(spot_counts, 0).astype(np.int64), keep_probability).astype(np.float32)
  408. elif scenario == "marker_dropout":
  409. dominant_type = celltypes[int(np.argmax(true_fraction))]
  410. marker_genes = [gene for gene in markers.get(dominant_type, []) if gene in gene_to_idx]
  411. if marker_genes:
  412. marker_idx = np.asarray([gene_to_idx[gene] for gene in marker_genes], dtype=int)
  413. spot_counts[marker_idx] = rng.binomial(
  414. np.maximum(spot_counts[marker_idx], 0).astype(np.int64),
  415. 0.25,
  416. ).astype(np.float32)
  417. pseudo_counts[spot_id, :] = spot_counts
  418. spot_name = f"pseudo_spot_{spot_id + 1:05d}"
  419. spot_rows.append(
  420. {
  421. "spot_id": spot_name,
  422. "sample_id": "dlpfc_known_composition",
  423. "x_spatial": float(x),
  424. "y_spatial": float(y),
  425. "pseudo_region": region_name,
  426. "scenario": scenario,
  427. "n_cells": int(n_cells),
  428. "library_size": float(spot_counts.sum()),
  429. }
  430. )
  431. true_rows.append(true_fraction)
  432. spot_table = pd.DataFrame(spot_rows).set_index("spot_id")
  433. true_abundance = pd.DataFrame(true_rows, index=spot_table.index, columns=celltypes)
  434. return spot_table, true_abundance, prototypes, pseudo_counts
  435. def _compute_local_heterogeneity(expression: np.ndarray, neighbors: np.ndarray, *, n_components: int = 12) -> np.ndarray:
  436. n_components = min(n_components, max(2, expression.shape[1] - 1), max(2, expression.shape[0] - 1))
  437. svd = TruncatedSVD(n_components=n_components, random_state=0)
  438. embedding = svd.fit_transform(expression)
  439. heterogeneity = np.zeros(embedding.shape[0], dtype=float)
  440. for i, row in enumerate(neighbors):
  441. valid = row[row >= 0]
  442. if len(valid) == 0:
  443. heterogeneity[i] = 1.0
  444. continue
  445. local = embedding[valid]
  446. center = local.mean(axis=0, keepdims=True)
  447. heterogeneity[i] = float(np.mean(np.sum((local - center) ** 2, axis=1))) + 1e-6
  448. return heterogeneity
  449. def _project_abundance(
  450. expression: np.ndarray,
  451. signatures: pd.DataFrame,
  452. *,
  453. method: str,
  454. ridge_lambda: float,
  455. ) -> np.ndarray:
  456. normalized_method = method.strip().lower()
  457. signature_values = signatures.to_numpy(dtype=np.float32)
  458. if normalized_method == "ridge":
  459. return ridge_project_celltype_proportions(expression, signature_values, ridge_lambda=ridge_lambda)
  460. if normalized_method != "nnls":
  461. raise ValueError(f"Unsupported projection method: {method}")
  462. projected = np.zeros((expression.shape[0], signature_values.shape[1]), dtype=np.float32)
  463. for idx, y in enumerate(expression):
  464. weights, _ = nnls(signature_values, np.asarray(y, dtype=np.float64), maxiter=1000)
  465. projected[idx, :] = weights.astype(np.float32)
  466. return row_normalize(projected)
  467. def _combine_any_features(
  468. table: pd.DataFrame,
  469. weights: dict[str, float],
  470. *,
  471. groups: pd.Series | None = None,
  472. ) -> pd.Series:
  473. linear = np.zeros(table.shape[0], dtype=float)
  474. total_weight = 0.0
  475. for name, weight in weights.items():
  476. if name not in table.columns or np.isclose(float(weight), 0.0):
  477. continue
  478. linear += float(weight) * grouped_zscore(table[name].to_numpy(dtype=float), groups=groups)
  479. total_weight += abs(float(weight))
  480. if np.isclose(total_weight, 0.0):
  481. raise ValueError("At least one non-zero available feature is required.")
  482. return pd.Series(sigmoid(linear / total_weight), index=table.index)
  483. def _abundance_baselines(predicted: pd.DataFrame) -> pd.DataFrame:
  484. values = predicted.to_numpy(dtype=float)
  485. row_sums = values.sum(axis=1, keepdims=True)
  486. probs = np.divide(values, row_sums, out=np.zeros_like(values), where=row_sums > 0)
  487. sorted_probs = np.sort(probs, axis=1)
  488. top1 = sorted_probs[:, -1] if probs.shape[1] else np.zeros(probs.shape[0], dtype=float)
  489. top2 = sorted_probs[:, -2] if probs.shape[1] > 1 else np.zeros(probs.shape[0], dtype=float)
  490. if probs.shape[1] <= 1:
  491. entropy = np.zeros(probs.shape[0], dtype=float)
  492. else:
  493. entropy = -np.sum(probs * np.log(probs + 1e-12), axis=1) / np.log(probs.shape[1])
  494. return pd.DataFrame(
  495. {
  496. "abundance_entropy_risk": entropy,
  497. "inverse_top1_margin": 1.0 - (top1 - top2),
  498. "inverse_max_abundance": 1.0 - top1,
  499. },
  500. index=predicted.index,
  501. )
  502. def _error_table(predicted: pd.DataFrame, truth: pd.DataFrame) -> pd.DataFrame:
  503. pred = predicted.loc[truth.index, truth.columns].to_numpy(dtype=float)
  504. true = truth.to_numpy(dtype=float)
  505. absolute = np.abs(pred - true)
  506. rmse = np.sqrt(np.mean((pred - true) ** 2, axis=1))
  507. numerator = (pred * true).sum(axis=1)
  508. denominator = np.linalg.norm(pred, axis=1) * np.linalg.norm(true, axis=1)
  509. cosine = np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator > 0)
  510. return pd.DataFrame(
  511. {
  512. "l1_error": absolute.sum(axis=1),
  513. "total_variation_error": 0.5 * absolute.sum(axis=1),
  514. "rmse_error": rmse,
  515. "cosine_distance": 1.0 - np.clip(cosine, -1.0, 1.0),
  516. "dominant_mismatch": predicted.idxmax(axis=1).ne(truth.idxmax(axis=1)).astype(int).to_numpy(),
  517. },
  518. index=truth.index,
  519. )
  520. def _safe_corr(score: pd.Series, error: pd.Series, *, method: str) -> tuple[float, float]:
  521. valid = pd.concat([score, error], axis=1).dropna()
  522. if valid.shape[0] < 3 or valid.iloc[:, 0].nunique() <= 1 or valid.iloc[:, 1].nunique() <= 1:
  523. return np.nan, np.nan
  524. if method == "spearman":
  525. stat, pvalue = spearmanr(valid.iloc[:, 0], valid.iloc[:, 1])
  526. elif method == "pearson":
  527. stat, pvalue = pearsonr(valid.iloc[:, 0], valid.iloc[:, 1])
  528. else:
  529. raise ValueError(method)
  530. return float(stat), float(pvalue)
  531. def _safe_auc(score: pd.Series, labels: pd.Series) -> tuple[float, float]:
  532. valid = pd.concat([score, labels], axis=1).dropna()
  533. if valid.shape[0] < 3 or valid.iloc[:, 1].nunique() < 2 or valid.iloc[:, 0].nunique() <= 1:
  534. return np.nan, np.nan
  535. y_true = valid.iloc[:, 1].astype(int).to_numpy()
  536. y_score = valid.iloc[:, 0].astype(float).to_numpy()
  537. return float(roc_auc_score(y_true, y_score)), float(average_precision_score(y_true, y_score))
  538. def _score_error_summary(table: pd.DataFrame, *, score_cols: list[str], error_col: str) -> pd.DataFrame:
  539. error = table[error_col].astype(float)
  540. high20 = (error >= error.quantile(0.80)).astype(int)
  541. high10 = (error >= error.quantile(0.90)).astype(int)
  542. rows = []
  543. for score_col in score_cols:
  544. score = table[score_col].astype(float)
  545. spearman, spearman_p = _safe_corr(score, error, method="spearman")
  546. pearson, pearson_p = _safe_corr(score, error, method="pearson")
  547. auc20, ap20 = _safe_auc(score, high20)
  548. auc10, ap10 = _safe_auc(score, high10)
  549. low_mask = score <= score.quantile(0.20)
  550. high_mask = score >= score.quantile(0.80)
  551. rows.append(
  552. {
  553. "score_name": score_col,
  554. "n_spots": int(score.notna().sum()),
  555. "error_col": error_col,
  556. "spearman_error": spearman,
  557. "spearman_pvalue": spearman_p,
  558. "pearson_error": pearson,
  559. "pearson_pvalue": pearson_p,
  560. "auroc_top20_error": auc20,
  561. "average_precision_top20_error": ap20,
  562. "auroc_top10_error": auc10,
  563. "average_precision_top10_error": ap10,
  564. "bottom20_score_mean_error": float(error.loc[low_mask].mean()),
  565. "top20_score_mean_error": float(error.loc[high_mask].mean()),
  566. "top_minus_bottom20_error": float(error.loc[high_mask].mean() - error.loc[low_mask].mean()),
  567. }
  568. )
  569. return pd.DataFrame(rows).sort_values(["auroc_top20_error", "spearman_error"], ascending=[False, False])
  570. def _selective_error_curve(
  571. table: pd.DataFrame,
  572. *,
  573. score_cols: list[str],
  574. error_col: str,
  575. keep_fractions: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9, 1.0),
  576. ) -> pd.DataFrame:
  577. full_mean = float(table[error_col].mean())
  578. high_error_threshold = float(table[error_col].quantile(0.8))
  579. rows = []
  580. for score_col in score_cols:
  581. ordered = table.sort_values(score_col, ascending=True)
  582. for keep_fraction in keep_fractions:
  583. n_keep = max(1, int(round(ordered.shape[0] * keep_fraction)))
  584. kept = ordered.head(n_keep)
  585. mean_error = float(kept[error_col].mean())
  586. rows.append(
  587. {
  588. "score_name": score_col,
  589. "keep_fraction": float(keep_fraction),
  590. "abstain_fraction": float(1.0 - keep_fraction),
  591. "n_kept": int(n_keep),
  592. "mean_error": mean_error,
  593. "median_error": float(kept[error_col].median()),
  594. "error_reduction_vs_full": float(1.0 - (mean_error / full_mean)) if full_mean > 0 else np.nan,
  595. "high_error_fraction": float((kept[error_col] >= high_error_threshold).mean()),
  596. "full_mean_error": full_mean,
  597. }
  598. )
  599. return pd.DataFrame(rows)
  600. def _scenario_summary(table: pd.DataFrame, *, score_col: str = "risk_score") -> pd.DataFrame:
  601. grouped = table.groupby("scenario", sort=True)
  602. return (
  603. grouped.agg(
  604. n_spots=("scenario", "size"),
  605. mean_true_error=("total_variation_error", "mean"),
  606. median_true_error=("total_variation_error", "median"),
  607. mean_risk_score=(score_col, "mean"),
  608. mean_phi_local=("phi_local", "mean"),
  609. mean_phi_uncertainty=("phi_uncertainty", "mean"),
  610. mean_phi_reference=("phi_reference", "mean"),
  611. )
  612. .reset_index()
  613. .sort_values("mean_true_error", ascending=False)
  614. )
  615. def _write_benchmark_h5ad_inputs(
  616. run_dir: Path,
  617. *,
  618. obs: pd.DataFrame,
  619. matrix: sparse.spmatrix | np.ndarray,
  620. genes: list[str],
  621. train_cells: dict[str, np.ndarray],
  622. celltypes: list[str],
  623. spot_table: pd.DataFrame,
  624. pseudo_counts: np.ndarray,
  625. label_col: str,
  626. layer: str,
  627. ) -> tuple[Path, Path]:
  628. train_indices = np.sort(np.concatenate([train_cells[celltype] for celltype in celltypes]))
  629. reference_obs = obs.iloc[train_indices].copy()
  630. reference_var = pd.DataFrame(index=pd.Index(genes, name=None).astype(str))
  631. reference_counts = matrix[train_indices, :]
  632. reference = ad.AnnData(X=reference_counts.copy(), obs=reference_obs, var=reference_var)
  633. reference.layers[layer] = reference_counts.copy()
  634. pseudo_obs = spot_table.copy()
  635. pseudo_var = pd.DataFrame(index=pd.Index(genes, name=None).astype(str))
  636. pseudo = ad.AnnData(X=pseudo_counts.copy(), obs=pseudo_obs, var=pseudo_var)
  637. pseudo.layers[layer] = pseudo_counts.copy()
  638. pseudo.obsm["spatial"] = pseudo_obs[["x_spatial", "y_spatial"]].to_numpy(dtype=float)
  639. pseudo.obs["sample_id"] = pseudo.obs["sample_id"].astype(str)
  640. reference_path = results_file(run_dir, "artifacts", "known_composition_train_reference.h5ad")
  641. pseudo_path = results_file(run_dir, "artifacts", "known_composition_pseudo_visium.h5ad")
  642. reference.write_h5ad(reference_path)
  643. pseudo.write_h5ad(pseudo_path)
  644. return reference_path, pseudo_path
  645. def _write_report(
  646. run_dir: Path,
  647. *,
  648. summary: pd.DataFrame,
  649. selective: pd.DataFrame,
  650. scenario: pd.DataFrame,
  651. metadata: dict[str, object],
  652. ) -> None:
  653. best = summary.iloc[0]
  654. risk_row = summary.loc[summary["score_name"] == "risk_score"]
  655. risk_text = "not available"
  656. if not risk_row.empty:
  657. row = risk_row.iloc[0]
  658. risk_text = (
  659. f"Spearman={row['spearman_error']:.3f}, "
  660. f"AUROC(top20 error)={row['auroc_top20_error']:.3f}, "
  661. f"top-bottom20 error gap={row['top_minus_bottom20_error']:.3f}"
  662. )
  663. keep80 = selective.loc[(selective["score_name"] == "risk_score") & (selective["keep_fraction"] == 0.8)]
  664. keep80_text = "not available"
  665. if not keep80.empty:
  666. row = keep80.iloc[0]
  667. keep80_text = (
  668. f"mean error={row['mean_error']:.3f}, "
  669. f"error reduction={row['error_reduction_vs_full']:.3f}, "
  670. f"high-error fraction={row['high_error_fraction']:.3f}"
  671. )
  672. lines = [
  673. "# Revision Known-Composition Benchmark",
  674. "",
  675. "## Purpose",
  676. "",
  677. "本运行生成 DLPFC pseudo-spots(伪空间点),保留 known cell-type composition(已知细胞类型组成),用于直接评估 ReliST risk score(ReliST 风险分数)与 true deconvolution error(真实反卷积误差)的关系。",
  678. "",
  679. "## Main Result Snapshot",
  680. "",
  681. f"- pseudo-spots(伪空间点)数量:`{metadata['n_spots']}`",
  682. f"- retained cell types(保留细胞类型):`{metadata['n_celltypes']}`",
  683. f"- split mode(拆分方式):`{metadata['split']['split_mode']}`,split column(拆分列):`{metadata['split'].get('split_column', 'none')}`。",
  684. f"- primary `risk_score(风险分数)`:{risk_text}",
  685. f"- 低风险 keep 80%(保留 80% 低风险点)后:{keep80_text}",
  686. f"- 当前最佳 score(分数):`{best['score_name']}`,AUROC(top20 error)={best['auroc_top20_error']:.3f}",
  687. "",
  688. "## Caveats",
  689. "",
  690. "- 这是 pseudo-spatial known-composition benchmark(伪空间已知组成基准),可直接回答审稿人关于 true error(真实误差)的核心问题,但仍不是自然组织中的 spot-level ground truth(空间点级真实标签)。",
  691. "- 当前默认不启用 base-model perturbation stability(基础模型扰动稳定性);`phi_stability`(稳定性特征)保留为零列,避免偏离当前 manuscript boundary(手稿边界)。",
  692. "- `risk_score`(风险分数)在本运行中定义为 `phi_local(局部特征)`、`phi_uncertainty(输出模糊性)` 和 `phi_reference(参考特征)` 的等权标准化组合;高分表示更不可靠。",
  693. "",
  694. "## Output Tables",
  695. "",
  696. "- `tables/known_composition_spot_table.csv`",
  697. "- `tables/known_composition_true_abundance.csv`",
  698. "- `tables/known_composition_predicted_abundance.csv`",
  699. "- `tables/known_composition_risk_error_table.csv`",
  700. "- `tables/known_composition_celltype_inclusion.csv`",
  701. "- `tables/known_composition_score_error_summary.csv`",
  702. "- `tables/known_composition_selective_error_curve.csv`",
  703. "- `tables/known_composition_scenario_summary.csv`",
  704. "- `tables/reference_signatures_means.csv`",
  705. "- `tables/reference_signature_markers.csv`",
  706. "- `artifacts/known_composition_train_reference.h5ad`",
  707. "- `artifacts/known_composition_pseudo_visium.h5ad`",
  708. ]
  709. (run_dir / "revision_known_composition_benchmark.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  710. def main() -> int:
  711. args = parse_args()
  712. rng = np.random.default_rng(args.random_state)
  713. run_dir = args.output_root / "runs" / args.run_id
  714. ensure_results_layout(run_dir)
  715. set_selected_run(args.output_root, args.run_id)
  716. requested_genes = _read_gene_list(args.source_run_dir)
  717. source_celltype_order = _read_source_celltype_order(args.source_run_dir)
  718. obs, matrix, genes, celltypes, inclusion_table = _select_reference_matrix(
  719. args.reference_h5ad,
  720. layer=args.layer,
  721. label_col=args.reference_label_column,
  722. split_col=args.split_column if args.split_mode == "donor_disjoint" else None,
  723. requested_genes=requested_genes,
  724. source_celltype_order=source_celltype_order,
  725. min_cells_per_type=args.min_cells_per_type,
  726. )
  727. if args.split_mode == "donor_disjoint":
  728. train_cells, simulate_cells, celltypes, split_metadata = _split_reference_cells_donor_disjoint(
  729. obs,
  730. label_col=args.reference_label_column,
  731. split_col=args.split_column,
  732. celltypes=celltypes,
  733. train_fraction=args.train_fraction,
  734. min_cells_per_side=args.min_split_cells_per_type,
  735. rng=rng,
  736. )
  737. else:
  738. train_cells, simulate_cells = _split_reference_cells(
  739. obs,
  740. label_col=args.reference_label_column,
  741. celltypes=celltypes,
  742. train_fraction=args.train_fraction,
  743. rng=rng,
  744. )
  745. split_metadata = {
  746. "split_mode": "cell_random",
  747. "split_column": None,
  748. "train_fraction": float(args.train_fraction),
  749. "n_retained_celltypes_after_split": int(len(celltypes)),
  750. "dropped_after_split": [],
  751. }
  752. inclusion_table = _augment_inclusion_table(
  753. inclusion_table,
  754. celltypes_after_split=celltypes,
  755. train_cells=train_cells,
  756. simulate_cells=simulate_cells,
  757. )
  758. signatures = _compute_signatures(matrix, train_cells=train_cells, genes=genes, celltypes=celltypes)
  759. markers_all, marker_table = select_signature_markers(
  760. signatures,
  761. top_k=args.marker_top_k,
  762. min_positive_markers=args.min_positive_markers,
  763. )
  764. markers = subset_markers(markers_all, mode=args.marker_subset_mode)
  765. spot_table, true_abundance, region_prototypes, pseudo_counts = _sample_pseudo_spots(
  766. matrix,
  767. simulate_cells=simulate_cells,
  768. celltypes=celltypes,
  769. genes=genes,
  770. markers=markers_all,
  771. n_spots=args.n_spots,
  772. n_regions=args.n_regions,
  773. cells_per_spot_min=args.cells_per_spot_min,
  774. cells_per_spot_max=args.cells_per_spot_max,
  775. rng=rng,
  776. )
  777. reference_h5ad, pseudo_visium_h5ad = _write_benchmark_h5ad_inputs(
  778. run_dir,
  779. obs=obs,
  780. matrix=matrix,
  781. genes=genes,
  782. train_cells=train_cells,
  783. celltypes=celltypes,
  784. spot_table=spot_table,
  785. pseudo_counts=pseudo_counts,
  786. label_col=args.reference_label_column,
  787. layer=args.layer,
  788. )
  789. expression = _normalize_log_cp10k(pseudo_counts)
  790. expression_df = pd.DataFrame(expression, index=spot_table.index, columns=genes)
  791. predicted_values = _project_abundance(
  792. expression,
  793. signatures,
  794. method=args.projection_method,
  795. ridge_lambda=args.ridge_lambda,
  796. )
  797. predicted_abundance = pd.DataFrame(predicted_values, index=spot_table.index, columns=celltypes)
  798. error = _error_table(predicted_abundance, true_abundance)
  799. coords = spot_table[["x_spatial", "y_spatial"]].to_numpy(dtype=float)
  800. neighbors = knn_indices(coords, k=8)
  801. weights = inverse_distance_weights(coords, neighbors)
  802. heterogeneity = _compute_local_heterogeneity(expression, neighbors)
  803. stability_predictions = None
  804. if args.stability_repeats > 0:
  805. stability_predictions = gene_subsample_stability(
  806. expression,
  807. signatures.to_numpy(dtype=np.float32),
  808. repeats=args.stability_repeats,
  809. gene_fraction=args.stability_gene_fraction,
  810. ridge_lambda=args.ridge_lambda,
  811. random_state=args.random_state,
  812. )
  813. ambiguity = pd.Series(ambiguity_score(predicted_abundance), index=predicted_abundance.index, name="phi_uncertainty")
  814. model_output = BaseSpatialModelOutput(abundance=predicted_abundance, uncertainty=ambiguity)
  815. features = build_feature_table(
  816. model_output,
  817. neighbors=neighbors,
  818. weights=weights,
  819. heterogeneity=heterogeneity,
  820. stability_predictions=stability_predictions,
  821. confidence_proxy_precomputed=True,
  822. )
  823. marker_scores = compute_reference_marker_scores(expression_df, markers)
  824. features["phi_reference"] = reference_subsampling_instability(
  825. predicted_abundance,
  826. expression_df,
  827. markers,
  828. repeats=args.reference_repeats,
  829. subset_fraction=args.reference_fraction,
  830. random_state=args.random_state,
  831. )
  832. reference_marker = reference_marker_discordance_proxy(predicted_abundance, marker_scores)
  833. reference_residual = reference_signature_residual_proxy(
  834. predicted_abundance,
  835. expression_df,
  836. signatures,
  837. genes=marker_table["gene"].astype(str).tolist(),
  838. )
  839. groups = spot_table["sample_id"].astype(str)
  840. risk_table = features.copy()
  841. risk_table["risk_score"] = _combine_any_features(
  842. risk_table,
  843. {"phi_local": 1.0, "phi_uncertainty": 1.0, "phi_reference": 1.0},
  844. groups=groups,
  845. )
  846. risk_table["reference_risk_score"] = _combine_any_features(
  847. risk_table,
  848. {"phi_uncertainty": 2.0, "phi_reference": 2.0},
  849. groups=groups,
  850. )
  851. risk_table["local_uncertainty_risk_score"] = _combine_any_features(
  852. risk_table,
  853. {"phi_local": 1.0, "phi_uncertainty": 1.0},
  854. groups=groups,
  855. )
  856. for column, values in _abundance_baselines(predicted_abundance).items():
  857. risk_table[column] = values
  858. risk_table["snrna_marker_discordance"] = reference_marker
  859. risk_table["snrna_signature_residual"] = reference_residual
  860. risk_table = pd.concat([spot_table, risk_table, error], axis=1)
  861. score_cols = [
  862. "risk_score",
  863. "reference_risk_score",
  864. "local_uncertainty_risk_score",
  865. "abundance_entropy_risk",
  866. "inverse_top1_margin",
  867. "inverse_max_abundance",
  868. "phi_local",
  869. "phi_uncertainty",
  870. "phi_reference",
  871. "snrna_marker_discordance",
  872. "snrna_signature_residual",
  873. ]
  874. if args.stability_repeats > 0:
  875. score_cols.append("phi_stability")
  876. score_summary = _score_error_summary(risk_table, score_cols=score_cols, error_col="total_variation_error")
  877. selective_curve = _selective_error_curve(risk_table, score_cols=score_cols, error_col="total_variation_error")
  878. scenario_summary = _scenario_summary(risk_table)
  879. spot_table.to_csv(results_file(run_dir, "tables", "known_composition_spot_table.csv"))
  880. true_abundance.to_csv(results_file(run_dir, "tables", "known_composition_true_abundance.csv"))
  881. predicted_abundance.to_csv(results_file(run_dir, "tables", "known_composition_predicted_abundance.csv"))
  882. risk_table.to_csv(results_file(run_dir, "tables", "known_composition_risk_error_table.csv"))
  883. inclusion_table.to_csv(results_file(run_dir, "tables", "known_composition_celltype_inclusion.csv"), index=False)
  884. score_summary.to_csv(results_file(run_dir, "tables", "known_composition_score_error_summary.csv"), index=False)
  885. selective_curve.to_csv(results_file(run_dir, "tables", "known_composition_selective_error_curve.csv"), index=False)
  886. scenario_summary.to_csv(results_file(run_dir, "tables", "known_composition_scenario_summary.csv"), index=False)
  887. region_prototypes.to_csv(results_file(run_dir, "tables", "known_composition_region_prototypes.csv"))
  888. signatures.to_csv(results_file(run_dir, "tables", "reference_signatures_means.csv"))
  889. marker_table.to_csv(results_file(run_dir, "tables", "reference_signature_markers.csv"), index=False)
  890. expression_df.to_csv(results_file(run_dir, "tables", "known_composition_expression_log_cp10k.csv"))
  891. metadata = {
  892. "run_id": args.run_id,
  893. "reference_h5ad": str(args.reference_h5ad),
  894. "reference_label_column": args.reference_label_column,
  895. "source_run_dir": str(args.source_run_dir),
  896. "n_spots": int(args.n_spots),
  897. "n_genes": int(len(genes)),
  898. "n_celltypes": int(len(celltypes)),
  899. "n_original_labels": int(inclusion_table.shape[0]),
  900. "n_retained_before_split": int(inclusion_table["retained_before_split"].sum()),
  901. "n_retained_after_split": int(inclusion_table["retained_after_split"].sum()),
  902. "celltypes": celltypes,
  903. "random_state": int(args.random_state),
  904. "train_fraction": float(args.train_fraction),
  905. "split": split_metadata,
  906. "train_reference_h5ad": str(reference_h5ad),
  907. "pseudo_visium_h5ad": str(pseudo_visium_h5ad),
  908. "cells_per_spot_min": int(args.cells_per_spot_min),
  909. "cells_per_spot_max": int(args.cells_per_spot_max),
  910. "pseudo_spot_scenarios": {
  911. "clean": "No extra degradation beyond pseudo-bulk sampling.",
  912. "low_depth": "Binomial thinning of counts with keep_probability sampled uniformly from 0.15 to 0.45.",
  913. "marker_dropout": "Dominant-type marker counts thinned to 25% keep probability.",
  914. "diffuse_mixture": "Composition smoothed toward a uniform mixture before cell sampling.",
  915. },
  916. "spatial_coordinate_generation": (
  917. "Pseudo-spots are placed on a regular grid. Smooth region prototypes vary along the y coordinate; "
  918. "therefore local-structure features should be interpreted with the shuffled-coordinate/null controls "
  919. "added in downstream revision scripts."
  920. ),
  921. "risk_score_definition": "equal-weight grouped-zscore combination of phi_local, phi_uncertainty, and phi_reference",
  922. "projection_method": args.projection_method,
  923. "stability_repeats": int(args.stability_repeats),
  924. "reference_feature_mode": "reference_subsampling_instability",
  925. "reference_marker_subset_mode": args.marker_subset_mode,
  926. "primary_error_col": "total_variation_error",
  927. "score_columns": score_cols,
  928. "manuscript_boundary": (
  929. "This benchmark is a known-composition validation of risk-error association. "
  930. "It does not make natural tissue spot-level truth claims."
  931. ),
  932. }
  933. results_file(run_dir, "metadata", "known_composition_benchmark.json").write_text(
  934. json.dumps(metadata, indent=2, ensure_ascii=False),
  935. encoding="utf-8",
  936. )
  937. _write_report(run_dir, summary=score_summary, selective=selective_curve, scenario=scenario_summary, metadata=metadata)
  938. print(f"Wrote known-composition benchmark to {run_dir}")
  939. print(json.dumps({"run_id": args.run_id, "n_spots": args.n_spots, "n_celltypes": len(celltypes)}, indent=2))
  940. return 0
  941. if __name__ == "__main__":
  942. raise SystemExit(main())

run_revision_known_composition_benchmark.py at commit ae4af50, under MIT · at the source

Overview

Authors: Xinyu Zhang1, Li He2, Yu Peng3, Yijia Li4, Jianyuan Kang1, Lisheng Peng4, Yifei Xu1, Sen Lin2
ORCID iDs: Xinyu Zhang
  1. Institute of Gastroenterology, Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
  2. Department of Oncology, Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
  3. Science and Technology Innovation Center, Guangzhou University of Chinese Medicine, Guangzhou, Guangdong, China
  4. Shenzhen Traditional Chinese Medicine Hospital, The Fourth Clinical Medical College of Guangzhou University of Chinese Medicine, Shenzhen, Guangdong, China
Journal: iScience, volume 29, issue 9, article 117206
Dates: received 21 May 2026; accepted 30 July 2026; published online 14 August 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1016/j.isci.2026.117206 · PMID 42643167 · PMCID PMC13503129 · OpenAlex W7203496055
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), human (organism), mouse (organism)
Methods: Connectivity, Statistics, Preprocessing
Keywords: ReliST, spatial transcriptomics, deconvolution, risk-aware deconvolution, reliability, selective prediction, risk-bin diagnostics, reference mismatch, artifact-aware interpretation
Topic: Single-cell and spatial transcriptomics (Molecular Biology, Biochemistry, Genetics and Molecular Biology), according to OpenAlex
Funding: Sanming Project of Medicine in Shenzhen (SZZYSM202211003)
Citations: not cited yet (Europe PMC); 55 references in the paper

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

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: ae4af503c975f3bfad45ab89658ad92fd25c3465, 20 June 2026
Languages: Python (57), R (1)
Size: 107 files, 58 scripts
Software Heritage: not archived
Found in: “Data and code availability”
Holds: README, license file, environment (pyproject.toml), tests, documentation
Not found: CITATION.cff, continuous integration
Tools: pandas (37 files), NumPy (32 files), scikit-learn (8 files), SciPy (7 files), anndata (6 files), PyTorch (4 files), Matplotlib (1 file), Pillow (1 file), Scanpy (1 file), seaborn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
60 files

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://doi.org/10.1016/j.isci.2026.117206

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/j.isci.2026.117206},
url = {https://doi.org/10.1016/j.isci.2026.117206},
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/08/14
VL - 29
IS - 9
SP - 117206
SN - 2589-0042
PB - Elsevier
DO - 10.1016/j.isci.2026.117206
UR - https://doi.org/10.1016/j.isci.2026.117206
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.isci.2026.117206",
"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": "iScience",
"volume": "29",
"issue": "9",
"page": "117206",
"DOI": "10.1016/j.isci.2026.117206",
"PMID": "42643167",
"PMCID": "PMC13503129",
"ISSN": "2589-0042",
"publisher": "Elsevier",
"URL": "https://doi.org/10.1016/j.isci.2026.117206",
"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 Technologies
Journal: 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 bioinformatics
In 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 bioinformatics
In 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 methods
In 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 methods
In 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 neuroscience
In 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.

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.