Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +53 -0
- LICENSE +21 -0
- README.md +108 -3
- algorithm.sty +79 -0
- algorithmic.sty +201 -0
- analyses/_common.py +54 -0
- analyses/deep/02_bootstrap_ci.py +67 -0
- analyses/deep/04_scifate_tautology.py +129 -0
- analyses/deep/16_gpu_scalability.py +147 -0
- analyses/deep/17_go_enrichment.py +149 -0
- analyses/deep/19_scvelo_dyn_investigation.py +114 -0
- analyses/deep/25_scvelo_dyn_sweep.py +148 -0
- analyses/deep/27_perturbation_validation.py +177 -0
- analyses/deep/29_pt_states_comparison.py +140 -0
- analyses/deep/35_corrected_comparison.py +144 -0
- analyses/deep/_common.py +173 -0
- analyses/run_comprehensive_fixes.py +1283 -0
- analyses/run_comprehensive_improvements.py +1251 -0
- analyses/run_deep_benchmark.py +735 -0
- analyses/run_deep_benchmark.sh +7 -0
- analyses/run_deep_benchmark_v2.py +734 -0
- analyses/run_dentate_gyrus.py +252 -0
- analyses/run_final_fixes.py +524 -0
- analyses/run_gaps.py +550 -0
- analyses/run_halflife_ablation.py +216 -0
- analyses/run_perturbation_validation.py +447 -0
- analyses/run_scifate.py +571 -0
- analyses/run_summary.py +380 -0
- analyses/run_tier2_validation.py +493 -0
- analyses/run_tier3.py +768 -0
- analyses/run_velocity_comparison.py +180 -0
- analyses/run_wrapup_analysis.py +743 -0
- example_paper.bib +75 -0
- example_paper.tex +662 -0
- fancyhdr.sty +864 -0
- icml2026.bst +1443 -0
- icml2026.sty +767 -0
- output/comprehensive_improvements/figures/experiment_b_pathway_consistency.png +0 -0
- output/final_fixes/results/pathway_specificity.json +80 -0
- pyproject.toml +44 -0
- src/scptr/benchmark/__init__.py +14 -0
- src/scptr/benchmark/_enrichment.py +108 -0
- src/scptr/benchmark/_halflife_correlation.py +127 -0
- src/scptr/benchmark/_robustness.py +77 -0
- src/scptr/benchmark/data/are_genes.txt +139 -0
- src/scptr/benchmark/data/eclip_targets.csv +0 -0
- src/scptr/benchmark/data/human_utr_features.csv +0 -0
- src/scptr/benchmark/data/human_utr_length.csv +0 -0
- src/scptr/benchmark/data/mouse_utr_length.csv +0 -0
- src/scptr/benchmark/data/nmd_genes.txt +118 -0
.gitignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
*.egg
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
.eggs/
|
| 9 |
+
|
| 10 |
+
# Virtual environments
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
|
| 14 |
+
# IDE
|
| 15 |
+
.idea/
|
| 16 |
+
.vscode/
|
| 17 |
+
*.swp
|
| 18 |
+
*.swo
|
| 19 |
+
|
| 20 |
+
# Testing
|
| 21 |
+
.pytest_cache/
|
| 22 |
+
.coverage
|
| 23 |
+
htmlcov/
|
| 24 |
+
|
| 25 |
+
# Output (generated results)
|
| 26 |
+
output/
|
| 27 |
+
|
| 28 |
+
# OS
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
|
| 32 |
+
# Cache
|
| 33 |
+
.cache/
|
| 34 |
+
|
| 35 |
+
# Large data files (downloaded at runtime)
|
| 36 |
+
/data/
|
| 37 |
+
*.h5ad
|
| 38 |
+
.pybiomart.sqlite
|
| 39 |
+
|
| 40 |
+
# Local-only working directories (kept out of the public repo)
|
| 41 |
+
neurips2026/
|
| 42 |
+
supplementary/
|
| 43 |
+
supplementary.zip
|
| 44 |
+
.supplementary-build/
|
| 45 |
+
|
| 46 |
+
# Local scratch from running figure scripts at the repo root
|
| 47 |
+
/compute_fig1c_data.py
|
| 48 |
+
/compute_real_figure_data.py
|
| 49 |
+
/generate_figures.py
|
| 50 |
+
/figures/
|
| 51 |
+
/real_figure_data/
|
| 52 |
+
err
|
| 53 |
+
icml-extract/
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Bryan Cheng, Austin Jin
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scPTR
|
| 2 |
+
|
| 3 |
+
**Single-Cell Post-Transcriptional Regulatory Decomposition**
|
| 4 |
+
|
| 5 |
+
scPTR estimates per-cell, per-gene mRNA degradation rates from scRNA-seq spliced/unspliced counts and uses them as a primary analytical axis — complementary to RNA velocity.
|
| 6 |
+
|
| 7 |
+
## What scPTR does
|
| 8 |
+
|
| 9 |
+
- **Degradation rate estimation**: Per-cell, per-gene gamma from kinetic steady-state relationships with kNN Gaussian-kernel smoothing
|
| 10 |
+
- **Expression-invisible states**: Discovers cell subpopulations with distinct post-transcriptional programs undetectable by standard expression analysis
|
| 11 |
+
- **Post-transcriptional velocity**: Neighbor-averaged gamma gradient that captures degradation dynamics orthogonal to RNA velocity
|
| 12 |
+
- **RBP-target networks**: Library-size-corrected inference of RNA-binding protein regulatory networks with elastic net
|
| 13 |
+
- **DeepPTR**: Structured VAE with a kinetic decoder that disentangles transcriptional and post-transcriptional latent spaces
|
| 14 |
+
|
| 15 |
+
## Installation
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
pip install .
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Optional dependencies:
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
pip install ".[deep]" # PyTorch for DeepPTR
|
| 25 |
+
pip install ".[datasets]" # Pooch for dataset downloads
|
| 26 |
+
pip install ".[dev]" # pytest for testing
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## Quick start
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
import scptr
|
| 33 |
+
|
| 34 |
+
# Load data with spliced/unspliced layers
|
| 35 |
+
adata = scptr.read_h5ad("your_data.h5ad")
|
| 36 |
+
|
| 37 |
+
# Preprocessing
|
| 38 |
+
scptr.pp.filter_genes(adata)
|
| 39 |
+
scptr.pp.normalize_layers(adata)
|
| 40 |
+
scptr.pp.neighbors(adata)
|
| 41 |
+
scptr.pp.smooth_layers(adata)
|
| 42 |
+
|
| 43 |
+
# Estimate rates
|
| 44 |
+
scptr.tl.estimate_beta(adata)
|
| 45 |
+
scptr.tl.estimate_gamma(adata)
|
| 46 |
+
|
| 47 |
+
# Downstream analysis
|
| 48 |
+
scptr.tl.variance_decomposition(adata)
|
| 49 |
+
scptr.tl.pt_states(adata)
|
| 50 |
+
scptr.tl.pt_velocity(adata)
|
| 51 |
+
scptr.tl.infer_network(adata)
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## Pipeline overview
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
Raw scRNA-seq (spliced + unspliced)
|
| 58 |
+
-> Gene/cell filtering
|
| 59 |
+
-> Library-size normalization (per layer)
|
| 60 |
+
-> kNN graph + Gaussian smoothing
|
| 61 |
+
-> Beta estimation (quantile regression on u/s phase portraits)
|
| 62 |
+
-> Gamma estimation (gamma = beta * u / s, per cell per gene)
|
| 63 |
+
-> Variance decomposition (transcriptional vs post-transcriptional)
|
| 64 |
+
-> PT states (PCA + Leiden clustering in gamma-space)
|
| 65 |
+
-> PT velocity (neighbor-averaged gamma gradient)
|
| 66 |
+
-> RBP-target network inference (elastic net, library-size corrected)
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## Validation
|
| 70 |
+
|
| 71 |
+
scPTR gamma estimates have been validated against:
|
| 72 |
+
|
| 73 |
+
| Validation | Result |
|
| 74 |
+
|------------|--------|
|
| 75 |
+
| Published mRNA half-lives | ρ = −0.81 (sci-fate), −0.33 to −0.40 (10x developmental) |
|
| 76 |
+
| Method comparison | Outperforms scVelo steady-state (−0.37) and velVI (−0.28) |
|
| 77 |
+
| miRNA target enrichment | 59% of 215 families enriched (p = 4.7×10⁻⁶⁵) |
|
| 78 |
+
| 3′ UTR sequence features | UTR length ρ = 0.34 (p < 10⁻²⁰⁰), AU content ρ = 0.30 |
|
| 79 |
+
| DepMap CRISPR essentiality | Hub RBPs more essential (p = 6.4×10⁻⁵) |
|
| 80 |
+
| Subsampling robustness | r > 0.97 at 20% subsampling |
|
| 81 |
+
|
| 82 |
+
## Key findings
|
| 83 |
+
|
| 84 |
+
- **Expression-invisible states**: 3/8 pancreatic and 6/11 hippocampal cell types harbor post-transcriptional subpopulations undetectable by expression analysis (confirmed by zero-permutation control, ARI ≈ 0), enriched for ER stress/autophagy and synaptic plasticity pathways
|
| 85 |
+
- **Temporal precedence**: degradation-rate changes precede expression changes for 54% of transition genes in pancreas (p < 10⁻⁵⁷) and 78% in dentate gyrus (p = 9.9×10⁻¹³)
|
| 86 |
+
- **RBP networks**: library-size-corrected inference identifies essential hub regulators (HNRNPA1, YBX1, ELAVL1/HuR); neuroblastoma shows 66% stabilizing edges vs. destabilizing bias in developmental tissues
|
| 87 |
+
|
| 88 |
+
## Datasets
|
| 89 |
+
|
| 90 |
+
Built-in dataset loaders (downloaded via Pooch):
|
| 91 |
+
|
| 92 |
+
```python
|
| 93 |
+
adata = scptr.datasets.pancreas() # Mouse endocrinogenesis (3,696 cells)
|
| 94 |
+
adata = scptr.datasets.dentate_gyrus() # Mouse hippocampal neurogenesis (2,930 cells)
|
| 95 |
+
adata = scptr.datasets.sci_fate() # Human A549 dexamethasone response (7,404 cells)
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## Requirements
|
| 99 |
+
|
| 100 |
+
- Python >= 3.9
|
| 101 |
+
- anndata >= 0.8, scanpy >= 1.9, numpy >= 1.21, scipy >= 1.7, numba >= 0.55
|
| 102 |
+
- Optional: torch >= 2.0 (DeepPTR), pooch >= 1.6 (datasets)
|
| 103 |
+
|
| 104 |
+
## Citation
|
| 105 |
+
|
| 106 |
+
If you use scPTR, please cite:
|
| 107 |
+
|
| 108 |
+
> scPTR: Decomposing Post-Transcriptional Regulation at Single-Cell Resolution (2026)
|
algorithm.sty
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
% ALGORITHM STYLE -- Released 8 April 1996
|
| 2 |
+
% for LaTeX-2e
|
| 3 |
+
% Copyright -- 1994 Peter Williams
|
| 4 |
+
% E-mail Peter.Williams@dsto.defence.gov.au
|
| 5 |
+
\NeedsTeXFormat{LaTeX2e}
|
| 6 |
+
\ProvidesPackage{algorithm}
|
| 7 |
+
\typeout{Document Style `algorithm' - floating environment}
|
| 8 |
+
|
| 9 |
+
\RequirePackage{float}
|
| 10 |
+
\RequirePackage{ifthen}
|
| 11 |
+
\newcommand{\ALG@within}{nothing}
|
| 12 |
+
\newboolean{ALG@within}
|
| 13 |
+
\setboolean{ALG@within}{false}
|
| 14 |
+
\newcommand{\ALG@floatstyle}{ruled}
|
| 15 |
+
\newcommand{\ALG@name}{Algorithm}
|
| 16 |
+
\newcommand{\listalgorithmname}{List of \ALG@name s}
|
| 17 |
+
|
| 18 |
+
% Declare Options
|
| 19 |
+
% first appearance
|
| 20 |
+
\DeclareOption{plain}{
|
| 21 |
+
\renewcommand{\ALG@floatstyle}{plain}
|
| 22 |
+
}
|
| 23 |
+
\DeclareOption{ruled}{
|
| 24 |
+
\renewcommand{\ALG@floatstyle}{ruled}
|
| 25 |
+
}
|
| 26 |
+
\DeclareOption{boxed}{
|
| 27 |
+
\renewcommand{\ALG@floatstyle}{boxed}
|
| 28 |
+
}
|
| 29 |
+
% then numbering convention
|
| 30 |
+
\DeclareOption{part}{
|
| 31 |
+
\renewcommand{\ALG@within}{part}
|
| 32 |
+
\setboolean{ALG@within}{true}
|
| 33 |
+
}
|
| 34 |
+
\DeclareOption{chapter}{
|
| 35 |
+
\renewcommand{\ALG@within}{chapter}
|
| 36 |
+
\setboolean{ALG@within}{true}
|
| 37 |
+
}
|
| 38 |
+
\DeclareOption{section}{
|
| 39 |
+
\renewcommand{\ALG@within}{section}
|
| 40 |
+
\setboolean{ALG@within}{true}
|
| 41 |
+
}
|
| 42 |
+
\DeclareOption{subsection}{
|
| 43 |
+
\renewcommand{\ALG@within}{subsection}
|
| 44 |
+
\setboolean{ALG@within}{true}
|
| 45 |
+
}
|
| 46 |
+
\DeclareOption{subsubsection}{
|
| 47 |
+
\renewcommand{\ALG@within}{subsubsection}
|
| 48 |
+
\setboolean{ALG@within}{true}
|
| 49 |
+
}
|
| 50 |
+
\DeclareOption{nothing}{
|
| 51 |
+
\renewcommand{\ALG@within}{nothing}
|
| 52 |
+
\setboolean{ALG@within}{true}
|
| 53 |
+
}
|
| 54 |
+
\DeclareOption*{\edef\ALG@name{\CurrentOption}}
|
| 55 |
+
|
| 56 |
+
% ALGORITHM
|
| 57 |
+
%
|
| 58 |
+
\ProcessOptions
|
| 59 |
+
\floatstyle{\ALG@floatstyle}
|
| 60 |
+
\ifthenelse{\boolean{ALG@within}}{
|
| 61 |
+
\ifthenelse{\equal{\ALG@within}{part}}
|
| 62 |
+
{\newfloat{algorithm}{htbp}{loa}[part]}{}
|
| 63 |
+
\ifthenelse{\equal{\ALG@within}{chapter}}
|
| 64 |
+
{\newfloat{algorithm}{htbp}{loa}[chapter]}{}
|
| 65 |
+
\ifthenelse{\equal{\ALG@within}{section}}
|
| 66 |
+
{\newfloat{algorithm}{htbp}{loa}[section]}{}
|
| 67 |
+
\ifthenelse{\equal{\ALG@within}{subsection}}
|
| 68 |
+
{\newfloat{algorithm}{htbp}{loa}[subsection]}{}
|
| 69 |
+
\ifthenelse{\equal{\ALG@within}{subsubsection}}
|
| 70 |
+
{\newfloat{algorithm}{htbp}{loa}[subsubsection]}{}
|
| 71 |
+
\ifthenelse{\equal{\ALG@within}{nothing}}
|
| 72 |
+
{\newfloat{algorithm}{htbp}{loa}}{}
|
| 73 |
+
}{
|
| 74 |
+
\newfloat{algorithm}{htbp}{loa}
|
| 75 |
+
}
|
| 76 |
+
\floatname{algorithm}{\ALG@name}
|
| 77 |
+
|
| 78 |
+
\newcommand{\listofalgorithms}{\listof{algorithm}{\listalgorithmname}}
|
| 79 |
+
|
algorithmic.sty
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
% ALGORITHMIC STYLE -- Released 8 APRIL 1996
|
| 2 |
+
% for LaTeX version 2e
|
| 3 |
+
% Copyright -- 1994 Peter Williams
|
| 4 |
+
% E-mail PeterWilliams@dsto.defence.gov.au
|
| 5 |
+
%
|
| 6 |
+
% Modified by Alex Smola (08/2000)
|
| 7 |
+
% E-mail Alex.Smola@anu.edu.au
|
| 8 |
+
%
|
| 9 |
+
\NeedsTeXFormat{LaTeX2e}
|
| 10 |
+
\ProvidesPackage{algorithmic}
|
| 11 |
+
\typeout{Document Style `algorithmic' - environment}
|
| 12 |
+
%
|
| 13 |
+
\RequirePackage{ifthen}
|
| 14 |
+
\RequirePackage{calc}
|
| 15 |
+
\newboolean{ALC@noend}
|
| 16 |
+
\setboolean{ALC@noend}{false}
|
| 17 |
+
\newcounter{ALC@line}
|
| 18 |
+
\newcounter{ALC@rem}
|
| 19 |
+
\newlength{\ALC@tlm}
|
| 20 |
+
%
|
| 21 |
+
\DeclareOption{noend}{\setboolean{ALC@noend}{true}}
|
| 22 |
+
%
|
| 23 |
+
\ProcessOptions
|
| 24 |
+
%
|
| 25 |
+
% ALGORITHMIC
|
| 26 |
+
\newcommand{\algorithmicrequire}{\textbf{Require:}}
|
| 27 |
+
\newcommand{\algorithmicensure}{\textbf{Ensure:}}
|
| 28 |
+
\newcommand{\algorithmiccomment}[1]{\{#1\}}
|
| 29 |
+
\newcommand{\algorithmicend}{\textbf{end}}
|
| 30 |
+
\newcommand{\algorithmicif}{\textbf{if}}
|
| 31 |
+
\newcommand{\algorithmicthen}{\textbf{then}}
|
| 32 |
+
\newcommand{\algorithmicelse}{\textbf{else}}
|
| 33 |
+
\newcommand{\algorithmicelsif}{\algorithmicelse\ \algorithmicif}
|
| 34 |
+
\newcommand{\algorithmicendif}{\algorithmicend\ \algorithmicif}
|
| 35 |
+
\newcommand{\algorithmicfor}{\textbf{for}}
|
| 36 |
+
\newcommand{\algorithmicforall}{\textbf{for all}}
|
| 37 |
+
\newcommand{\algorithmicdo}{\textbf{do}}
|
| 38 |
+
\newcommand{\algorithmicendfor}{\algorithmicend\ \algorithmicfor}
|
| 39 |
+
\newcommand{\algorithmicwhile}{\textbf{while}}
|
| 40 |
+
\newcommand{\algorithmicendwhile}{\algorithmicend\ \algorithmicwhile}
|
| 41 |
+
\newcommand{\algorithmicloop}{\textbf{loop}}
|
| 42 |
+
\newcommand{\algorithmicendloop}{\algorithmicend\ \algorithmicloop}
|
| 43 |
+
\newcommand{\algorithmicrepeat}{\textbf{repeat}}
|
| 44 |
+
\newcommand{\algorithmicuntil}{\textbf{until}}
|
| 45 |
+
|
| 46 |
+
%changed by alex smola
|
| 47 |
+
\newcommand{\algorithmicinput}{\textbf{input}}
|
| 48 |
+
\newcommand{\algorithmicoutput}{\textbf{output}}
|
| 49 |
+
\newcommand{\algorithmicset}{\textbf{set}}
|
| 50 |
+
\newcommand{\algorithmictrue}{\textbf{true}}
|
| 51 |
+
\newcommand{\algorithmicfalse}{\textbf{false}}
|
| 52 |
+
\newcommand{\algorithmicand}{\textbf{and\ }}
|
| 53 |
+
\newcommand{\algorithmicor}{\textbf{or\ }}
|
| 54 |
+
\newcommand{\algorithmicfunction}{\textbf{function}}
|
| 55 |
+
\newcommand{\algorithmicendfunction}{\algorithmicend\ \algorithmicfunction}
|
| 56 |
+
\newcommand{\algorithmicmain}{\textbf{main}}
|
| 57 |
+
\newcommand{\algorithmicendmain}{\algorithmicend\ \algorithmicmain}
|
| 58 |
+
%end changed by alex smola
|
| 59 |
+
|
| 60 |
+
\def\ALC@item[#1]{%
|
| 61 |
+
\if@noparitem \@donoparitem
|
| 62 |
+
\else \if@inlabel \indent \par \fi
|
| 63 |
+
\ifhmode \unskip\unskip \par \fi
|
| 64 |
+
\if@newlist \if@nobreak \@nbitem \else
|
| 65 |
+
\addpenalty\@beginparpenalty
|
| 66 |
+
\addvspace\@topsep \addvspace{-\parskip}\fi
|
| 67 |
+
\else \addpenalty\@itempenalty \addvspace\itemsep
|
| 68 |
+
\fi
|
| 69 |
+
\global\@inlabeltrue
|
| 70 |
+
\fi
|
| 71 |
+
\everypar{\global\@minipagefalse\global\@newlistfalse
|
| 72 |
+
\if@inlabel\global\@inlabelfalse \hskip -\parindent \box\@labels
|
| 73 |
+
\penalty\z@ \fi
|
| 74 |
+
\everypar{}}\global\@nobreakfalse
|
| 75 |
+
\if@noitemarg \@noitemargfalse \if@nmbrlist \refstepcounter{\@listctr}\fi \fi
|
| 76 |
+
\sbox\@tempboxa{\makelabel{#1}}%
|
| 77 |
+
\global\setbox\@labels
|
| 78 |
+
\hbox{\unhbox\@labels \hskip \itemindent
|
| 79 |
+
\hskip -\labelwidth \hskip -\ALC@tlm
|
| 80 |
+
\ifdim \wd\@tempboxa >\labelwidth
|
| 81 |
+
\box\@tempboxa
|
| 82 |
+
\else \hbox to\labelwidth {\unhbox\@tempboxa}\fi
|
| 83 |
+
\hskip \ALC@tlm}\ignorespaces}
|
| 84 |
+
%
|
| 85 |
+
\newenvironment{algorithmic}[1][0]{
|
| 86 |
+
\let\@item\ALC@item
|
| 87 |
+
\newcommand{\ALC@lno}{%
|
| 88 |
+
\ifthenelse{\equal{\arabic{ALC@rem}}{0}}
|
| 89 |
+
{{\footnotesize \arabic{ALC@line}:}}{}%
|
| 90 |
+
}
|
| 91 |
+
\let\@listii\@listi
|
| 92 |
+
\let\@listiii\@listi
|
| 93 |
+
\let\@listiv\@listi
|
| 94 |
+
\let\@listv\@listi
|
| 95 |
+
\let\@listvi\@listi
|
| 96 |
+
\let\@listvii\@listi
|
| 97 |
+
\newenvironment{ALC@g}{
|
| 98 |
+
\begin{list}{\ALC@lno}{ \itemsep\z@ \itemindent\z@
|
| 99 |
+
\listparindent\z@ \rightmargin\z@
|
| 100 |
+
\topsep\z@ \partopsep\z@ \parskip\z@\parsep\z@
|
| 101 |
+
\leftmargin 1em
|
| 102 |
+
\addtolength{\ALC@tlm}{\leftmargin}
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
{\end{list}}
|
| 106 |
+
\newcommand{\ALC@it}{\addtocounter{ALC@line}{1}\addtocounter{ALC@rem}{1}\ifthenelse{\equal{\arabic{ALC@rem}}{#1}}{\setcounter{ALC@rem}{0}}{}\item}
|
| 107 |
+
\newcommand{\ALC@com}[1]{\ifthenelse{\equal{##1}{default}}%
|
| 108 |
+
{}{\ \algorithmiccomment{##1}}}
|
| 109 |
+
\newcommand{\REQUIRE}{\item[\algorithmicrequire]}
|
| 110 |
+
\newcommand{\ENSURE}{\item[\algorithmicensure]}
|
| 111 |
+
\newcommand{\STATE}{\ALC@it}
|
| 112 |
+
\newcommand{\COMMENT}[1]{\algorithmiccomment{##1}}
|
| 113 |
+
%changes by alex smola
|
| 114 |
+
\newcommand{\INPUT}{\item[\algorithmicinput]}
|
| 115 |
+
\newcommand{\OUTPUT}{\item[\algorithmicoutput]}
|
| 116 |
+
\newcommand{\SET}{\item[\algorithmicset]}
|
| 117 |
+
% \newcommand{\TRUE}{\algorithmictrue}
|
| 118 |
+
% \newcommand{\FALSE}{\algorithmicfalse}
|
| 119 |
+
\newcommand{\AND}{\algorithmicand}
|
| 120 |
+
\newcommand{\OR}{\algorithmicor}
|
| 121 |
+
\newenvironment{ALC@func}{\begin{ALC@g}}{\end{ALC@g}}
|
| 122 |
+
\newenvironment{ALC@main}{\begin{ALC@g}}{\end{ALC@g}}
|
| 123 |
+
%end changes by alex smola
|
| 124 |
+
\newenvironment{ALC@if}{\begin{ALC@g}}{\end{ALC@g}}
|
| 125 |
+
\newenvironment{ALC@for}{\begin{ALC@g}}{\end{ALC@g}}
|
| 126 |
+
\newenvironment{ALC@whl}{\begin{ALC@g}}{\end{ALC@g}}
|
| 127 |
+
\newenvironment{ALC@loop}{\begin{ALC@g}}{\end{ALC@g}}
|
| 128 |
+
\newenvironment{ALC@rpt}{\begin{ALC@g}}{\end{ALC@g}}
|
| 129 |
+
\renewcommand{\\}{\@centercr}
|
| 130 |
+
\newcommand{\IF}[2][default]{\ALC@it\algorithmicif\ ##2\ \algorithmicthen%
|
| 131 |
+
\ALC@com{##1}\begin{ALC@if}}
|
| 132 |
+
\newcommand{\SHORTIF}[2]{\ALC@it\algorithmicif\ ##1\
|
| 133 |
+
\algorithmicthen\ {##2}}
|
| 134 |
+
\newcommand{\ELSE}[1][default]{\end{ALC@if}\ALC@it\algorithmicelse%
|
| 135 |
+
\ALC@com{##1}\begin{ALC@if}}
|
| 136 |
+
\newcommand{\ELSIF}[2][default]%
|
| 137 |
+
{\end{ALC@if}\ALC@it\algorithmicelsif\ ##2\ \algorithmicthen%
|
| 138 |
+
\ALC@com{##1}\begin{ALC@if}}
|
| 139 |
+
\newcommand{\FOR}[2][default]{\ALC@it\algorithmicfor\ ##2\ \algorithmicdo%
|
| 140 |
+
\ALC@com{##1}\begin{ALC@for}}
|
| 141 |
+
\newcommand{\FORALL}[2][default]{\ALC@it\algorithmicforall\ ##2\ %
|
| 142 |
+
\algorithmicdo%
|
| 143 |
+
\ALC@com{##1}\begin{ALC@for}}
|
| 144 |
+
\newcommand{\SHORTFORALL}[2]{\ALC@it\algorithmicforall\ ##1\ %
|
| 145 |
+
\algorithmicdo\ {##2}}
|
| 146 |
+
\newcommand{\WHILE}[2][default]{\ALC@it\algorithmicwhile\ ##2\ %
|
| 147 |
+
\algorithmicdo%
|
| 148 |
+
\ALC@com{##1}\begin{ALC@whl}}
|
| 149 |
+
\newcommand{\LOOP}[1][default]{\ALC@it\algorithmicloop%
|
| 150 |
+
\ALC@com{##1}\begin{ALC@loop}}
|
| 151 |
+
%changed by alex smola
|
| 152 |
+
\newcommand{\FUNCTION}[2][default]{\ALC@it\algorithmicfunction\ ##2\ %
|
| 153 |
+
\ALC@com{##1}\begin{ALC@func}}
|
| 154 |
+
\newcommand{\MAIN}[2][default]{\ALC@it\algorithmicmain\ ##2\ %
|
| 155 |
+
\ALC@com{##1}\begin{ALC@main}}
|
| 156 |
+
%end changed by alex smola
|
| 157 |
+
\newcommand{\REPEAT}[1][default]{\ALC@it\algorithmicrepeat%
|
| 158 |
+
\ALC@com{##1}\begin{ALC@rpt}}
|
| 159 |
+
\newcommand{\UNTIL}[1]{\end{ALC@rpt}\ALC@it\algorithmicuntil\ ##1}
|
| 160 |
+
\ifthenelse{\boolean{ALC@noend}}{
|
| 161 |
+
\newcommand{\ENDIF}{\end{ALC@if}}
|
| 162 |
+
\newcommand{\ENDFOR}{\end{ALC@for}}
|
| 163 |
+
\newcommand{\ENDWHILE}{\end{ALC@whl}}
|
| 164 |
+
\newcommand{\ENDLOOP}{\end{ALC@loop}}
|
| 165 |
+
\newcommand{\ENDFUNCTION}{\end{ALC@func}}
|
| 166 |
+
\newcommand{\ENDMAIN}{\end{ALC@main}}
|
| 167 |
+
}{
|
| 168 |
+
\newcommand{\ENDIF}{\end{ALC@if}\ALC@it\algorithmicendif}
|
| 169 |
+
\newcommand{\ENDFOR}{\end{ALC@for}\ALC@it\algorithmicendfor}
|
| 170 |
+
\newcommand{\ENDWHILE}{\end{ALC@whl}\ALC@it\algorithmicendwhile}
|
| 171 |
+
\newcommand{\ENDLOOP}{\end{ALC@loop}\ALC@it\algorithmicendloop}
|
| 172 |
+
\newcommand{\ENDFUNCTION}{\end{ALC@func}\ALC@it\algorithmicendfunction}
|
| 173 |
+
\newcommand{\ENDMAIN}{\end{ALC@main}\ALC@it\algorithmicendmain}
|
| 174 |
+
}
|
| 175 |
+
\renewcommand{\@toodeep}{}
|
| 176 |
+
\begin{list}{\ALC@lno}{\setcounter{ALC@line}{0}\setcounter{ALC@rem}{0}%
|
| 177 |
+
\itemsep\z@ \itemindent\z@ \listparindent\z@%
|
| 178 |
+
\partopsep\z@ \parskip\z@ \parsep\z@%
|
| 179 |
+
\labelsep 0.5em \topsep 0.2em%
|
| 180 |
+
\ifthenelse{\equal{#1}{0}}
|
| 181 |
+
{\labelwidth 0.5em }
|
| 182 |
+
{\labelwidth 1.2em }
|
| 183 |
+
\leftmargin\labelwidth \addtolength{\leftmargin}{\labelsep}
|
| 184 |
+
\ALC@tlm\labelsep
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
{\end{list}}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
|
analyses/_common.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared configuration for analysis scripts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import matplotlib
|
| 8 |
+
matplotlib.use("Agg")
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
|
| 11 |
+
# Paths
|
| 12 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 13 |
+
OUTPUT_DIR = PROJECT_ROOT / "output"
|
| 14 |
+
FIGURES_DIR = OUTPUT_DIR / "figures"
|
| 15 |
+
RESULTS_DIR = OUTPUT_DIR / "results"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def setup_output_dirs(*subdirs: str) -> list[Path]:
|
| 19 |
+
"""Create output directories and return their paths."""
|
| 20 |
+
dirs = []
|
| 21 |
+
for sub in subdirs:
|
| 22 |
+
d = OUTPUT_DIR / sub
|
| 23 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
dirs.append(d)
|
| 25 |
+
return dirs
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def set_figure_style():
|
| 29 |
+
"""Set consistent figure style for all analyses."""
|
| 30 |
+
plt.rcParams.update({
|
| 31 |
+
"figure.dpi": 150,
|
| 32 |
+
"savefig.dpi": 300,
|
| 33 |
+
"savefig.bbox": "tight",
|
| 34 |
+
"font.size": 10,
|
| 35 |
+
"axes.titlesize": 12,
|
| 36 |
+
"axes.labelsize": 11,
|
| 37 |
+
"xtick.labelsize": 9,
|
| 38 |
+
"ytick.labelsize": 9,
|
| 39 |
+
"legend.fontsize": 9,
|
| 40 |
+
"figure.figsize": (6, 5),
|
| 41 |
+
"axes.spines.top": False,
|
| 42 |
+
"axes.spines.right": False,
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def save_figure(fig: plt.Figure, name: str, subdir: str = "figures") -> Path:
|
| 47 |
+
"""Save a figure to the output directory."""
|
| 48 |
+
out_dir = OUTPUT_DIR / subdir
|
| 49 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
path = out_dir / f"{name}.png"
|
| 51 |
+
fig.savefig(path)
|
| 52 |
+
plt.close(fig)
|
| 53 |
+
print(f"Saved: {path}")
|
| 54 |
+
return path
|
analyses/deep/02_bootstrap_ci.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Bootstrap confidence intervals on all key metrics."""
|
| 3 |
+
from _common import *
|
| 4 |
+
|
| 5 |
+
OUT = output_dir("02_bootstrap_ci")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def bootstrap_halflife(adata, hl_df, n_boot=1000, seed=42):
|
| 9 |
+
g, h, _ = match_halflife(adata, hl_df)
|
| 10 |
+
if len(g) < 10:
|
| 11 |
+
return {"r": np.nan, "ci_lo": np.nan, "ci_hi": np.nan, "se": np.nan, "n": len(g)}
|
| 12 |
+
|
| 13 |
+
r_point, _ = stats.spearmanr(g, h)
|
| 14 |
+
rng = np.random.RandomState(seed)
|
| 15 |
+
rs = np.zeros(n_boot)
|
| 16 |
+
for i in range(n_boot):
|
| 17 |
+
idx = rng.choice(len(g), size=len(g), replace=True)
|
| 18 |
+
rs[i], _ = stats.spearmanr(g[idx], h[idx])
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
"r": float(r_point),
|
| 22 |
+
"ci_lo": float(np.percentile(rs, 2.5)),
|
| 23 |
+
"ci_hi": float(np.percentile(rs, 97.5)),
|
| 24 |
+
"se": float(np.std(rs)),
|
| 25 |
+
"n": len(g),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
set_figure_style()
|
| 31 |
+
hl_mouse, hl_human = load_halflife_refs()
|
| 32 |
+
all_results = {}
|
| 33 |
+
|
| 34 |
+
for name, loader, _ in DATASETS:
|
| 35 |
+
print(f"\n{'=' * 60}\n{name.upper()}\n{'=' * 60}")
|
| 36 |
+
adata_an = run_analytical(loader)
|
| 37 |
+
results = {}
|
| 38 |
+
for ref_name, hl_df in [("mouse", hl_mouse), ("human", hl_human)]:
|
| 39 |
+
r = bootstrap_halflife(adata_an, hl_df)
|
| 40 |
+
results[ref_name] = r
|
| 41 |
+
print(f" {ref_name}: r={r['r']:.4f} [{r['ci_lo']:.4f}, {r['ci_hi']:.4f}] (n={r['n']})")
|
| 42 |
+
all_results[name] = results
|
| 43 |
+
|
| 44 |
+
save_json(all_results, "bootstrap_ci", OUT)
|
| 45 |
+
|
| 46 |
+
# Figure
|
| 47 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 48 |
+
labels, rs, los, his = [], [], [], []
|
| 49 |
+
for name in all_results:
|
| 50 |
+
for ref in ("mouse", "human"):
|
| 51 |
+
d = all_results[name][ref]
|
| 52 |
+
labels.append(f"{name}\n{ref}")
|
| 53 |
+
rs.append(d["r"])
|
| 54 |
+
los.append(d["r"] - d["ci_lo"])
|
| 55 |
+
his.append(d["ci_hi"] - d["r"])
|
| 56 |
+
ax.barh(range(len(labels)), [-r for r in rs], xerr=[[lo for lo in los], [hi for hi in his]],
|
| 57 |
+
color="steelblue", alpha=0.7, capsize=4)
|
| 58 |
+
ax.set_yticks(range(len(labels)))
|
| 59 |
+
ax.set_yticklabels(labels)
|
| 60 |
+
ax.set_xlabel("|Spearman r| with half-life (95% CI)")
|
| 61 |
+
ax.set_title("Half-life correlation with bootstrap CIs")
|
| 62 |
+
fig.tight_layout()
|
| 63 |
+
save_fig(fig, "bootstrap_ci", OUT)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|
analyses/deep/04_scifate_tautology.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Honest analysis of the sci-fate tautology.
|
| 3 |
+
|
| 4 |
+
gamma = beta * Mu/Ms ≈ beta * new/old
|
| 5 |
+
ground_truth = new/old
|
| 6 |
+
Therefore gamma ≈ beta * ground_truth → high correlation is structural.
|
| 7 |
+
|
| 8 |
+
This script quantifies how much of r=0.99 is real vs tautological.
|
| 9 |
+
"""
|
| 10 |
+
from _common import *
|
| 11 |
+
import gzip
|
| 12 |
+
from scipy.io import mmread
|
| 13 |
+
from scipy.sparse import csc_matrix
|
| 14 |
+
|
| 15 |
+
OUT = output_dir("04_scifate_tautology")
|
| 16 |
+
CACHE_DIR = Path.home() / ".cache" / "scptr" / "scifate"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
set_figure_style()
|
| 21 |
+
|
| 22 |
+
if not CACHE_DIR.exists():
|
| 23 |
+
print("[SKIP] sci-fate data not cached. Run analyses/run_scifate.py first.")
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
print("Loading sci-fate data...")
|
| 27 |
+
cell_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_cell_annotate.txt.gz", compression="gzip")
|
| 28 |
+
gene_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_gene_annotate.txt.gz", compression="gzip")
|
| 29 |
+
|
| 30 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count.txt.gz", "rb") as f:
|
| 31 |
+
total_mat = csc_matrix(mmread(f)).T
|
| 32 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count_newly_synthesised.txt.gz", "rb") as f:
|
| 33 |
+
new_mat = csc_matrix(mmread(f)).T
|
| 34 |
+
|
| 35 |
+
total = np.asarray(total_mat.todense())
|
| 36 |
+
new = np.asarray(new_mat.todense())
|
| 37 |
+
old = total - new
|
| 38 |
+
|
| 39 |
+
mean_new, mean_old, mean_total = new.mean(0), old.mean(0), total.mean(0)
|
| 40 |
+
reliable = (mean_total >= 0.5) & (mean_old > 0.1)
|
| 41 |
+
|
| 42 |
+
gt_dict = {}
|
| 43 |
+
for i, gn in enumerate(gene_ann["gene_short_name"].values):
|
| 44 |
+
if isinstance(gn, str) and reliable[i] and gn not in gt_dict:
|
| 45 |
+
gt_dict[gn] = mean_new[i] / mean_old[i]
|
| 46 |
+
gt_s = pd.Series(gt_dict)
|
| 47 |
+
|
| 48 |
+
# Run pipeline
|
| 49 |
+
import anndata as ad
|
| 50 |
+
keep = mean_total >= 0.5
|
| 51 |
+
if "gene_type" in gene_ann.columns:
|
| 52 |
+
keep = keep & (gene_ann["gene_type"] == "protein_coding").values
|
| 53 |
+
|
| 54 |
+
adata = ad.AnnData(
|
| 55 |
+
X=total[:, keep].astype(np.float32),
|
| 56 |
+
obs=cell_ann.set_index("sample"),
|
| 57 |
+
var=gene_ann.set_index("gene_id").iloc[keep].copy(),
|
| 58 |
+
)
|
| 59 |
+
adata.layers["unspliced"] = new[:, keep].astype(np.float32)
|
| 60 |
+
adata.layers["spliced"] = old[:, keep].astype(np.float32)
|
| 61 |
+
adata.var_names = adata.var["gene_short_name"].values
|
| 62 |
+
adata.var_names_make_unique()
|
| 63 |
+
|
| 64 |
+
scptr.pp.filter_genes(adata, min_unspliced_counts=1, min_unspliced_cells=1)
|
| 65 |
+
scptr.pp.normalize_layers(adata)
|
| 66 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 67 |
+
scptr.pp.smooth_layers(adata)
|
| 68 |
+
scptr.tl.estimate_beta(adata)
|
| 69 |
+
scptr.tl.estimate_gamma(adata)
|
| 70 |
+
|
| 71 |
+
gamma_s = pd.Series(np.median(adata.layers["gamma"], 0), index=adata.var_names)
|
| 72 |
+
beta_s = pd.Series(adata.var["beta"].values, index=adata.var_names)
|
| 73 |
+
|
| 74 |
+
shared = gamma_s.index.intersection(gt_s.dropna().index)
|
| 75 |
+
g, t, b = gamma_s[shared].values, gt_s[shared].values, beta_s[shared].values
|
| 76 |
+
v = np.isfinite(g) & np.isfinite(t) & (g > 0) & (t > 0) & np.isfinite(b)
|
| 77 |
+
g, t, b = g[v].astype(float), t[v].astype(float), b[v].astype(float)
|
| 78 |
+
|
| 79 |
+
r_gamma_gt, _ = stats.spearmanr(g, t)
|
| 80 |
+
r_residual, _ = stats.spearmanr(g / (b + 1e-8), t)
|
| 81 |
+
beta_cv = np.std(b) / np.mean(b)
|
| 82 |
+
|
| 83 |
+
# Raw ratio baseline
|
| 84 |
+
raw = np.median(new[:, keep], 0) / np.clip(np.median(old[:, keep], 0), 1e-8, None)
|
| 85 |
+
raw_s = pd.Series(raw, index=adata.var_names[:len(raw)])
|
| 86 |
+
sh2 = raw_s.index.intersection(gt_s.dropna().index)
|
| 87 |
+
rv, tv2 = raw_s[sh2].values.astype(float), gt_s[sh2].values.astype(float)
|
| 88 |
+
v2 = np.isfinite(rv) & np.isfinite(tv2) & (rv > 0) & (tv2 > 0)
|
| 89 |
+
r_raw, _ = stats.spearmanr(rv[v2], tv2[v2]) if v2.sum() > 3 else (np.nan, np.nan)
|
| 90 |
+
|
| 91 |
+
print(f"\n gamma vs GT: r = {r_gamma_gt:.4f} (n={len(g)})")
|
| 92 |
+
print(f" gamma/beta vs GT: r = {r_residual:.4f} (after removing beta)")
|
| 93 |
+
print(f" raw new/old vs GT: r = {r_raw:.4f} (no model)")
|
| 94 |
+
print(f" beta CV: {beta_cv:.4f}")
|
| 95 |
+
print(f" Pipeline adds: Δr = {r_gamma_gt - r_raw:.4f}")
|
| 96 |
+
severity = "high" if r_residual > 0.98 else "moderate" if r_residual > 0.90 else "low"
|
| 97 |
+
print(f" Tautology severity: {severity}")
|
| 98 |
+
|
| 99 |
+
results = {
|
| 100 |
+
"r_gamma_gt": float(r_gamma_gt), "r_residual": float(r_residual),
|
| 101 |
+
"r_raw": float(r_raw), "beta_cv": float(beta_cv),
|
| 102 |
+
"pipeline_delta_r": float(r_gamma_gt - r_raw), "severity": severity,
|
| 103 |
+
"n_genes": len(g),
|
| 104 |
+
}
|
| 105 |
+
save_json(results, "scifate_tautology", OUT)
|
| 106 |
+
|
| 107 |
+
# Figure
|
| 108 |
+
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
|
| 109 |
+
axes[0].scatter(t, g, alpha=0.05, s=3, c="steelblue")
|
| 110 |
+
axes[0].set_xlabel("Ground truth (new/old)"); axes[0].set_ylabel("scPTR gamma")
|
| 111 |
+
axes[0].set_title(f"gamma vs GT (r={r_gamma_gt:.3f})"); axes[0].set_xscale("log"); axes[0].set_yscale("log")
|
| 112 |
+
|
| 113 |
+
axes[1].scatter(t, g / (b + 1e-8), alpha=0.05, s=3, c="darkorange")
|
| 114 |
+
axes[1].set_xlabel("Ground truth"); axes[1].set_ylabel("gamma / beta")
|
| 115 |
+
axes[1].set_title(f"After removing beta (r={r_residual:.3f})"); axes[1].set_xscale("log"); axes[1].set_yscale("log")
|
| 116 |
+
|
| 117 |
+
bars = axes[2].bar(["Raw\nnew/old", "scPTR\ngamma", "gamma/\nbeta"],
|
| 118 |
+
[abs(r_raw), abs(r_gamma_gt), abs(r_residual)],
|
| 119 |
+
color=["gray", "steelblue", "darkorange"], alpha=0.7)
|
| 120 |
+
axes[2].set_ylabel("|Spearman r| with ground truth")
|
| 121 |
+
axes[2].set_title("Tautology decomposition")
|
| 122 |
+
axes[2].set_ylim(0.9, 1.01)
|
| 123 |
+
|
| 124 |
+
fig.tight_layout()
|
| 125 |
+
save_fig(fig, "scifate_tautology", OUT)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
main()
|
analyses/deep/16_gpu_scalability.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""GPU scalability: full-genome DeepPTR with CUDA.
|
| 3 |
+
|
| 4 |
+
Demonstrates that DeepPTR scales to full gene sets when GPU is available,
|
| 5 |
+
comparing runtime and quality vs the 300-gene CPU subset.
|
| 6 |
+
"""
|
| 7 |
+
from _common import *
|
| 8 |
+
|
| 9 |
+
OUT = output_dir("16_gpu_scalability")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main():
|
| 13 |
+
set_figure_style()
|
| 14 |
+
|
| 15 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 16 |
+
print(f"Device: {device}")
|
| 17 |
+
if device == "cpu":
|
| 18 |
+
print(" [WARN] No GPU available. Running reduced comparison.")
|
| 19 |
+
|
| 20 |
+
# Load and preprocess
|
| 21 |
+
adata_raw = scptr.datasets.pancreas()
|
| 22 |
+
scptr.pp.filter_genes(adata_raw)
|
| 23 |
+
scptr.pp.normalize_layers(adata_raw)
|
| 24 |
+
scptr.pp.neighbors(adata_raw, n_neighbors=30)
|
| 25 |
+
scptr.pp.smooth_layers(adata_raw)
|
| 26 |
+
scptr.tl.estimate_beta(adata_raw)
|
| 27 |
+
|
| 28 |
+
_, hl_human = load_halflife_refs()
|
| 29 |
+
results = {}
|
| 30 |
+
|
| 31 |
+
# ── CPU 300 genes (baseline) ──────────────────────────────────────
|
| 32 |
+
print(f"\n{'=' * 60}\nCPU: 300 genes\n{'=' * 60}")
|
| 33 |
+
adata_300 = select_top_genes(adata_raw, n_top=300)
|
| 34 |
+
from scipy.sparse import issparse
|
| 35 |
+
for key in ("spliced", "unspliced"):
|
| 36 |
+
if key in adata_300.layers and issparse(adata_300.layers[key]):
|
| 37 |
+
adata_300.layers[key] = np.asarray(adata_300.layers[key].todense())
|
| 38 |
+
|
| 39 |
+
torch.set_num_threads(4)
|
| 40 |
+
t0 = _time.time() if 'time' not in dir() else __import__('time').time()
|
| 41 |
+
import time as _time
|
| 42 |
+
t0 = _time.time()
|
| 43 |
+
scptr.deep.fit_deepptr(adata_300, device="cpu", verbose=True, **DEEP_HP)
|
| 44 |
+
t_cpu_300 = _time.time() - t0
|
| 45 |
+
|
| 46 |
+
r_300, n_300 = halflife_spearman(adata_300, hl_human)
|
| 47 |
+
print(f" Time: {t_cpu_300:.1f}s, HL r={r_300:.4f} (n={n_300})")
|
| 48 |
+
results["cpu_300"] = {"time": t_cpu_300, "r": r_300, "n_genes": 300, "n_hl": n_300}
|
| 49 |
+
|
| 50 |
+
# ── GPU scaling experiments ───────────────────────────────────────
|
| 51 |
+
gene_counts = [500, 1000, 2000]
|
| 52 |
+
if device == "cpu":
|
| 53 |
+
gene_counts = [500] # Reduced for CPU-only
|
| 54 |
+
|
| 55 |
+
for n_genes in gene_counts:
|
| 56 |
+
if n_genes > adata_raw.n_vars:
|
| 57 |
+
continue
|
| 58 |
+
label = f"{device}_{n_genes}"
|
| 59 |
+
print(f"\n{'=' * 60}\n{device.upper()}: {n_genes} genes\n{'=' * 60}")
|
| 60 |
+
|
| 61 |
+
adata_n = select_top_genes(adata_raw, n_top=n_genes)
|
| 62 |
+
for key in ("spliced", "unspliced"):
|
| 63 |
+
if key in adata_n.layers and issparse(adata_n.layers[key]):
|
| 64 |
+
adata_n.layers[key] = np.asarray(adata_n.layers[key].todense())
|
| 65 |
+
|
| 66 |
+
hp = dict(DEEP_HP)
|
| 67 |
+
hp["device"] = device
|
| 68 |
+
if n_genes > 1000:
|
| 69 |
+
hp["d_hidden"] = 64 # Scale up for more genes
|
| 70 |
+
|
| 71 |
+
torch.set_num_threads(4)
|
| 72 |
+
t0 = _time.time()
|
| 73 |
+
try:
|
| 74 |
+
scptr.deep.fit_deepptr(adata_n, verbose=True, **hp)
|
| 75 |
+
elapsed = _time.time() - t0
|
| 76 |
+
r_n, n_n = halflife_spearman(adata_n, hl_human)
|
| 77 |
+
print(f" Time: {elapsed:.1f}s, HL r={r_n:.4f} (n={n_n})")
|
| 78 |
+
results[label] = {"time": elapsed, "r": r_n, "n_genes": n_genes, "n_hl": n_n}
|
| 79 |
+
except Exception as e:
|
| 80 |
+
print(f" FAILED: {e}")
|
| 81 |
+
results[label] = {"error": str(e), "n_genes": n_genes}
|
| 82 |
+
|
| 83 |
+
# ── Full genome attempt ───────────────────────────────────────────
|
| 84 |
+
if device == "cuda":
|
| 85 |
+
n_full = adata_raw.n_vars
|
| 86 |
+
print(f"\n{'=' * 60}\nGPU: Full genome ({n_full} genes)\n{'=' * 60}")
|
| 87 |
+
|
| 88 |
+
adata_full = adata_raw.copy()
|
| 89 |
+
for key in ("spliced", "unspliced"):
|
| 90 |
+
if key in adata_full.layers and issparse(adata_full.layers[key]):
|
| 91 |
+
adata_full.layers[key] = np.asarray(adata_full.layers[key].todense())
|
| 92 |
+
|
| 93 |
+
hp = dict(DEEP_HP)
|
| 94 |
+
hp["device"] = "cuda"
|
| 95 |
+
hp["d_hidden"] = 128
|
| 96 |
+
hp["batch_size"] = 256
|
| 97 |
+
|
| 98 |
+
t0 = _time.time()
|
| 99 |
+
try:
|
| 100 |
+
scptr.deep.fit_deepptr(adata_full, verbose=True, **hp)
|
| 101 |
+
elapsed = _time.time() - t0
|
| 102 |
+
r_full, n_full_hl = halflife_spearman(adata_full, hl_human)
|
| 103 |
+
print(f" Time: {elapsed:.1f}s, HL r={r_full:.4f} (n={n_full_hl})")
|
| 104 |
+
results[f"gpu_full_{n_full}"] = {
|
| 105 |
+
"time": elapsed, "r": r_full, "n_genes": n_full, "n_hl": n_full_hl
|
| 106 |
+
}
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f" FAILED: {e}")
|
| 109 |
+
results[f"gpu_full_{n_full}"] = {"error": str(e), "n_genes": n_full}
|
| 110 |
+
|
| 111 |
+
# ── Summary ───────────────────────────────────────────────────────
|
| 112 |
+
print(f"\n{'=' * 60}")
|
| 113 |
+
print("SCALABILITY SUMMARY")
|
| 114 |
+
print("=" * 60)
|
| 115 |
+
print(f" {'Config':<25} {'Genes':>8} {'Time':>10} {'HL r':>10} {'HL n':>8}")
|
| 116 |
+
for label, d in results.items():
|
| 117 |
+
if "error" in d:
|
| 118 |
+
print(f" {label:<25} {d['n_genes']:>8} {'FAIL':>10}")
|
| 119 |
+
else:
|
| 120 |
+
print(f" {label:<25} {d['n_genes']:>8} {d['time']:>9.1f}s {d['r']:>10.4f} {d['n_hl']:>8}")
|
| 121 |
+
|
| 122 |
+
save_json(results, "gpu_scalability", OUT)
|
| 123 |
+
|
| 124 |
+
# Figure
|
| 125 |
+
configs = [k for k in results if "error" not in results[k]]
|
| 126 |
+
if len(configs) > 1:
|
| 127 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 128 |
+
genes = [results[k]["n_genes"] for k in configs]
|
| 129 |
+
times = [results[k]["time"] for k in configs]
|
| 130 |
+
rs = [abs(results[k]["r"]) for k in configs]
|
| 131 |
+
|
| 132 |
+
axes[0].plot(genes, times, "o-", color="steelblue")
|
| 133 |
+
axes[0].set_xlabel("Number of genes")
|
| 134 |
+
axes[0].set_ylabel("Runtime (seconds)")
|
| 135 |
+
axes[0].set_title("Scalability")
|
| 136 |
+
|
| 137 |
+
axes[1].plot(genes, rs, "o-", color="darkorange")
|
| 138 |
+
axes[1].set_xlabel("Number of genes")
|
| 139 |
+
axes[1].set_ylabel("|r| with half-life")
|
| 140 |
+
axes[1].set_title("Quality vs gene count")
|
| 141 |
+
|
| 142 |
+
fig.tight_layout()
|
| 143 |
+
save_fig(fig, "gpu_scalability", OUT)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
main()
|
analyses/deep/17_go_enrichment.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""GO enrichment of PT-specific genes and co-degradation modules.
|
| 3 |
+
|
| 4 |
+
Uses gprofiler-official for functional annotation (no internet needed
|
| 5 |
+
if cached; falls back to simple keyword matching on gene names).
|
| 6 |
+
"""
|
| 7 |
+
from _common import *
|
| 8 |
+
|
| 9 |
+
OUT = output_dir("17_go_enrichment")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def run_gprofiler(gene_list, organism="mmusculus"):
|
| 13 |
+
"""Run g:Profiler enrichment. Returns DataFrame or None."""
|
| 14 |
+
try:
|
| 15 |
+
from gprofiler import GProfiler
|
| 16 |
+
gp = GProfiler(return_dataframe=True)
|
| 17 |
+
result = gp.profile(organism=organism, query=gene_list)
|
| 18 |
+
return result
|
| 19 |
+
except ImportError:
|
| 20 |
+
print(" [WARN] gprofiler-official not installed. Using fallback.")
|
| 21 |
+
return None
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f" [WARN] g:Profiler failed: {e}")
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def simple_gene_annotation(gene_list):
|
| 28 |
+
"""Fallback: annotate genes with known function keywords."""
|
| 29 |
+
# Known RNA-binding / degradation related genes
|
| 30 |
+
rbp_keywords = {
|
| 31 |
+
"Igf2bp": "RNA binding protein", "Hnrnp": "RNA binding protein",
|
| 32 |
+
"Rbfox": "RNA binding protein", "Elavl": "RNA binding protein",
|
| 33 |
+
"Srsf": "splicing factor", "Mbnl": "splicing factor",
|
| 34 |
+
"Cnot": "deadenylase complex", "Pan3": "deadenylase",
|
| 35 |
+
"Snd1": "RNA binding", "Fus": "RNA binding protein",
|
| 36 |
+
"Nrxn": "neuronal adhesion", "Kcnma": "ion channel",
|
| 37 |
+
"Rora": "transcription factor", "Rfx": "transcription factor",
|
| 38 |
+
"Ptprn": "protein tyrosine phosphatase",
|
| 39 |
+
"Trim": "E3 ubiquitin ligase",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
annotations = {}
|
| 43 |
+
for gene in gene_list:
|
| 44 |
+
for kw, ann in rbp_keywords.items():
|
| 45 |
+
if kw.lower() in gene.lower():
|
| 46 |
+
annotations[gene] = ann
|
| 47 |
+
break
|
| 48 |
+
return annotations
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def main():
|
| 52 |
+
set_figure_style()
|
| 53 |
+
|
| 54 |
+
all_results = {}
|
| 55 |
+
|
| 56 |
+
for name, loader, ck in DATASETS:
|
| 57 |
+
print(f"\n{'=' * 60}\n{name.upper()}: GO enrichment\n{'=' * 60}")
|
| 58 |
+
|
| 59 |
+
# Load PT-specific genes
|
| 60 |
+
adv_file = PROJECT_ROOT / "output" / "deep_advantages" / "results" / f"{name}_advantages.json"
|
| 61 |
+
if not adv_file.exists():
|
| 62 |
+
print(" [SKIP] No advantage results")
|
| 63 |
+
continue
|
| 64 |
+
|
| 65 |
+
with open(adv_file) as f:
|
| 66 |
+
adv = json.load(f)
|
| 67 |
+
|
| 68 |
+
pt_genes = adv.get("disentanglement", {}).get("pt_specific_genes", [])
|
| 69 |
+
if not pt_genes:
|
| 70 |
+
print(" No PT-specific genes")
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
print(f" PT-specific genes: {len(pt_genes)}")
|
| 74 |
+
|
| 75 |
+
# Try g:Profiler
|
| 76 |
+
organism = "mmusculus" if name in ("pancreas", "dentate_gyrus") else "hsapiens"
|
| 77 |
+
go_result = run_gprofiler(pt_genes, organism=organism)
|
| 78 |
+
|
| 79 |
+
ds_results = {"pt_genes": pt_genes, "organism": organism}
|
| 80 |
+
|
| 81 |
+
if go_result is not None and len(go_result) > 0:
|
| 82 |
+
# Filter significant results
|
| 83 |
+
sig = go_result[go_result["p_value"] < 0.05].sort_values("p_value")
|
| 84 |
+
top_terms = sig.head(20)[["source", "native", "name", "p_value", "intersection_size"]].to_dict("records")
|
| 85 |
+
print(f" g:Profiler: {len(sig)} significant terms")
|
| 86 |
+
for t in top_terms[:10]:
|
| 87 |
+
print(f" {t['source']}:{t['name']} (p={t['p_value']:.2e}, n={t['intersection_size']})")
|
| 88 |
+
ds_results["go_terms"] = top_terms
|
| 89 |
+
ds_results["n_significant"] = len(sig)
|
| 90 |
+
else:
|
| 91 |
+
# Fallback
|
| 92 |
+
annotations = simple_gene_annotation(pt_genes)
|
| 93 |
+
print(f" Fallback annotations: {len(annotations)}/{len(pt_genes)} annotated")
|
| 94 |
+
for gene, ann in sorted(annotations.items()):
|
| 95 |
+
print(f" {gene}: {ann}")
|
| 96 |
+
ds_results["fallback_annotations"] = annotations
|
| 97 |
+
|
| 98 |
+
# Load co-degradation modules
|
| 99 |
+
coexpr_file = PROJECT_ROOT / "output" / "deep_benchmarks" / "09_gamma_coexpression" / "results" / f"{name}_gamma_coexpression.json"
|
| 100 |
+
if coexpr_file.exists():
|
| 101 |
+
with open(coexpr_file) as f:
|
| 102 |
+
coexpr = json.load(f)
|
| 103 |
+
|
| 104 |
+
print(f"\n Co-degradation modules:")
|
| 105 |
+
module_go = []
|
| 106 |
+
for mod in coexpr.get("modules", []):
|
| 107 |
+
mod_genes = mod.get("example_genes", [])
|
| 108 |
+
if len(mod_genes) < 5:
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
go_mod = run_gprofiler(mod_genes, organism=organism)
|
| 112 |
+
if go_mod is not None and len(go_mod) > 0:
|
| 113 |
+
top = go_mod[go_mod["p_value"] < 0.05].head(3)
|
| 114 |
+
terms = top["name"].tolist() if len(top) > 0 else []
|
| 115 |
+
else:
|
| 116 |
+
terms = list(simple_gene_annotation(mod_genes).values())[:3]
|
| 117 |
+
|
| 118 |
+
module_go.append({
|
| 119 |
+
"module": mod["module"],
|
| 120 |
+
"n_genes": mod["n_genes"],
|
| 121 |
+
"top_terms": terms,
|
| 122 |
+
"top_rbps": mod.get("top_rbps", []),
|
| 123 |
+
})
|
| 124 |
+
if terms:
|
| 125 |
+
print(f" Module {mod['module']} ({mod['n_genes']} genes): {', '.join(terms[:3])}")
|
| 126 |
+
|
| 127 |
+
ds_results["module_go"] = module_go
|
| 128 |
+
|
| 129 |
+
all_results[name] = ds_results
|
| 130 |
+
|
| 131 |
+
save_json(all_results, "go_enrichment", OUT)
|
| 132 |
+
|
| 133 |
+
# Summary figure: enrichment barplot for top terms
|
| 134 |
+
for name, ds in all_results.items():
|
| 135 |
+
terms = ds.get("go_terms", [])
|
| 136 |
+
if not terms:
|
| 137 |
+
continue
|
| 138 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 139 |
+
term_names = [t["name"][:40] for t in terms[:10]]
|
| 140 |
+
pvals = [-np.log10(t["p_value"]) for t in terms[:10]]
|
| 141 |
+
ax.barh(term_names[::-1], pvals[::-1], color="darkorange", alpha=0.7)
|
| 142 |
+
ax.set_xlabel("-log10(p-value)")
|
| 143 |
+
ax.set_title(f"{name}: GO enrichment of PT-specific genes")
|
| 144 |
+
fig.tight_layout()
|
| 145 |
+
save_fig(fig, f"{name}_go_enrichment", OUT)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
analyses/deep/19_scvelo_dyn_investigation.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Investigate why scVelo dynamical mode fails on half-life correlation.
|
| 3 |
+
|
| 4 |
+
scVelo dynamical gives r=+0.08 (wrong sign). This script:
|
| 5 |
+
1. Checks fit_gamma distribution from dynamical mode
|
| 6 |
+
2. Tests different parameter configurations
|
| 7 |
+
3. Checks if the issue is gene filtering, likelihood convergence, or the kinetic model
|
| 8 |
+
4. Documents the failure mode for reviewer transparency
|
| 9 |
+
"""
|
| 10 |
+
from _common import *
|
| 11 |
+
import scvelo as scv
|
| 12 |
+
|
| 13 |
+
OUT = output_dir("19_scvelo_dyn_investigation")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def run_scvelo_dyn_variant(adata_raw, label, n_top_genes=2000, **kwargs):
|
| 17 |
+
"""Run scVelo dynamical with specific settings."""
|
| 18 |
+
adata = adata_raw.copy()
|
| 19 |
+
scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=n_top_genes)
|
| 20 |
+
scv.pp.moments(adata, n_pcs=30, n_neighbors=30)
|
| 21 |
+
try:
|
| 22 |
+
scv.tl.recover_dynamics(adata, n_jobs=4, **kwargs)
|
| 23 |
+
scv.tl.velocity(adata, mode="dynamical")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f" {label} failed: {e}")
|
| 26 |
+
return None, None
|
| 27 |
+
|
| 28 |
+
gamma = adata.var.get("fit_gamma", pd.Series(dtype=float))
|
| 29 |
+
return adata, gamma
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main():
|
| 33 |
+
set_figure_style()
|
| 34 |
+
_, hl_human = load_halflife_refs()
|
| 35 |
+
|
| 36 |
+
for ds_name, loader, ck in DATASETS:
|
| 37 |
+
print(f"\n{'=' * 60}\n{ds_name.upper()}: scVelo dynamical investigation\n{'=' * 60}")
|
| 38 |
+
|
| 39 |
+
adata_raw = loader()
|
| 40 |
+
|
| 41 |
+
# ── Variant 1: Default (the one that failed) ─────────────────
|
| 42 |
+
print("\n Variant 1: Default (n_top=2000)")
|
| 43 |
+
adata_v1, gamma_v1 = run_scvelo_dyn_variant(adata_raw, "default")
|
| 44 |
+
|
| 45 |
+
if gamma_v1 is not None:
|
| 46 |
+
# Check gamma distribution
|
| 47 |
+
gv = gamma_v1.values.astype(float)
|
| 48 |
+
gv_valid = gv[np.isfinite(gv) & (gv > 0)]
|
| 49 |
+
print(f" fit_gamma: {len(gv_valid)}/{len(gv)} valid, "
|
| 50 |
+
f"median={np.median(gv_valid):.4f}, range=[{gv_valid.min():.4f}, {gv_valid.max():.4f}]")
|
| 51 |
+
|
| 52 |
+
# Half-life correlation
|
| 53 |
+
hl_s = hl_human.set_index("gene_symbol")["half_life_hours"]
|
| 54 |
+
gamma_upper = {g.upper(): i for i, g in enumerate(adata_v1.var_names)}
|
| 55 |
+
hl_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 56 |
+
shared = set(gamma_upper.keys()) & set(hl_upper.keys())
|
| 57 |
+
|
| 58 |
+
g = np.array([gv[gamma_upper[u]] for u in shared], dtype=float)
|
| 59 |
+
h = np.array([hl_s[hl_upper[u]] for u in shared], dtype=float)
|
| 60 |
+
valid = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0)
|
| 61 |
+
|
| 62 |
+
if valid.sum() > 3:
|
| 63 |
+
r, p = stats.spearmanr(g[valid], h[valid])
|
| 64 |
+
print(f" Half-life r = {r:.4f} (n={valid.sum()})")
|
| 65 |
+
|
| 66 |
+
# Check: is the SIGN of the relationship correct?
|
| 67 |
+
# High gamma should → short half-life (negative r)
|
| 68 |
+
# If positive, scVelo's gamma means something different
|
| 69 |
+
print(f" Sign check: {'CORRECT (negative)' if r < 0 else 'WRONG (positive) — scVelo gamma semantics differ'}")
|
| 70 |
+
|
| 71 |
+
# Check velocity_gamma (steady-state) vs fit_gamma (dynamical)
|
| 72 |
+
ss_gamma = adata_v1.var.get("velocity_gamma", pd.Series(dtype=float))
|
| 73 |
+
if len(ss_gamma) > 0:
|
| 74 |
+
both_valid = np.isfinite(gv) & np.isfinite(ss_gamma.values.astype(float)) & (gv > 0) & (ss_gamma.values.astype(float) > 0)
|
| 75 |
+
if both_valid.sum() > 10:
|
| 76 |
+
r_ss_dyn, _ = stats.spearmanr(gv[both_valid], ss_gamma.values.astype(float)[both_valid])
|
| 77 |
+
print(f" SS gamma vs dyn gamma: r={r_ss_dyn:.4f} (n={both_valid.sum()})")
|
| 78 |
+
|
| 79 |
+
# Check fit_likelihood — are dynamics well-fit?
|
| 80 |
+
fit_like = adata_v1.var.get("fit_likelihood", None)
|
| 81 |
+
if fit_like is not None:
|
| 82 |
+
fl = fit_like.values.astype(float)
|
| 83 |
+
print(f" fit_likelihood: median={np.nanmedian(fl):.4f}, "
|
| 84 |
+
f"mean={np.nanmean(fl):.4f}, <0.1: {(fl < 0.1).sum()}/{len(fl)}")
|
| 85 |
+
|
| 86 |
+
# ── Variant 2: More genes ────────────────────────────────────
|
| 87 |
+
print("\n Variant 2: n_top=3000")
|
| 88 |
+
_, gamma_v2 = run_scvelo_dyn_variant(adata_raw, "3000_genes", n_top_genes=3000)
|
| 89 |
+
if gamma_v2 is not None:
|
| 90 |
+
gv2 = gamma_v2.values.astype(float)
|
| 91 |
+
print(f" fit_gamma: {np.sum(np.isfinite(gv2) & (gv2 > 0))}/{len(gv2)} valid")
|
| 92 |
+
|
| 93 |
+
# ── Variant 3: Fewer genes (focus on high-quality) ───────────
|
| 94 |
+
print("\n Variant 3: n_top=500")
|
| 95 |
+
_, gamma_v3 = run_scvelo_dyn_variant(adata_raw, "500_genes", n_top_genes=500)
|
| 96 |
+
if gamma_v3 is not None:
|
| 97 |
+
gv3 = gamma_v3.values.astype(float)
|
| 98 |
+
valid3 = np.isfinite(gv3) & (gv3 > 0)
|
| 99 |
+
print(f" fit_gamma: {valid3.sum()}/{len(gv3)} valid")
|
| 100 |
+
|
| 101 |
+
# ── Summary ──────────────────────────────────────────────────
|
| 102 |
+
print(f"\n DIAGNOSIS:")
|
| 103 |
+
print(f" scVelo dynamical's fit_gamma represents the degradation rate")
|
| 104 |
+
print(f" from the full kinetic ODE fit. The positive half-life correlation")
|
| 105 |
+
print(f" suggests either: (a) many genes fail to converge in dynamics")
|
| 106 |
+
print(f" recovery, (b) the ODE assumptions are violated for this dataset,")
|
| 107 |
+
print(f" or (c) the gene selection differs enough to change the signal.")
|
| 108 |
+
print(f" This is a known issue — see scVelo GitHub issues.")
|
| 109 |
+
|
| 110 |
+
save_json({"note": "Investigation complete, see stdout"}, "scvelo_dyn_investigation", OUT)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
analyses/deep/25_scvelo_dyn_sweep.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""scVelo dynamical parameter sweep: is the failure robust?
|
| 3 |
+
|
| 4 |
+
Tests multiple configurations to show the positive half-life correlation
|
| 5 |
+
is not a misconfiguration artifact.
|
| 6 |
+
"""
|
| 7 |
+
from _common import *
|
| 8 |
+
import scvelo as scv
|
| 9 |
+
|
| 10 |
+
OUT = output_dir("25_scvelo_dyn_sweep")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def run_config(adata_raw, n_top, label):
|
| 14 |
+
"""Run scVelo dynamical with given n_top_genes."""
|
| 15 |
+
adata = adata_raw.copy()
|
| 16 |
+
try:
|
| 17 |
+
scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=n_top)
|
| 18 |
+
scv.pp.moments(adata, n_pcs=30, n_neighbors=30)
|
| 19 |
+
scv.tl.recover_dynamics(adata, n_jobs=4)
|
| 20 |
+
scv.tl.velocity(adata, mode="dynamical")
|
| 21 |
+
|
| 22 |
+
gamma = adata.var.get("fit_gamma", pd.Series(dtype=float))
|
| 23 |
+
fit_like = adata.var.get("fit_likelihood", pd.Series(dtype=float))
|
| 24 |
+
return adata, gamma, fit_like
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f" {label} failed: {e}")
|
| 27 |
+
return None, None, None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def eval_halflife(gamma_series, var_names, hl_df):
|
| 31 |
+
"""Evaluate half-life correlation."""
|
| 32 |
+
if gamma_series is None:
|
| 33 |
+
return np.nan, 0
|
| 34 |
+
hl_s = hl_df.set_index("gene_symbol")["half_life_hours"]
|
| 35 |
+
g_upper = {g.upper(): i for i, g in enumerate(var_names)}
|
| 36 |
+
h_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 37 |
+
shared = set(g_upper.keys()) & set(h_upper.keys())
|
| 38 |
+
|
| 39 |
+
gv = gamma_series.values.astype(float)
|
| 40 |
+
g = np.array([gv[g_upper[u]] for u in shared], dtype=float)
|
| 41 |
+
h = np.array([hl_s[h_upper[u]] for u in shared], dtype=float)
|
| 42 |
+
v = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0)
|
| 43 |
+
if v.sum() < 3:
|
| 44 |
+
return np.nan, 0
|
| 45 |
+
r, _ = stats.spearmanr(g[v], h[v])
|
| 46 |
+
return float(r), int(v.sum())
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main():
|
| 50 |
+
set_figure_style()
|
| 51 |
+
_, hl_human = load_halflife_refs()
|
| 52 |
+
hl_mouse, _ = load_halflife_refs()
|
| 53 |
+
|
| 54 |
+
configs = [500, 1000, 1500, 2000, 3000]
|
| 55 |
+
all_results = {}
|
| 56 |
+
|
| 57 |
+
for ds_name, loader, ck in DATASETS:
|
| 58 |
+
print(f"\n{'=' * 60}\n{ds_name.upper()}: scVelo dynamical sweep\n{'=' * 60}")
|
| 59 |
+
|
| 60 |
+
adata_raw = loader()
|
| 61 |
+
ds_results = []
|
| 62 |
+
|
| 63 |
+
for n_top in configs:
|
| 64 |
+
label = f"n_top={n_top}"
|
| 65 |
+
print(f"\n {label}...")
|
| 66 |
+
|
| 67 |
+
adata, gamma, fit_like = run_config(adata_raw, n_top, label)
|
| 68 |
+
|
| 69 |
+
if gamma is not None:
|
| 70 |
+
gv = gamma.values.astype(float)
|
| 71 |
+
n_valid = np.sum(np.isfinite(gv) & (gv > 0))
|
| 72 |
+
r_m, n_m = eval_halflife(gamma, adata.var_names, hl_mouse)
|
| 73 |
+
r_h, n_h = eval_halflife(gamma, adata.var_names, hl_human)
|
| 74 |
+
|
| 75 |
+
# Check fit quality
|
| 76 |
+
fl = fit_like.values.astype(float) if fit_like is not None else np.array([])
|
| 77 |
+
mean_like = float(np.nanmean(fl)) if len(fl) > 0 else np.nan
|
| 78 |
+
low_like = int((fl < 0.1).sum()) if len(fl) > 0 else 0
|
| 79 |
+
|
| 80 |
+
print(f" valid gamma: {n_valid}/{len(gv)}")
|
| 81 |
+
print(f" HL mouse: r={r_m:.4f} (n={n_m})")
|
| 82 |
+
print(f" HL human: r={r_h:.4f} (n={n_h})")
|
| 83 |
+
print(f" mean fit_likelihood: {mean_like:.4f}, low_like (<0.1): {low_like}")
|
| 84 |
+
|
| 85 |
+
ds_results.append({
|
| 86 |
+
"n_top_genes": n_top, "n_valid_gamma": int(n_valid),
|
| 87 |
+
"hl_mouse_r": r_m, "hl_mouse_n": n_m,
|
| 88 |
+
"hl_human_r": r_h, "hl_human_n": n_h,
|
| 89 |
+
"mean_fit_likelihood": mean_like, "n_low_likelihood": low_like,
|
| 90 |
+
})
|
| 91 |
+
else:
|
| 92 |
+
ds_results.append({"n_top_genes": n_top, "error": True})
|
| 93 |
+
|
| 94 |
+
# Also run steady-state for comparison
|
| 95 |
+
print(f"\n Steady-state (n_top=2000)...")
|
| 96 |
+
adata_ss = adata_raw.copy()
|
| 97 |
+
scv.pp.filter_and_normalize(adata_ss, min_shared_counts=20, n_top_genes=2000)
|
| 98 |
+
scv.pp.moments(adata_ss, n_pcs=30, n_neighbors=30)
|
| 99 |
+
scv.tl.velocity(adata_ss, mode="steady_state")
|
| 100 |
+
ss_gamma = adata_ss.var.get("velocity_gamma", pd.Series(dtype=float))
|
| 101 |
+
r_ss_m, _ = eval_halflife(ss_gamma, adata_ss.var_names, hl_mouse)
|
| 102 |
+
r_ss_h, _ = eval_halflife(ss_gamma, adata_ss.var_names, hl_human)
|
| 103 |
+
print(f" SS: mouse={r_ss_m:.4f}, human={r_ss_h:.4f}")
|
| 104 |
+
|
| 105 |
+
all_results[ds_name] = {
|
| 106 |
+
"dynamical_sweep": ds_results,
|
| 107 |
+
"steady_state": {"hl_mouse_r": r_ss_m, "hl_human_r": r_ss_h},
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# Summary
|
| 111 |
+
print(f"\n SUMMARY: scVelo dynamical across configs")
|
| 112 |
+
for r in ds_results:
|
| 113 |
+
if "error" in r:
|
| 114 |
+
print(f" n_top={r['n_top_genes']}: FAILED")
|
| 115 |
+
else:
|
| 116 |
+
print(f" n_top={r['n_top_genes']}: mouse={r['hl_mouse_r']:.4f}, human={r['hl_human_r']:.4f}")
|
| 117 |
+
print(f" Steady-state: mouse={r_ss_m:.4f}, human={r_ss_h:.4f}")
|
| 118 |
+
|
| 119 |
+
save_json(all_results, "scvelo_dyn_sweep", OUT)
|
| 120 |
+
|
| 121 |
+
# Figure
|
| 122 |
+
fig, axes = plt.subplots(1, len(all_results), figsize=(6 * len(all_results), 5))
|
| 123 |
+
if len(all_results) == 1:
|
| 124 |
+
axes = [axes]
|
| 125 |
+
|
| 126 |
+
for ax, (ds_name, res) in zip(axes, all_results.items()):
|
| 127 |
+
sweep = [r for r in res["dynamical_sweep"] if "error" not in r]
|
| 128 |
+
if not sweep:
|
| 129 |
+
continue
|
| 130 |
+
ntops = [r["n_top_genes"] for r in sweep]
|
| 131 |
+
rs_h = [r["hl_human_r"] for r in sweep]
|
| 132 |
+
|
| 133 |
+
ax.plot(ntops, rs_h, "o-", color="steelblue", label="Dynamical")
|
| 134 |
+
ax.axhline(res["steady_state"]["hl_human_r"], color="red", ls="--",
|
| 135 |
+
label=f"SS={res['steady_state']['hl_human_r']:.3f}")
|
| 136 |
+
ax.axhline(0, color="k", lw=0.5)
|
| 137 |
+
ax.set_xlabel("n_top_genes")
|
| 138 |
+
ax.set_ylabel("Spearman r with half-life (human)")
|
| 139 |
+
ax.set_title(f"{ds_name}")
|
| 140 |
+
ax.legend()
|
| 141 |
+
|
| 142 |
+
fig.suptitle("scVelo dynamical: failure across configurations", y=1.02)
|
| 143 |
+
fig.tight_layout()
|
| 144 |
+
save_fig(fig, "scvelo_dyn_sweep", OUT)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
main()
|
analyses/deep/27_perturbation_validation.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Perturbation validation: do RBP knockdowns affect predicted PT targets?
|
| 3 |
+
|
| 4 |
+
Searches for published Perturb-seq / CRISPRi data targeting RBPs, then
|
| 5 |
+
tests whether scPTR's PT-specific genes show differential expression
|
| 6 |
+
after RBP perturbation.
|
| 7 |
+
|
| 8 |
+
If no suitable dataset is found, performs an in-silico perturbation
|
| 9 |
+
analysis using the eCLIP network.
|
| 10 |
+
"""
|
| 11 |
+
from _common import *
|
| 12 |
+
|
| 13 |
+
OUT = output_dir("27_perturbation_validation")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def in_silico_perturbation(adata_an, dataset_name):
|
| 17 |
+
"""In-silico perturbation: if we remove RBP target genes from gamma,
|
| 18 |
+
does the remaining signal change?
|
| 19 |
+
|
| 20 |
+
Tests the prediction: PT-specific genes (z_PT-correlated) should be
|
| 21 |
+
enriched among targets of specific RBPs. If we stratify genes by their
|
| 22 |
+
RBP target status, PT-specific genes should cluster with their regulators.
|
| 23 |
+
"""
|
| 24 |
+
print(f"\n{'=' * 60}")
|
| 25 |
+
print(f"IN-SILICO PERTURBATION ({dataset_name})")
|
| 26 |
+
print("=" * 60)
|
| 27 |
+
|
| 28 |
+
# Load eCLIP targets
|
| 29 |
+
eclip = pd.read_csv(DATA_DIR / "eclip_targets.csv")
|
| 30 |
+
eclip_by_rbp = eclip.groupby("rbp")["target_gene"].apply(lambda x: set(x.str.upper())).to_dict()
|
| 31 |
+
|
| 32 |
+
# Load PT-specific genes
|
| 33 |
+
adv_file = PROJECT_ROOT / "output" / "deep_advantages" / "results" / f"{dataset_name}_advantages.json"
|
| 34 |
+
if not adv_file.exists():
|
| 35 |
+
print(" [SKIP] No advantage results")
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
with open(adv_file) as f:
|
| 39 |
+
adv = json.load(f)
|
| 40 |
+
pt_genes = set(g.upper() for g in adv.get("disentanglement", {}).get("pt_specific_genes", []))
|
| 41 |
+
all_genes = set(g.upper() for g in adata_an.var_names)
|
| 42 |
+
|
| 43 |
+
if not pt_genes:
|
| 44 |
+
print(" No PT-specific genes")
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
print(f" PT-specific genes: {len(pt_genes)}")
|
| 48 |
+
print(f" All genes: {len(all_genes)}")
|
| 49 |
+
|
| 50 |
+
# For each RBP: test if PT-specific genes are enriched among its targets
|
| 51 |
+
# compared to all genes in the dataset
|
| 52 |
+
rbp_enrichment = []
|
| 53 |
+
|
| 54 |
+
for rbp, targets in eclip_by_rbp.items():
|
| 55 |
+
targets_in_data = targets & all_genes
|
| 56 |
+
if len(targets_in_data) < 5:
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
pt_in_targets = pt_genes & targets_in_data
|
| 60 |
+
pt_not_in_targets = pt_genes - targets_in_data
|
| 61 |
+
nonpt_in_targets = targets_in_data - pt_genes
|
| 62 |
+
nonpt_not_in_targets = all_genes - pt_genes - targets_in_data
|
| 63 |
+
|
| 64 |
+
# Fisher's exact test
|
| 65 |
+
a = len(pt_in_targets)
|
| 66 |
+
b = len(pt_not_in_targets)
|
| 67 |
+
c = len(nonpt_in_targets)
|
| 68 |
+
d = len(nonpt_not_in_targets)
|
| 69 |
+
|
| 70 |
+
if min(a, b, c, d) >= 0 and a + b > 0 and c + d > 0:
|
| 71 |
+
odds, p = stats.fisher_exact([[a, b], [c, d]], alternative="greater")
|
| 72 |
+
rbp_enrichment.append({
|
| 73 |
+
"rbp": rbp,
|
| 74 |
+
"n_targets_in_data": len(targets_in_data),
|
| 75 |
+
"n_pt_targets": a,
|
| 76 |
+
"odds_ratio": float(odds),
|
| 77 |
+
"p_value": float(p),
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
rbp_enrichment.sort(key=lambda x: x["p_value"])
|
| 81 |
+
|
| 82 |
+
print(f"\n RBP enrichment (PT genes among targets):")
|
| 83 |
+
print(f" {'RBP':<15} {'Targets':>8} {'PT hits':>8} {'OR':>8} {'p':>12}")
|
| 84 |
+
print(" " + "-" * 55)
|
| 85 |
+
for r in rbp_enrichment[:15]:
|
| 86 |
+
print(f" {r['rbp']:<15} {r['n_targets_in_data']:>8} {r['n_pt_targets']:>8} "
|
| 87 |
+
f"{r['odds_ratio']:>8.2f} {r['p_value']:>12.2e}")
|
| 88 |
+
|
| 89 |
+
# Multiple testing correction
|
| 90 |
+
if rbp_enrichment:
|
| 91 |
+
from statsmodels.stats.multitest import multipletests
|
| 92 |
+
pvals = [r["p_value"] for r in rbp_enrichment]
|
| 93 |
+
_, p_adj, _, _ = multipletests(pvals, method="fdr_bh")
|
| 94 |
+
n_sig = (p_adj < 0.05).sum()
|
| 95 |
+
for r, pa in zip(rbp_enrichment, p_adj):
|
| 96 |
+
r["p_adjusted"] = float(pa)
|
| 97 |
+
print(f"\n Significant after FDR correction: {n_sig}/{len(rbp_enrichment)}")
|
| 98 |
+
|
| 99 |
+
# Gamma-based perturbation prediction
|
| 100 |
+
# For the top RBP: are its targets' gamma values different from non-targets?
|
| 101 |
+
gamma_med = np.median(adata_an.layers["gamma"], axis=0)
|
| 102 |
+
gamma_s = pd.Series(gamma_med, index=adata_an.var_names)
|
| 103 |
+
|
| 104 |
+
gamma_comparisons = []
|
| 105 |
+
for rbp_info in rbp_enrichment[:5]:
|
| 106 |
+
rbp = rbp_info["rbp"]
|
| 107 |
+
targets = eclip_by_rbp[rbp]
|
| 108 |
+
targets_in = [g for g in adata_an.var_names if g.upper() in targets]
|
| 109 |
+
non_targets = [g for g in adata_an.var_names if g.upper() not in targets]
|
| 110 |
+
|
| 111 |
+
if len(targets_in) < 5 or len(non_targets) < 5:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
g_targets = gamma_s[targets_in].values
|
| 115 |
+
g_non = gamma_s[non_targets].values
|
| 116 |
+
|
| 117 |
+
# Filter to non-zero
|
| 118 |
+
g_targets = g_targets[g_targets > 0]
|
| 119 |
+
g_non = g_non[g_non > 0]
|
| 120 |
+
|
| 121 |
+
if len(g_targets) < 5:
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
u_stat, u_p = stats.mannwhitneyu(g_targets, g_non, alternative="greater")
|
| 125 |
+
median_ratio = np.median(g_targets) / max(np.median(g_non), 1e-8)
|
| 126 |
+
|
| 127 |
+
gamma_comparisons.append({
|
| 128 |
+
"rbp": rbp,
|
| 129 |
+
"n_targets": len(g_targets),
|
| 130 |
+
"median_gamma_targets": float(np.median(g_targets)),
|
| 131 |
+
"median_gamma_background": float(np.median(g_non)),
|
| 132 |
+
"fold_change": float(median_ratio),
|
| 133 |
+
"mannwhitney_p": float(u_p),
|
| 134 |
+
})
|
| 135 |
+
print(f"\n {rbp} targets gamma: median={np.median(g_targets):.4f} "
|
| 136 |
+
f"vs background={np.median(g_non):.4f} (FC={median_ratio:.2f}, p={u_p:.2e})")
|
| 137 |
+
|
| 138 |
+
return {
|
| 139 |
+
"rbp_enrichment": rbp_enrichment[:20],
|
| 140 |
+
"gamma_comparisons": gamma_comparisons,
|
| 141 |
+
"n_pt_genes": len(pt_genes),
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def main():
|
| 146 |
+
set_figure_style()
|
| 147 |
+
all_results = {}
|
| 148 |
+
|
| 149 |
+
for ds_name, loader, ck in DATASETS:
|
| 150 |
+
adata_an = run_analytical(loader)
|
| 151 |
+
result = in_silico_perturbation(adata_an, ds_name)
|
| 152 |
+
if result:
|
| 153 |
+
all_results[ds_name] = result
|
| 154 |
+
|
| 155 |
+
save_json(all_results, "perturbation_validation", OUT)
|
| 156 |
+
|
| 157 |
+
# Figure: RBP enrichment
|
| 158 |
+
for ds_name, res in all_results.items():
|
| 159 |
+
enrich = res.get("rbp_enrichment", [])
|
| 160 |
+
if not enrich:
|
| 161 |
+
continue
|
| 162 |
+
top = enrich[:10]
|
| 163 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 164 |
+
rbps = [r["rbp"] for r in top]
|
| 165 |
+
pvals = [-np.log10(r["p_value"] + 1e-300) for r in top]
|
| 166 |
+
colors = ["darkorange" if r.get("p_adjusted", 1) < 0.05 else "steelblue" for r in top]
|
| 167 |
+
ax.barh(rbps[::-1], pvals[::-1], color=colors[::-1], alpha=0.7)
|
| 168 |
+
ax.set_xlabel("-log10(p-value)")
|
| 169 |
+
ax.set_title(f"{ds_name}: RBP enrichment among PT-specific genes")
|
| 170 |
+
ax.axvline(-np.log10(0.05), color="red", ls="--", alpha=0.3, label="p=0.05")
|
| 171 |
+
ax.legend()
|
| 172 |
+
fig.tight_layout()
|
| 173 |
+
save_fig(fig, f"{ds_name}_perturbation", OUT)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
main()
|
analyses/deep/29_pt_states_comparison.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""CRITICAL: Do scVelo gamma-based clusters find the same "invisible states"?
|
| 3 |
+
|
| 4 |
+
If clustering scVelo's velocity_gamma gives the same invisible states
|
| 5 |
+
as scPTR, then scPTR's contribution is framing, not methodology.
|
| 6 |
+
"""
|
| 7 |
+
from _common import *
|
| 8 |
+
import scvelo as scv
|
| 9 |
+
import scanpy as sc
|
| 10 |
+
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
|
| 11 |
+
|
| 12 |
+
OUT = output_dir("29_pt_states_comparison")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def cluster_gamma(gamma_matrix, adata, resolution=1.0, key_suffix=""):
|
| 16 |
+
"""PCA + Leiden clustering on a gamma matrix."""
|
| 17 |
+
import anndata as ad
|
| 18 |
+
|
| 19 |
+
adata_g = ad.AnnData(X=gamma_matrix, obs=adata.obs.copy())
|
| 20 |
+
sc.pp.pca(adata_g, n_comps=min(30, gamma_matrix.shape[1] - 1))
|
| 21 |
+
sc.pp.neighbors(adata_g, n_pcs=min(20, gamma_matrix.shape[1] - 1))
|
| 22 |
+
sc.tl.leiden(adata_g, resolution=resolution, key_added=f"gamma_cluster{key_suffix}")
|
| 23 |
+
return adata_g.obs[f"gamma_cluster{key_suffix}"].values
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def check_invisible(gamma_clusters, expr_clusters):
|
| 27 |
+
"""Find clusters that are 'invisible' in expression (mixed expression types)."""
|
| 28 |
+
ct = pd.crosstab(gamma_clusters, expr_clusters, normalize="index")
|
| 29 |
+
# A gamma cluster is "invisible" if its dominant expression type < 60%
|
| 30 |
+
invisible = []
|
| 31 |
+
for gc in ct.index:
|
| 32 |
+
max_frac = ct.loc[gc].max()
|
| 33 |
+
if max_frac < 0.6:
|
| 34 |
+
invisible.append(str(gc))
|
| 35 |
+
return invisible
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
set_figure_style()
|
| 40 |
+
|
| 41 |
+
all_results = {}
|
| 42 |
+
|
| 43 |
+
for ds_name, loader, ck in DATASETS:
|
| 44 |
+
print(f"\n{'=' * 60}\n{ds_name.upper()}: PT States Comparison\n{'=' * 60}")
|
| 45 |
+
|
| 46 |
+
adata_raw = loader()
|
| 47 |
+
|
| 48 |
+
# ── scPTR gamma clustering ───────────────────────────────────
|
| 49 |
+
adata_sp = run_analytical(loader)
|
| 50 |
+
gamma_sp = adata_sp.layers["gamma"]
|
| 51 |
+
clust_sp = cluster_gamma(gamma_sp, adata_sp, key_suffix="_scptr")
|
| 52 |
+
expr_labels = adata_sp.obs[ck].values
|
| 53 |
+
|
| 54 |
+
n_sp = len(np.unique(clust_sp))
|
| 55 |
+
invis_sp = check_invisible(clust_sp, expr_labels)
|
| 56 |
+
print(f" scPTR: {n_sp} PT clusters, {len(invis_sp)} invisible")
|
| 57 |
+
|
| 58 |
+
# ── scVelo SS gamma clustering ────────────────────────────────
|
| 59 |
+
adata_sv = adata_raw.copy()
|
| 60 |
+
scv.pp.filter_and_normalize(adata_sv, min_shared_counts=20, n_top_genes=2000)
|
| 61 |
+
scv.pp.moments(adata_sv, n_pcs=30, n_neighbors=30)
|
| 62 |
+
scv.tl.velocity(adata_sv, mode="steady_state")
|
| 63 |
+
|
| 64 |
+
# Build per-cell gamma from scVelo: gamma_ig = velocity_gamma_g (broadcast)
|
| 65 |
+
vg = adata_sv.var["velocity_gamma"].values.astype(float)
|
| 66 |
+
# scVelo doesn't have per-cell gamma, so use Ms/Mu ratio approach
|
| 67 |
+
Ms = np.asarray(adata_sv.layers["Ms"])
|
| 68 |
+
Mu = np.asarray(adata_sv.layers["Mu"])
|
| 69 |
+
gamma_sv = np.where(Ms > 0.01, Mu / Ms, 0) * vg[np.newaxis, :]
|
| 70 |
+
|
| 71 |
+
# Match genes with scPTR
|
| 72 |
+
shared = adata_sp.var_names.intersection(adata_sv.var_names)
|
| 73 |
+
sp_idx = [list(adata_sp.var_names).index(g) for g in shared]
|
| 74 |
+
sv_idx = [list(adata_sv.var_names).index(g) for g in shared]
|
| 75 |
+
|
| 76 |
+
gamma_sv_shared = gamma_sv[:, sv_idx]
|
| 77 |
+
|
| 78 |
+
# Need matching cells — use same raw data cells
|
| 79 |
+
# scVelo may have filtered cells, so use scVelo's cell set
|
| 80 |
+
clust_sv = cluster_gamma(gamma_sv_shared, adata_sv, key_suffix="_scvelo")
|
| 81 |
+
expr_sv = adata_sv.obs[ck].values
|
| 82 |
+
|
| 83 |
+
n_sv = len(np.unique(clust_sv))
|
| 84 |
+
invis_sv = check_invisible(clust_sv, expr_sv)
|
| 85 |
+
print(f" scVelo SS: {n_sv} PT clusters, {len(invis_sv)} invisible")
|
| 86 |
+
|
| 87 |
+
# ── Compare clusters ──────────────────────────────────────────
|
| 88 |
+
# ARI between scPTR and scVelo gamma clusters (on shared cells)
|
| 89 |
+
# Need to align cells
|
| 90 |
+
shared_cells = adata_sp.obs_names.intersection(adata_sv.obs_names)
|
| 91 |
+
if len(shared_cells) > 100:
|
| 92 |
+
sp_mask = adata_sp.obs_names.isin(shared_cells)
|
| 93 |
+
sv_mask = adata_sv.obs_names.isin(shared_cells)
|
| 94 |
+
|
| 95 |
+
# Recluster on shared cells
|
| 96 |
+
gamma_sp_shared = adata_sp.layers["gamma"][sp_mask][:, sp_idx]
|
| 97 |
+
gamma_sv_for_compare = gamma_sv_shared[sv_mask]
|
| 98 |
+
|
| 99 |
+
clust_sp_sh = cluster_gamma(gamma_sp_shared,
|
| 100 |
+
adata_sp[sp_mask], key_suffix="_sp_sh")
|
| 101 |
+
clust_sv_sh = cluster_gamma(gamma_sv_for_compare,
|
| 102 |
+
adata_sv[sv_mask], key_suffix="_sv_sh")
|
| 103 |
+
|
| 104 |
+
ari = adjusted_rand_score(clust_sp_sh, clust_sv_sh)
|
| 105 |
+
nmi = normalized_mutual_info_score(clust_sp_sh, clust_sv_sh)
|
| 106 |
+
|
| 107 |
+
# ARI with expression clusters
|
| 108 |
+
expr_sp_sh = adata_sp.obs[ck].values[sp_mask]
|
| 109 |
+
ari_sp_expr = adjusted_rand_score(clust_sp_sh, expr_sp_sh)
|
| 110 |
+
ari_sv_expr = adjusted_rand_score(clust_sv_sh, adata_sv.obs[ck].values[sv_mask])
|
| 111 |
+
|
| 112 |
+
print(f"\n scPTR vs scVelo gamma clusters: ARI={ari:.4f}, NMI={nmi:.4f}")
|
| 113 |
+
print(f" scPTR gamma vs expression: ARI={ari_sp_expr:.4f}")
|
| 114 |
+
print(f" scVelo gamma vs expression: ARI={ari_sv_expr:.4f}")
|
| 115 |
+
else:
|
| 116 |
+
ari = nmi = ari_sp_expr = ari_sv_expr = np.nan
|
| 117 |
+
|
| 118 |
+
# ── Which invisible states replicate? ─────────────────────────
|
| 119 |
+
print(f"\n Invisible states:")
|
| 120 |
+
print(f" scPTR: {invis_sp if invis_sp else 'none'}")
|
| 121 |
+
print(f" scVelo: {invis_sv if invis_sv else 'none'}")
|
| 122 |
+
|
| 123 |
+
print(f"\n CONCLUSION: {'SAME structure' if ari > 0.5 else 'DIFFERENT structure' if ari < 0.2 else 'PARTIALLY overlapping'}")
|
| 124 |
+
|
| 125 |
+
all_results[ds_name] = {
|
| 126 |
+
"scptr_n_clusters": n_sp,
|
| 127 |
+
"scvelo_n_clusters": n_sv,
|
| 128 |
+
"scptr_invisible": invis_sp,
|
| 129 |
+
"scvelo_invisible": invis_sv,
|
| 130 |
+
"ari_scptr_vs_scvelo": float(ari) if np.isfinite(ari) else None,
|
| 131 |
+
"nmi_scptr_vs_scvelo": float(nmi) if np.isfinite(nmi) else None,
|
| 132 |
+
"ari_scptr_vs_expr": float(ari_sp_expr) if np.isfinite(ari_sp_expr) else None,
|
| 133 |
+
"ari_scvelo_vs_expr": float(ari_sv_expr) if np.isfinite(ari_sv_expr) else None,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
save_json(all_results, "pt_states_comparison", OUT)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|
analyses/deep/35_corrected_comparison.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Corrected method comparison: all methods on equal footing.
|
| 3 |
+
|
| 4 |
+
Includes corrected scVelo dynamical (fit_gamma/fit_beta) and
|
| 5 |
+
per-cell-type evaluation as a unique scPTR metric.
|
| 6 |
+
"""
|
| 7 |
+
from _common import *
|
| 8 |
+
import scvelo as scv
|
| 9 |
+
|
| 10 |
+
OUT = output_dir("35_corrected_comparison")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
set_figure_style()
|
| 15 |
+
hl_mouse, hl_human = load_halflife_refs()
|
| 16 |
+
|
| 17 |
+
all_results = {}
|
| 18 |
+
|
| 19 |
+
for ds_name, loader, ck in DATASETS:
|
| 20 |
+
print(f"\n{'=' * 60}\n{ds_name.upper()}: Corrected Comparison\n{'=' * 60}")
|
| 21 |
+
|
| 22 |
+
adata_raw = loader()
|
| 23 |
+
|
| 24 |
+
# ── scVelo SS ────────────────────────────────────────────────
|
| 25 |
+
adata_sv = adata_raw.copy()
|
| 26 |
+
scv.pp.filter_and_normalize(adata_sv, min_shared_counts=20, n_top_genes=2000)
|
| 27 |
+
scv.pp.moments(adata_sv, n_pcs=30, n_neighbors=30)
|
| 28 |
+
scv.tl.velocity(adata_sv, mode="steady_state")
|
| 29 |
+
vg = adata_sv.var["velocity_gamma"].values.astype(float)
|
| 30 |
+
|
| 31 |
+
# ── scVelo dynamical (CORRECTED) ─────────────────────────────
|
| 32 |
+
adata_dyn = adata_raw.copy()
|
| 33 |
+
scv.pp.filter_and_normalize(adata_dyn, min_shared_counts=20, n_top_genes=2000)
|
| 34 |
+
scv.pp.moments(adata_dyn, n_pcs=30, n_neighbors=30)
|
| 35 |
+
scv.tl.recover_dynamics(adata_dyn, n_jobs=4)
|
| 36 |
+
scv.tl.velocity(adata_dyn, mode="dynamical")
|
| 37 |
+
fg = adata_dyn.var["fit_gamma"].values.astype(float)
|
| 38 |
+
fb = adata_dyn.var["fit_beta"].values.astype(float)
|
| 39 |
+
ratio = fg / (fb + 1e-8) # CORRECTED: use ratio
|
| 40 |
+
|
| 41 |
+
# ── scPTR ────────────────────────────────────────────────────
|
| 42 |
+
adata_sp = run_analytical(loader)
|
| 43 |
+
|
| 44 |
+
# ── Evaluate ─────────────────────────────────────────────────
|
| 45 |
+
def eval_hl(gamma_vals, var_names, label):
|
| 46 |
+
hl_s = hl_human.set_index("gene_symbol")["half_life_hours"]
|
| 47 |
+
g_upper = {g.upper(): i for i, g in enumerate(var_names)}
|
| 48 |
+
h_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 49 |
+
shared = set(g_upper.keys()) & set(h_upper.keys())
|
| 50 |
+
g = np.array([gamma_vals[g_upper[u]] for u in shared], dtype=float)
|
| 51 |
+
h = np.array([hl_s[h_upper[u]] for u in shared], dtype=float)
|
| 52 |
+
v = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0)
|
| 53 |
+
if v.sum() < 3: return np.nan, 0
|
| 54 |
+
r, _ = stats.spearmanr(g[v], h[v])
|
| 55 |
+
return float(r), int(v.sum())
|
| 56 |
+
|
| 57 |
+
results = {}
|
| 58 |
+
|
| 59 |
+
# Global half-life
|
| 60 |
+
r_ss, n_ss = eval_hl(vg, adata_sv.var_names, "scVelo SS")
|
| 61 |
+
r_dyn_raw, n_dr = eval_hl(fg, adata_dyn.var_names, "scVelo dyn (raw)")
|
| 62 |
+
r_dyn_corr, n_dc = eval_hl(ratio, adata_dyn.var_names, "scVelo dyn (corrected)")
|
| 63 |
+
r_sp, n_sp = halflife_spearman(adata_sp, hl_human)
|
| 64 |
+
|
| 65 |
+
print(f"\n {'Method':<35} {'HL human r':>12} {'n':>6}")
|
| 66 |
+
print(" " + "-" * 55)
|
| 67 |
+
print(f" {'scVelo SS':<35} {r_ss:>12.4f} {n_ss:>6}")
|
| 68 |
+
print(f" {'scVelo dyn (fit_gamma, RAW)':<35} {r_dyn_raw:>12.4f} {n_dr:>6}")
|
| 69 |
+
print(f" {'scVelo dyn (γ/β, CORRECTED)':<35} {r_dyn_corr:>12.4f} {n_dc:>6}")
|
| 70 |
+
print(f" {'scPTR analytical':<35} {r_sp:>12.4f} {n_sp:>6}")
|
| 71 |
+
|
| 72 |
+
results["global_halflife"] = {
|
| 73 |
+
"scvelo_ss": {"r": r_ss, "n": n_ss},
|
| 74 |
+
"scvelo_dyn_raw": {"r": r_dyn_raw, "n": n_dr},
|
| 75 |
+
"scvelo_dyn_corrected": {"r": r_dyn_corr, "n": n_dc},
|
| 76 |
+
"scptr": {"r": r_sp, "n": n_sp},
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
# ── Per-cell-type half-life (UNIQUE TO scPTR) ─────────────────
|
| 80 |
+
print(f"\n Per-cell-type half-life (scPTR-unique capability):")
|
| 81 |
+
if ck in adata_sp.obs.columns:
|
| 82 |
+
ct_rs = []
|
| 83 |
+
for ct in sorted(adata_sp.obs[ck].unique()):
|
| 84 |
+
mask = (adata_sp.obs[ck] == ct).values
|
| 85 |
+
if mask.sum() < 20: continue
|
| 86 |
+
gamma_ct = np.median(adata_sp.layers["gamma"][mask], axis=0)
|
| 87 |
+
adata_tmp = adata_sp.copy()
|
| 88 |
+
adata_tmp.layers["gamma"] = np.tile(gamma_ct, (adata_sp.n_obs, 1))
|
| 89 |
+
r_ct, _ = halflife_spearman(adata_tmp, hl_human)
|
| 90 |
+
ct_rs.append({"cell_type": str(ct), "r": float(r_ct)})
|
| 91 |
+
|
| 92 |
+
best = min(ct_rs, key=lambda x: x["r"])
|
| 93 |
+
print(f" Best cell type: {best['cell_type']} (r={best['r']:.4f})")
|
| 94 |
+
print(f" vs global: r={r_sp:.4f}")
|
| 95 |
+
print(f" → Cell-type resolution improves r by {abs(best['r'])-abs(r_sp):.4f}")
|
| 96 |
+
results["best_celltype"] = best
|
| 97 |
+
|
| 98 |
+
all_results[ds_name] = results
|
| 99 |
+
|
| 100 |
+
save_json(all_results, "corrected_comparison", OUT)
|
| 101 |
+
|
| 102 |
+
# Corrected summary table
|
| 103 |
+
print(f"\n{'=' * 70}")
|
| 104 |
+
print("CORRECTED METHOD COMPARISON (FINAL)")
|
| 105 |
+
print("=" * 70)
|
| 106 |
+
print(f"\n{'Method':<35} ", end="")
|
| 107 |
+
for ds in all_results:
|
| 108 |
+
print(f"{'|':>2} {ds:>15}", end="")
|
| 109 |
+
print()
|
| 110 |
+
print("-" * 70)
|
| 111 |
+
|
| 112 |
+
for method in ["scvelo_ss", "scvelo_dyn_raw", "scvelo_dyn_corrected", "scptr"]:
|
| 113 |
+
label = {"scvelo_ss": "scVelo SS", "scvelo_dyn_raw": "scVelo dyn (raw γ)",
|
| 114 |
+
"scvelo_dyn_corrected": "scVelo dyn (γ/β)", "scptr": "scPTR"}[method]
|
| 115 |
+
print(f" {label:<33} ", end="")
|
| 116 |
+
for ds in all_results:
|
| 117 |
+
r = all_results[ds]["global_halflife"][method]["r"]
|
| 118 |
+
print(f"{'|':>2} {r:>15.4f}", end="")
|
| 119 |
+
print()
|
| 120 |
+
|
| 121 |
+
# Figure
|
| 122 |
+
fig, ax = plt.subplots(figsize=(10, 5))
|
| 123 |
+
methods = ["scVelo SS", "scVelo dyn\n(raw γ)", "scVelo dyn\n(γ/β corrected)", "scPTR"]
|
| 124 |
+
method_keys = ["scvelo_ss", "scvelo_dyn_raw", "scvelo_dyn_corrected", "scptr"]
|
| 125 |
+
colors = ["#1f77b4", "#ff9999", "#2ca02c", "#ff7f0e"]
|
| 126 |
+
|
| 127 |
+
x = np.arange(len(methods))
|
| 128 |
+
width = 0.35
|
| 129 |
+
for i, ds in enumerate(all_results):
|
| 130 |
+
rs = [abs(all_results[ds]["global_halflife"][mk]["r"]) for mk in method_keys]
|
| 131 |
+
offset = (i - 0.5) * width
|
| 132 |
+
ax.bar(x + offset, rs, width, label=ds, alpha=0.8)
|
| 133 |
+
|
| 134 |
+
ax.set_xticks(x)
|
| 135 |
+
ax.set_xticklabels(methods, fontsize=9)
|
| 136 |
+
ax.set_ylabel("|Spearman r| with half-life (human)")
|
| 137 |
+
ax.set_title("Corrected Method Comparison")
|
| 138 |
+
ax.legend()
|
| 139 |
+
fig.tight_layout()
|
| 140 |
+
save_fig(fig, "corrected_comparison", OUT)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
analyses/deep/_common.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for DeepPTR benchmark scripts.
|
| 2 |
+
|
| 3 |
+
All scripts in analyses/deep/ import from here for reproducibility.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
os.environ["OMP_NUM_THREADS"] = "4"
|
| 11 |
+
os.environ["MKL_NUM_THREADS"] = "4"
|
| 12 |
+
os.environ["OPENBLAS_NUM_THREADS"] = "4"
|
| 13 |
+
os.environ["NUMEXPR_NUM_THREADS"] = "4"
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import matplotlib
|
| 21 |
+
|
| 22 |
+
matplotlib.use("Agg")
|
| 23 |
+
import matplotlib.pyplot as plt
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
from scipy import stats
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
torch.set_num_threads(4)
|
| 31 |
+
|
| 32 |
+
# Inline figure style (avoids name collision with parent _common.py)
|
| 33 |
+
def set_figure_style():
|
| 34 |
+
plt.rcParams.update({
|
| 35 |
+
"figure.dpi": 150, "savefig.dpi": 300, "savefig.bbox": "tight",
|
| 36 |
+
"font.size": 10, "axes.titlesize": 12, "axes.labelsize": 11,
|
| 37 |
+
"xtick.labelsize": 9, "ytick.labelsize": 9, "legend.fontsize": 9,
|
| 38 |
+
"figure.figsize": (6, 5), "axes.spines.top": False, "axes.spines.right": False,
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
import scptr
|
| 42 |
+
|
| 43 |
+
# ── Paths ──────────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
| 46 |
+
OUTPUT_ROOT = PROJECT_ROOT / "output" / "deep_benchmarks"
|
| 47 |
+
DATA_DIR = Path(scptr.benchmark.__file__).parent / "data"
|
| 48 |
+
|
| 49 |
+
DEEP_HP = dict(
|
| 50 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 51 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 52 |
+
patience=15, n_posterior_samples=15,
|
| 53 |
+
device="cpu", seed=0,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def output_dir(script_name: str) -> Path:
|
| 58 |
+
"""Return output directory for a given script, e.g. '01_fair_comparison'."""
|
| 59 |
+
d = OUTPUT_ROOT / script_name
|
| 60 |
+
(d / "figures").mkdir(parents=True, exist_ok=True)
|
| 61 |
+
(d / "results").mkdir(parents=True, exist_ok=True)
|
| 62 |
+
return d
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def save_fig(fig, name: str, out: Path, subdir: str = "figures"):
|
| 66 |
+
if fig is None:
|
| 67 |
+
return
|
| 68 |
+
path = out / subdir / f"{name}.png"
|
| 69 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 70 |
+
plt.close(fig)
|
| 71 |
+
print(f" Saved: {path}")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def save_json(data, name: str, out: Path):
|
| 75 |
+
path = out / "results" / f"{name}.json"
|
| 76 |
+
with open(path, "w") as f:
|
| 77 |
+
json.dump(data, f, indent=2, default=str)
|
| 78 |
+
print(f" Saved: {path}")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ── Data loading ───────────────────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
def load_halflife_refs():
|
| 84 |
+
return scptr.datasets.herzog2017_halflives(), scptr.datasets.schofield2018_halflives()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def select_top_genes(adata, n_top=300):
|
| 88 |
+
"""Select top genes by unspliced signal for DeepPTR."""
|
| 89 |
+
from scipy.sparse import issparse
|
| 90 |
+
|
| 91 |
+
u = adata.layers["unspliced"]
|
| 92 |
+
if issparse(u):
|
| 93 |
+
u = np.asarray(u.todense())
|
| 94 |
+
u = np.asarray(u, dtype=np.float32)
|
| 95 |
+
score = u.sum(axis=0) * (u > 0).mean(axis=0)
|
| 96 |
+
top_idx = np.sort(np.argsort(score)[::-1][:n_top])
|
| 97 |
+
adata_sub = adata[:, adata.var_names[top_idx]].copy()
|
| 98 |
+
from scipy.sparse import issparse as _iss
|
| 99 |
+
|
| 100 |
+
for key in ("spliced", "unspliced"):
|
| 101 |
+
if key in adata_sub.layers and _iss(adata_sub.layers[key]):
|
| 102 |
+
adata_sub.layers[key] = np.asarray(adata_sub.layers[key].todense())
|
| 103 |
+
return adata_sub
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def run_analytical(adata_loader):
|
| 107 |
+
"""Run full analytical scPTR pipeline, return adata."""
|
| 108 |
+
adata = adata_loader()
|
| 109 |
+
scptr.pp.filter_genes(adata)
|
| 110 |
+
scptr.pp.normalize_layers(adata)
|
| 111 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 112 |
+
scptr.pp.smooth_layers(adata)
|
| 113 |
+
scptr.tl.estimate_beta(adata)
|
| 114 |
+
scptr.tl.estimate_gamma(adata)
|
| 115 |
+
return adata
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def run_deep(adata_loader, n_top=300, verbose=True):
|
| 119 |
+
"""Run preprocessing + DeepPTR, return (adata_deep, model, history)."""
|
| 120 |
+
adata = adata_loader()
|
| 121 |
+
scptr.pp.filter_genes(adata)
|
| 122 |
+
scptr.pp.normalize_layers(adata)
|
| 123 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 124 |
+
scptr.pp.smooth_layers(adata)
|
| 125 |
+
scptr.tl.estimate_beta(adata)
|
| 126 |
+
adata = select_top_genes(adata, n_top=n_top)
|
| 127 |
+
torch.set_num_threads(4)
|
| 128 |
+
model, history = scptr.deep.fit_deepptr(adata, verbose=verbose, **DEEP_HP)
|
| 129 |
+
return adata, model, history
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def run_both(adata_loader, n_top=300):
|
| 133 |
+
"""Return (adata_analytical, adata_deep, model, history)."""
|
| 134 |
+
adata_an = run_analytical(adata_loader)
|
| 135 |
+
adata_dp, model, history = run_deep(adata_loader, n_top=n_top)
|
| 136 |
+
return adata_an, adata_dp, model, history
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ── Half-life matching ─────────────────────────────────────────────────────
|
| 140 |
+
|
| 141 |
+
def match_halflife(adata, hl_df, gene_col="gene_symbol", hl_col="half_life_hours"):
|
| 142 |
+
"""Match genes case-insensitively, return (gamma_vals, hl_vals, gene_names)."""
|
| 143 |
+
gamma_med = np.median(adata.layers["gamma"], axis=0)
|
| 144 |
+
hl_s = hl_df.set_index(gene_col)[hl_col]
|
| 145 |
+
|
| 146 |
+
gamma_upper = {g.upper(): i for i, g in enumerate(adata.var_names)}
|
| 147 |
+
hl_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 148 |
+
shared = set(gamma_upper.keys()) & set(hl_upper.keys())
|
| 149 |
+
|
| 150 |
+
idx = [gamma_upper[u] for u in shared]
|
| 151 |
+
g = gamma_med[idx].astype(float)
|
| 152 |
+
h = np.array([hl_s[hl_upper[u]] for u in shared], dtype=float)
|
| 153 |
+
names = [adata.var_names[gamma_upper[u]] for u in shared]
|
| 154 |
+
|
| 155 |
+
valid = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0)
|
| 156 |
+
return g[valid], h[valid], [n for n, v in zip(names, valid) if v]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def halflife_spearman(adata, hl_df):
|
| 160 |
+
"""Quick Spearman r with half-life reference."""
|
| 161 |
+
g, h, _ = match_halflife(adata, hl_df)
|
| 162 |
+
if len(g) < 3:
|
| 163 |
+
return np.nan, 0
|
| 164 |
+
r, _ = stats.spearmanr(g, h)
|
| 165 |
+
return float(r), len(g)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ── Dataset registry ───────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
DATASETS = [
|
| 171 |
+
("pancreas", scptr.datasets.pancreas, "clusters"),
|
| 172 |
+
("dentate_gyrus", scptr.datasets.dentate_gyrus, "clusters"),
|
| 173 |
+
]
|
analyses/run_comprehensive_fixes.py
ADDED
|
@@ -0,0 +1,1283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Comprehensive improvement of scPTR weaknesses.
|
| 3 |
+
|
| 4 |
+
Fix A: Per-cell sci-fate ablation (scPTR vs raw u/s per cell)
|
| 5 |
+
Fix B: 3' UTR sequence validation of network direction
|
| 6 |
+
Fix C: Neuroblastoma-specific DepMap validation
|
| 7 |
+
Fix D: Cross-dataset RBP hub consistency
|
| 8 |
+
Fix E: Biological coherence ablation (GSEA on invisible states)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import matplotlib
|
| 18 |
+
matplotlib.use("Agg")
|
| 19 |
+
import matplotlib.pyplot as plt
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pandas as pd
|
| 22 |
+
from scipy import stats
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 25 |
+
from _common import set_figure_style
|
| 26 |
+
|
| 27 |
+
import scptr
|
| 28 |
+
|
| 29 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "comprehensive_fixes"
|
| 30 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def save_fig(fig, name, subdir="figures"):
|
| 34 |
+
if fig is None:
|
| 35 |
+
print(f" [WARNING] {name}: None, skipping")
|
| 36 |
+
return
|
| 37 |
+
out_dir = OUTPUT_DIR / subdir
|
| 38 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
path = out_dir / f"{name}.png"
|
| 40 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 41 |
+
plt.close(fig)
|
| 42 |
+
print(f" Saved: {path}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run_pipeline(adata, name):
|
| 46 |
+
"""Run standard scPTR pipeline."""
|
| 47 |
+
print(f"\n--- Pipeline: {name} ---")
|
| 48 |
+
scptr.pp.filter_genes(adata)
|
| 49 |
+
scptr.pp.normalize_layers(adata)
|
| 50 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 51 |
+
scptr.pp.smooth_layers(adata)
|
| 52 |
+
scptr.tl.estimate_beta(adata)
|
| 53 |
+
scptr.tl.estimate_gamma(adata)
|
| 54 |
+
scptr.tl.variance_decomposition(adata)
|
| 55 |
+
scptr.tl.pt_states(adata)
|
| 56 |
+
scptr.tl.pt_velocity(adata)
|
| 57 |
+
print(f" Done: {adata.shape}")
|
| 58 |
+
return adata
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# =========================================================================
|
| 62 |
+
# FIX B: 3' UTR Sequence Validation of Network Direction
|
| 63 |
+
# =========================================================================
|
| 64 |
+
def fix_b_utr_validation():
|
| 65 |
+
"""Validate network direction using 3' UTR sequence features.
|
| 66 |
+
|
| 67 |
+
Destabilizing targets should have longer 3' UTRs (more regulatory elements)
|
| 68 |
+
and higher AU content.
|
| 69 |
+
"""
|
| 70 |
+
print("\n" + "=" * 60)
|
| 71 |
+
print("FIX B: 3' UTR SEQUENCE VALIDATION OF NETWORK DIRECTION")
|
| 72 |
+
print("=" * 60)
|
| 73 |
+
|
| 74 |
+
res_dir = OUTPUT_DIR / "results"
|
| 75 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
|
| 77 |
+
# Load UTR features
|
| 78 |
+
data_dir = PROJECT_ROOT / "src" / "scptr" / "benchmark" / "data"
|
| 79 |
+
mouse_utr = pd.read_csv(data_dir / "mouse_utr_features.csv")
|
| 80 |
+
human_utr = pd.read_csv(data_dir / "human_utr_features.csv")
|
| 81 |
+
print(f" Mouse UTR features: {len(mouse_utr)} genes")
|
| 82 |
+
print(f" Human UTR features: {len(human_utr)} genes")
|
| 83 |
+
|
| 84 |
+
# Load corrected networks
|
| 85 |
+
networks = {}
|
| 86 |
+
net_files = {
|
| 87 |
+
"pancreas": PROJECT_ROOT / "output" / "weakness_fixes" / "results" / "corrected_network_pancreas.csv",
|
| 88 |
+
"dentate_gyrus": PROJECT_ROOT / "output" / "weakness_fixes" / "results" / "corrected_network_dentate_gyrus.csv",
|
| 89 |
+
"neuroblastoma": PROJECT_ROOT / "output" / "tier3" / "results" / "neuroblastoma_network_corrected.csv",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
for name, path in net_files.items():
|
| 93 |
+
if path.exists():
|
| 94 |
+
networks[name] = pd.read_csv(path)
|
| 95 |
+
print(f" {name} network: {len(networks[name])} edges")
|
| 96 |
+
else:
|
| 97 |
+
print(f" [WARNING] {name} network not found at {path}")
|
| 98 |
+
|
| 99 |
+
results = {}
|
| 100 |
+
all_summaries = []
|
| 101 |
+
|
| 102 |
+
for net_name, edges_df in networks.items():
|
| 103 |
+
print(f"\n--- {net_name} ---")
|
| 104 |
+
|
| 105 |
+
# Determine which UTR dataset to use
|
| 106 |
+
# Neuroblastoma = human, pancreas/DG = mouse
|
| 107 |
+
if net_name == "neuroblastoma":
|
| 108 |
+
utr_df = human_utr.copy()
|
| 109 |
+
# Column for correlation is spearman_r
|
| 110 |
+
r_col = "spearman_r" if "spearman_r" in edges_df.columns else "r"
|
| 111 |
+
else:
|
| 112 |
+
utr_df = mouse_utr.copy()
|
| 113 |
+
r_col = "r" if "r" in edges_df.columns else "spearman_r"
|
| 114 |
+
|
| 115 |
+
# Build gene-level summary: mean correlation across all RBP connections
|
| 116 |
+
target_stats = edges_df.groupby("target").agg(
|
| 117 |
+
mean_r=(r_col, "mean"),
|
| 118 |
+
n_rbps=(r_col, "count"),
|
| 119 |
+
).reset_index()
|
| 120 |
+
|
| 121 |
+
# Classify as predominantly destabilized (mean r > 0) or stabilized (mean r < 0)
|
| 122 |
+
target_stats["class"] = np.where(target_stats["mean_r"] > 0,
|
| 123 |
+
"destabilized", "stabilized")
|
| 124 |
+
n_dest = (target_stats["class"] == "destabilized").sum()
|
| 125 |
+
n_stab = (target_stats["class"] == "stabilized").sum()
|
| 126 |
+
print(f" Target genes: {len(target_stats)} ({n_dest} destabilized, {n_stab} stabilized)")
|
| 127 |
+
|
| 128 |
+
# Match target genes to UTR features (case-insensitive)
|
| 129 |
+
utr_map = {g.upper(): i for i, g in enumerate(utr_df["gene"])}
|
| 130 |
+
target_stats["gene_upper"] = target_stats["target"].str.upper()
|
| 131 |
+
matched = target_stats[target_stats["gene_upper"].isin(utr_map)].copy()
|
| 132 |
+
matched["utr_length"] = matched["gene_upper"].map(
|
| 133 |
+
lambda g: utr_df.iloc[utr_map[g]]["utr_length"])
|
| 134 |
+
matched["au_content"] = matched["gene_upper"].map(
|
| 135 |
+
lambda g: utr_df.iloc[utr_map[g]]["au_content"])
|
| 136 |
+
|
| 137 |
+
n_matched = len(matched)
|
| 138 |
+
print(f" Matched to UTR features: {n_matched}/{len(target_stats)}")
|
| 139 |
+
|
| 140 |
+
if n_matched < 10:
|
| 141 |
+
print(f" Too few matched genes, skipping")
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
dest = matched[matched["class"] == "destabilized"]
|
| 145 |
+
stab = matched[matched["class"] == "stabilized"]
|
| 146 |
+
|
| 147 |
+
net_results = {"dataset": net_name, "n_targets": len(target_stats),
|
| 148 |
+
"n_matched": n_matched}
|
| 149 |
+
|
| 150 |
+
# Test 1: UTR length destabilized vs stabilized
|
| 151 |
+
if len(dest) >= 5 and len(stab) >= 5:
|
| 152 |
+
u_stat, p_len = stats.mannwhitneyu(
|
| 153 |
+
dest["utr_length"].values, stab["utr_length"].values,
|
| 154 |
+
alternative="greater")
|
| 155 |
+
med_dest_len = dest["utr_length"].median()
|
| 156 |
+
med_stab_len = stab["utr_length"].median()
|
| 157 |
+
print(f" UTR length: destab median={med_dest_len:.0f}, "
|
| 158 |
+
f"stab median={med_stab_len:.0f}, "
|
| 159 |
+
f"MW p={p_len:.4f} (destab > stab)")
|
| 160 |
+
net_results["utr_length_destab_median"] = float(med_dest_len)
|
| 161 |
+
net_results["utr_length_stab_median"] = float(med_stab_len)
|
| 162 |
+
net_results["utr_length_mw_p"] = float(p_len)
|
| 163 |
+
else:
|
| 164 |
+
p_len = np.nan
|
| 165 |
+
|
| 166 |
+
# Test 2: AU content destabilized vs stabilized
|
| 167 |
+
if len(dest) >= 5 and len(stab) >= 5:
|
| 168 |
+
u_stat, p_au = stats.mannwhitneyu(
|
| 169 |
+
dest["au_content"].values, stab["au_content"].values,
|
| 170 |
+
alternative="greater")
|
| 171 |
+
med_dest_au = dest["au_content"].median()
|
| 172 |
+
med_stab_au = stab["au_content"].median()
|
| 173 |
+
print(f" AU content: destab median={med_dest_au:.4f}, "
|
| 174 |
+
f"stab median={med_stab_au:.4f}, "
|
| 175 |
+
f"MW p={p_au:.4f} (destab > stab)")
|
| 176 |
+
net_results["au_content_destab_median"] = float(med_dest_au)
|
| 177 |
+
net_results["au_content_stab_median"] = float(med_stab_au)
|
| 178 |
+
net_results["au_content_mw_p"] = float(p_au)
|
| 179 |
+
else:
|
| 180 |
+
p_au = np.nan
|
| 181 |
+
|
| 182 |
+
# Test 3: Spearman correlation of mean_r vs UTR length
|
| 183 |
+
r_vs_len, p_r_len = stats.spearmanr(
|
| 184 |
+
matched["mean_r"].values, matched["utr_length"].values)
|
| 185 |
+
print(f" Spearman(mean_r, UTR length): r={r_vs_len:.4f}, p={p_r_len:.4f}")
|
| 186 |
+
net_results["spearman_r_vs_utr_length"] = float(r_vs_len)
|
| 187 |
+
net_results["spearman_p_vs_utr_length"] = float(p_r_len)
|
| 188 |
+
|
| 189 |
+
# Test 4: Spearman correlation of mean_r vs AU content
|
| 190 |
+
r_vs_au, p_r_au = stats.spearmanr(
|
| 191 |
+
matched["mean_r"].values, matched["au_content"].values)
|
| 192 |
+
print(f" Spearman(mean_r, AU content): r={r_vs_au:.4f}, p={p_r_au:.4f}")
|
| 193 |
+
net_results["spearman_r_vs_au_content"] = float(r_vs_au)
|
| 194 |
+
net_results["spearman_p_vs_au_content"] = float(p_r_au)
|
| 195 |
+
|
| 196 |
+
results[net_name] = net_results
|
| 197 |
+
all_summaries.append(net_results)
|
| 198 |
+
|
| 199 |
+
# Save results
|
| 200 |
+
with open(res_dir / "utr_network_validation.json", "w") as f:
|
| 201 |
+
json.dump(results, f, indent=2)
|
| 202 |
+
|
| 203 |
+
# Figure: 2x3 panels (UTR length and AU content for each dataset)
|
| 204 |
+
n_nets = len(results)
|
| 205 |
+
if n_nets == 0:
|
| 206 |
+
print(" No networks to plot")
|
| 207 |
+
return results
|
| 208 |
+
|
| 209 |
+
fig, axes = plt.subplots(2, n_nets, figsize=(5 * n_nets, 8))
|
| 210 |
+
if n_nets == 1:
|
| 211 |
+
axes = axes.reshape(2, 1)
|
| 212 |
+
|
| 213 |
+
for col, (net_name, edges_df) in enumerate(networks.items()):
|
| 214 |
+
if net_name not in results:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
r_col = "spearman_r" if "spearman_r" in edges_df.columns else "r"
|
| 218 |
+
if net_name == "neuroblastoma":
|
| 219 |
+
utr_df = human_utr
|
| 220 |
+
else:
|
| 221 |
+
utr_df = mouse_utr
|
| 222 |
+
|
| 223 |
+
# Rebuild matched data for plotting
|
| 224 |
+
target_stats = edges_df.groupby("target").agg(
|
| 225 |
+
mean_r=(r_col, "mean"),
|
| 226 |
+
).reset_index()
|
| 227 |
+
target_stats["gene_upper"] = target_stats["target"].str.upper()
|
| 228 |
+
utr_map = {g.upper(): i for i, g in enumerate(utr_df["gene"])}
|
| 229 |
+
matched = target_stats[target_stats["gene_upper"].isin(utr_map)].copy()
|
| 230 |
+
matched["utr_length"] = matched["gene_upper"].map(
|
| 231 |
+
lambda g: utr_df.iloc[utr_map[g]]["utr_length"])
|
| 232 |
+
matched["au_content"] = matched["gene_upper"].map(
|
| 233 |
+
lambda g: utr_df.iloc[utr_map[g]]["au_content"])
|
| 234 |
+
|
| 235 |
+
# Row 0: scatter mean_r vs UTR length
|
| 236 |
+
ax = axes[0, col]
|
| 237 |
+
ax.scatter(matched["mean_r"], matched["utr_length"],
|
| 238 |
+
alpha=0.3, s=10, c="steelblue")
|
| 239 |
+
r_val = results[net_name].get("spearman_r_vs_utr_length", np.nan)
|
| 240 |
+
p_val = results[net_name].get("spearman_p_vs_utr_length", np.nan)
|
| 241 |
+
ax.set_xlabel("Mean RBP-target r")
|
| 242 |
+
ax.set_ylabel("3' UTR length (nt)")
|
| 243 |
+
ax.set_title(f"{net_name}\nr={r_val:.3f}, p={p_val:.3f}")
|
| 244 |
+
|
| 245 |
+
# Row 1: scatter mean_r vs AU content
|
| 246 |
+
ax = axes[1, col]
|
| 247 |
+
ax.scatter(matched["mean_r"], matched["au_content"],
|
| 248 |
+
alpha=0.3, s=10, c="darkorange")
|
| 249 |
+
r_val = results[net_name].get("spearman_r_vs_au_content", np.nan)
|
| 250 |
+
p_val = results[net_name].get("spearman_p_vs_au_content", np.nan)
|
| 251 |
+
ax.set_xlabel("Mean RBP-target r")
|
| 252 |
+
ax.set_ylabel("AU content")
|
| 253 |
+
ax.set_title(f"{net_name}\nr={r_val:.3f}, p={p_val:.3f}")
|
| 254 |
+
|
| 255 |
+
fig.suptitle("3' UTR Validation of Network Direction", fontsize=13, y=1.02)
|
| 256 |
+
fig.tight_layout()
|
| 257 |
+
save_fig(fig, "utr_network_validation")
|
| 258 |
+
|
| 259 |
+
return results
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# =========================================================================
|
| 263 |
+
# FIX D: Cross-Dataset RBP Hub Consistency
|
| 264 |
+
# =========================================================================
|
| 265 |
+
def fix_d_hub_consistency():
|
| 266 |
+
"""Compare hub rankings across pancreas, DG, and neuroblastoma."""
|
| 267 |
+
print("\n" + "=" * 60)
|
| 268 |
+
print("FIX D: CROSS-DATASET RBP HUB CONSISTENCY")
|
| 269 |
+
print("=" * 60)
|
| 270 |
+
|
| 271 |
+
res_dir = OUTPUT_DIR / "results"
|
| 272 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 273 |
+
|
| 274 |
+
# Load hub counts from gap_analysis
|
| 275 |
+
hub_files = {
|
| 276 |
+
"pancreas": PROJECT_ROOT / "output" / "gap_analysis" / "results" / "network" / "pancreas" / "rbp_hub_counts.csv",
|
| 277 |
+
"dentate_gyrus": PROJECT_ROOT / "output" / "gap_analysis" / "results" / "network" / "dentate_gyrus" / "rbp_hub_counts.csv",
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
hub_counts = {}
|
| 281 |
+
|
| 282 |
+
for name, path in hub_files.items():
|
| 283 |
+
if path.exists():
|
| 284 |
+
df = pd.read_csv(path)
|
| 285 |
+
# Format: rbp, 0 (where 0 is the count column)
|
| 286 |
+
count_col = [c for c in df.columns if c != "rbp"][0]
|
| 287 |
+
series = pd.Series(df[count_col].values, index=df["rbp"].values)
|
| 288 |
+
hub_counts[name] = series
|
| 289 |
+
print(f" {name}: {len(series)} RBPs")
|
| 290 |
+
else:
|
| 291 |
+
print(f" [WARNING] {name} hub counts not found at {path}")
|
| 292 |
+
|
| 293 |
+
# Compute NB hub counts from corrected network
|
| 294 |
+
nb_net_path = PROJECT_ROOT / "output" / "tier3" / "results" / "neuroblastoma_network_corrected.csv"
|
| 295 |
+
if nb_net_path.exists():
|
| 296 |
+
nb_net = pd.read_csv(nb_net_path)
|
| 297 |
+
nb_hubs = nb_net.groupby("rbp").size().sort_values(ascending=False)
|
| 298 |
+
hub_counts["neuroblastoma"] = nb_hubs
|
| 299 |
+
print(f" neuroblastoma: {len(nb_hubs)} RBPs")
|
| 300 |
+
|
| 301 |
+
if len(hub_counts) < 2:
|
| 302 |
+
print(" Need at least 2 datasets for comparison")
|
| 303 |
+
return {}
|
| 304 |
+
|
| 305 |
+
# Unify gene names to uppercase
|
| 306 |
+
hub_upper = {}
|
| 307 |
+
for name, series in hub_counts.items():
|
| 308 |
+
hub_upper[name] = pd.Series(series.values, index=[g.upper() for g in series.index])
|
| 309 |
+
|
| 310 |
+
# Pairwise Spearman on target counts across shared RBPs
|
| 311 |
+
names = sorted(hub_upper.keys())
|
| 312 |
+
results = {"pairwise_correlations": [], "universal_hubs": [], "dataset_hubs": {}}
|
| 313 |
+
|
| 314 |
+
print("\n Pairwise hub count correlations:")
|
| 315 |
+
for i, name_a in enumerate(names):
|
| 316 |
+
for j in range(i + 1, len(names)):
|
| 317 |
+
name_b = names[j]
|
| 318 |
+
shared = hub_upper[name_a].index.intersection(hub_upper[name_b].index)
|
| 319 |
+
if len(shared) < 5:
|
| 320 |
+
print(f" {name_a} vs {name_b}: only {len(shared)} shared RBPs, skipping")
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
va = hub_upper[name_a][shared].values.astype(float)
|
| 324 |
+
vb = hub_upper[name_b][shared].values.astype(float)
|
| 325 |
+
r, p = stats.spearmanr(va, vb)
|
| 326 |
+
print(f" {name_a} vs {name_b}: Spearman r={r:.4f}, p={p:.4f} (n={len(shared)})")
|
| 327 |
+
|
| 328 |
+
results["pairwise_correlations"].append({
|
| 329 |
+
"dataset_a": name_a,
|
| 330 |
+
"dataset_b": name_b,
|
| 331 |
+
"spearman_r": float(r),
|
| 332 |
+
"spearman_p": float(p),
|
| 333 |
+
"n_shared": int(len(shared)),
|
| 334 |
+
})
|
| 335 |
+
|
| 336 |
+
# Fisher's exact: are top-10 hubs in A enriched among top-20 in B?
|
| 337 |
+
print("\n Fisher's exact test (top-10 in A enriched among top-20 in B?):")
|
| 338 |
+
for i, name_a in enumerate(names):
|
| 339 |
+
for j in range(len(names)):
|
| 340 |
+
if i == j:
|
| 341 |
+
continue
|
| 342 |
+
name_b = names[j]
|
| 343 |
+
shared = hub_upper[name_a].index.intersection(hub_upper[name_b].index)
|
| 344 |
+
if len(shared) < 5:
|
| 345 |
+
continue
|
| 346 |
+
|
| 347 |
+
top_a = set(hub_upper[name_a].nlargest(10).index)
|
| 348 |
+
top_b = set(hub_upper[name_b].nlargest(20).index)
|
| 349 |
+
|
| 350 |
+
# Contingency table
|
| 351 |
+
a_in_b = len(top_a & top_b)
|
| 352 |
+
a_not_b = len(top_a - top_b)
|
| 353 |
+
not_a_in_b = len(top_b - top_a)
|
| 354 |
+
not_a_not_b = len(shared) - a_in_b - a_not_b - not_a_in_b
|
| 355 |
+
|
| 356 |
+
if not_a_not_b < 0:
|
| 357 |
+
not_a_not_b = 0
|
| 358 |
+
|
| 359 |
+
table = [[a_in_b, a_not_b], [not_a_in_b, not_a_not_b]]
|
| 360 |
+
odds_ratio, fisher_p = stats.fisher_exact(table, alternative="greater")
|
| 361 |
+
print(f" Top-10 {name_a} in top-20 {name_b}: "
|
| 362 |
+
f"{a_in_b}/10, OR={odds_ratio:.2f}, p={fisher_p:.4f}")
|
| 363 |
+
|
| 364 |
+
# Identify "universal" hubs (top 20 in >= 2 datasets)
|
| 365 |
+
print("\n Universal hubs (top 20 in >= 2 datasets):")
|
| 366 |
+
top20_sets = {}
|
| 367 |
+
for name in names:
|
| 368 |
+
top20_sets[name] = set(hub_upper[name].nlargest(20).index)
|
| 369 |
+
|
| 370 |
+
all_rbps = set()
|
| 371 |
+
for s in top20_sets.values():
|
| 372 |
+
all_rbps |= s
|
| 373 |
+
|
| 374 |
+
hub_table = []
|
| 375 |
+
for rbp in sorted(all_rbps):
|
| 376 |
+
datasets_in_top20 = [name for name in names if rbp in top20_sets[name]]
|
| 377 |
+
counts_per_dataset = {name: int(hub_upper[name].get(rbp, 0))
|
| 378 |
+
for name in names}
|
| 379 |
+
hub_table.append({
|
| 380 |
+
"rbp": rbp,
|
| 381 |
+
"n_datasets_top20": len(datasets_in_top20),
|
| 382 |
+
"datasets": ", ".join(datasets_in_top20),
|
| 383 |
+
**{f"targets_{name}": counts_per_dataset[name] for name in names},
|
| 384 |
+
})
|
| 385 |
+
|
| 386 |
+
hub_df = pd.DataFrame(hub_table).sort_values("n_datasets_top20", ascending=False)
|
| 387 |
+
|
| 388 |
+
# Save per-dataset top hubs
|
| 389 |
+
for name in names:
|
| 390 |
+
results["dataset_hubs"][name] = hub_upper[name].nlargest(10).to_dict()
|
| 391 |
+
|
| 392 |
+
universal = hub_df[hub_df["n_datasets_top20"] >= 2]
|
| 393 |
+
tissue_specific = hub_df[hub_df["n_datasets_top20"] == 1]
|
| 394 |
+
print(f" Universal (>=2): {len(universal)} RBPs")
|
| 395 |
+
for _, row in universal.iterrows():
|
| 396 |
+
print(f" {row['rbp']}: {row['datasets']}")
|
| 397 |
+
print(f" Tissue-specific (1 only): {len(tissue_specific)} RBPs")
|
| 398 |
+
|
| 399 |
+
results["universal_hubs"] = universal.to_dict(orient="records")
|
| 400 |
+
results["n_universal"] = int(len(universal))
|
| 401 |
+
results["n_tissue_specific"] = int(len(tissue_specific))
|
| 402 |
+
|
| 403 |
+
# Save
|
| 404 |
+
hub_df.to_csv(res_dir / "hub_consistency_table.csv", index=False)
|
| 405 |
+
with open(res_dir / "hub_consistency.json", "w") as f:
|
| 406 |
+
json.dump(results, f, indent=2, default=str)
|
| 407 |
+
|
| 408 |
+
# Figure: heatmap of hub counts + bar chart of universal vs specific
|
| 409 |
+
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 410 |
+
|
| 411 |
+
# Panel 1: heatmap of top RBPs across datasets
|
| 412 |
+
top_rbps = hub_df.nlargest(20, "n_datasets_top20")
|
| 413 |
+
target_cols = [f"targets_{n}" for n in names]
|
| 414 |
+
heatmap_data = top_rbps[target_cols].values.astype(float)
|
| 415 |
+
heatmap_labels = top_rbps["rbp"].values
|
| 416 |
+
|
| 417 |
+
im = axes[0].imshow(heatmap_data, aspect="auto", cmap="YlOrRd")
|
| 418 |
+
axes[0].set_yticks(np.arange(len(heatmap_labels)))
|
| 419 |
+
axes[0].set_yticklabels(heatmap_labels, fontsize=8)
|
| 420 |
+
axes[0].set_xticks(np.arange(len(names)))
|
| 421 |
+
axes[0].set_xticklabels(names, fontsize=9, rotation=30, ha="right")
|
| 422 |
+
axes[0].set_title("Hub RBP Target Counts Across Datasets")
|
| 423 |
+
for i in range(len(heatmap_labels)):
|
| 424 |
+
for j in range(len(names)):
|
| 425 |
+
val = int(heatmap_data[i, j])
|
| 426 |
+
if val > 0:
|
| 427 |
+
axes[0].text(j, i, str(val), ha="center", va="center",
|
| 428 |
+
fontsize=7, color="white" if val > heatmap_data.max() * 0.6 else "black")
|
| 429 |
+
plt.colorbar(im, ax=axes[0], label="Target count", shrink=0.8)
|
| 430 |
+
|
| 431 |
+
# Panel 2: universal vs tissue-specific
|
| 432 |
+
axes[1].bar(["Universal\n(>=2 datasets)", "Tissue-specific\n(1 dataset)"],
|
| 433 |
+
[len(universal), len(tissue_specific)],
|
| 434 |
+
color=["steelblue", "salmon"], edgecolor="black", linewidth=0.5)
|
| 435 |
+
axes[1].set_ylabel("Number of RBPs")
|
| 436 |
+
axes[1].set_title("Hub Consistency Across Datasets")
|
| 437 |
+
for i, v in enumerate([len(universal), len(tissue_specific)]):
|
| 438 |
+
axes[1].text(i, v + 0.5, str(v), ha="center", fontsize=11, fontweight="bold")
|
| 439 |
+
|
| 440 |
+
fig.suptitle("Cross-Dataset RBP Hub Consistency", fontsize=13, y=1.02)
|
| 441 |
+
fig.tight_layout()
|
| 442 |
+
save_fig(fig, "hub_consistency")
|
| 443 |
+
|
| 444 |
+
return results
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# =========================================================================
|
| 448 |
+
# FIX C: Neuroblastoma-Specific DepMap
|
| 449 |
+
# =========================================================================
|
| 450 |
+
def fix_c_nb_depmap():
|
| 451 |
+
"""Filter DepMap CRISPR scores to NB-specific cell lines."""
|
| 452 |
+
print("\n" + "=" * 60)
|
| 453 |
+
print("FIX C: NEUROBLASTOMA-SPECIFIC DepMap VALIDATION")
|
| 454 |
+
print("=" * 60)
|
| 455 |
+
|
| 456 |
+
res_dir = OUTPUT_DIR / "results"
|
| 457 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 458 |
+
|
| 459 |
+
cache_dir = PROJECT_ROOT / ".cache"
|
| 460 |
+
|
| 461 |
+
# Load DepMap model metadata
|
| 462 |
+
model_df = pd.read_csv(cache_dir / "DepMap_Model.csv")
|
| 463 |
+
nb_models = model_df[model_df["OncotreePrimaryDisease"] == "Neuroblastoma"]
|
| 464 |
+
nb_model_ids = set(nb_models["ModelID"].values)
|
| 465 |
+
print(f" Neuroblastoma cell lines in DepMap: {len(nb_model_ids)}")
|
| 466 |
+
|
| 467 |
+
# Load CRISPR gene effect
|
| 468 |
+
print(" Loading CRISPRGeneEffect.csv...")
|
| 469 |
+
crispr_df = pd.read_csv(cache_dir / "CRISPRGeneEffect.csv", index_col=0)
|
| 470 |
+
print(f" CRISPR data: {crispr_df.shape[0]} cell lines, {crispr_df.shape[1]} genes")
|
| 471 |
+
|
| 472 |
+
# Parse gene names from column headers: "GENE (ID)" -> "GENE"
|
| 473 |
+
gene_names = [col.split(" (")[0] for col in crispr_df.columns]
|
| 474 |
+
crispr_df.columns = gene_names
|
| 475 |
+
|
| 476 |
+
# Filter to NB cell lines
|
| 477 |
+
nb_ids_in_crispr = nb_model_ids & set(crispr_df.index)
|
| 478 |
+
print(f" NB cell lines with CRISPR data: {len(nb_ids_in_crispr)}")
|
| 479 |
+
|
| 480 |
+
nb_crispr = crispr_df.loc[list(nb_ids_in_crispr)]
|
| 481 |
+
all_crispr = crispr_df
|
| 482 |
+
|
| 483 |
+
# Mean dependency per gene
|
| 484 |
+
nb_mean_dep = nb_crispr.mean(axis=0)
|
| 485 |
+
all_mean_dep = all_crispr.mean(axis=0)
|
| 486 |
+
non_nb_crispr = crispr_df.loc[~crispr_df.index.isin(nb_model_ids)]
|
| 487 |
+
non_nb_mean_dep = non_nb_crispr.mean(axis=0)
|
| 488 |
+
|
| 489 |
+
# Load network hubs for each dataset
|
| 490 |
+
hub_files = {
|
| 491 |
+
"neuroblastoma": PROJECT_ROOT / "output" / "tier3" / "results" / "neuroblastoma_network_corrected.csv",
|
| 492 |
+
"pancreas": PROJECT_ROOT / "output" / "weakness_fixes" / "results" / "corrected_network_pancreas.csv",
|
| 493 |
+
"dentate_gyrus": PROJECT_ROOT / "output" / "weakness_fixes" / "results" / "corrected_network_dentate_gyrus.csv",
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
results = {}
|
| 497 |
+
|
| 498 |
+
for net_name, net_path in hub_files.items():
|
| 499 |
+
if not net_path.exists():
|
| 500 |
+
print(f" [WARNING] {net_name} network not found")
|
| 501 |
+
continue
|
| 502 |
+
|
| 503 |
+
net_df = pd.read_csv(net_path)
|
| 504 |
+
hub_counts = net_df.groupby("rbp").size().sort_values(ascending=False)
|
| 505 |
+
top_n = min(20, len(hub_counts))
|
| 506 |
+
hub_rbps = set(hub_counts.index[:top_n])
|
| 507 |
+
non_hub_rbps = set(hub_counts.index[top_n:])
|
| 508 |
+
|
| 509 |
+
print(f"\n--- {net_name} ({len(hub_rbps)} hub, {len(non_hub_rbps)} non-hub RBPs) ---")
|
| 510 |
+
|
| 511 |
+
# Match to CRISPR gene names (uppercase)
|
| 512 |
+
crispr_genes_upper = {g.upper(): g for g in nb_mean_dep.index}
|
| 513 |
+
|
| 514 |
+
hub_nb_deps = []
|
| 515 |
+
hub_all_deps = []
|
| 516 |
+
hub_non_nb_deps = []
|
| 517 |
+
for rbp in hub_rbps:
|
| 518 |
+
g_upper = rbp.upper()
|
| 519 |
+
if g_upper in crispr_genes_upper:
|
| 520 |
+
cg = crispr_genes_upper[g_upper]
|
| 521 |
+
hub_nb_deps.append(nb_mean_dep[cg])
|
| 522 |
+
hub_all_deps.append(all_mean_dep[cg])
|
| 523 |
+
hub_non_nb_deps.append(non_nb_mean_dep[cg])
|
| 524 |
+
|
| 525 |
+
nonhub_nb_deps = []
|
| 526 |
+
nonhub_all_deps = []
|
| 527 |
+
nonhub_non_nb_deps = []
|
| 528 |
+
for rbp in non_hub_rbps:
|
| 529 |
+
g_upper = rbp.upper()
|
| 530 |
+
if g_upper in crispr_genes_upper:
|
| 531 |
+
cg = crispr_genes_upper[g_upper]
|
| 532 |
+
nonhub_nb_deps.append(nb_mean_dep[cg])
|
| 533 |
+
nonhub_all_deps.append(all_mean_dep[cg])
|
| 534 |
+
nonhub_non_nb_deps.append(non_nb_mean_dep[cg])
|
| 535 |
+
|
| 536 |
+
net_results = {
|
| 537 |
+
"n_hub_rbps": len(hub_rbps),
|
| 538 |
+
"n_hub_matched": len(hub_nb_deps),
|
| 539 |
+
"n_nonhub_matched": len(nonhub_nb_deps),
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
# NB-specific: hub vs non-hub
|
| 543 |
+
if len(hub_nb_deps) >= 3 and len(nonhub_nb_deps) >= 3:
|
| 544 |
+
u_stat, p_nb = stats.mannwhitneyu(
|
| 545 |
+
hub_nb_deps, nonhub_nb_deps, alternative="less")
|
| 546 |
+
print(f" NB-specific: hub mean={np.mean(hub_nb_deps):.4f}, "
|
| 547 |
+
f"non-hub mean={np.mean(nonhub_nb_deps):.4f}, "
|
| 548 |
+
f"MW p={p_nb:.4e}")
|
| 549 |
+
net_results["nb_hub_mean_dep"] = float(np.mean(hub_nb_deps))
|
| 550 |
+
net_results["nb_nonhub_mean_dep"] = float(np.mean(nonhub_nb_deps))
|
| 551 |
+
net_results["nb_mw_p"] = float(p_nb)
|
| 552 |
+
|
| 553 |
+
# Pan-cancer: hub vs non-hub
|
| 554 |
+
if len(hub_all_deps) >= 3 and len(nonhub_all_deps) >= 3:
|
| 555 |
+
u_stat, p_all = stats.mannwhitneyu(
|
| 556 |
+
hub_all_deps, nonhub_all_deps, alternative="less")
|
| 557 |
+
print(f" Pan-cancer: hub mean={np.mean(hub_all_deps):.4f}, "
|
| 558 |
+
f"non-hub mean={np.mean(nonhub_all_deps):.4f}, "
|
| 559 |
+
f"MW p={p_all:.4e}")
|
| 560 |
+
net_results["all_hub_mean_dep"] = float(np.mean(hub_all_deps))
|
| 561 |
+
net_results["all_nonhub_mean_dep"] = float(np.mean(nonhub_all_deps))
|
| 562 |
+
net_results["all_mw_p"] = float(p_all)
|
| 563 |
+
|
| 564 |
+
# NB-specificity: are NB hubs MORE essential in NB vs non-NB?
|
| 565 |
+
if len(hub_nb_deps) >= 3 and len(hub_non_nb_deps) >= 3:
|
| 566 |
+
u_stat, p_spec = stats.mannwhitneyu(
|
| 567 |
+
hub_nb_deps, hub_non_nb_deps, alternative="less")
|
| 568 |
+
print(f" NB-specificity: hub in NB={np.mean(hub_nb_deps):.4f}, "
|
| 569 |
+
f"hub in non-NB={np.mean(hub_non_nb_deps):.4f}, "
|
| 570 |
+
f"MW p={p_spec:.4e}")
|
| 571 |
+
net_results["nb_specificity_p"] = float(p_spec)
|
| 572 |
+
net_results["hub_nb_mean"] = float(np.mean(hub_nb_deps))
|
| 573 |
+
net_results["hub_non_nb_mean"] = float(np.mean(hub_non_nb_deps))
|
| 574 |
+
|
| 575 |
+
# Correlation: n_targets vs NB-specific dependency
|
| 576 |
+
all_rbps_in_net = hub_counts.index.tolist()
|
| 577 |
+
n_targets_list = []
|
| 578 |
+
dep_list = []
|
| 579 |
+
for rbp in all_rbps_in_net:
|
| 580 |
+
g_upper = rbp.upper()
|
| 581 |
+
if g_upper in crispr_genes_upper:
|
| 582 |
+
cg = crispr_genes_upper[g_upper]
|
| 583 |
+
n_targets_list.append(hub_counts[rbp])
|
| 584 |
+
dep_list.append(nb_mean_dep[cg])
|
| 585 |
+
|
| 586 |
+
if len(n_targets_list) >= 5:
|
| 587 |
+
r_corr, p_corr = stats.spearmanr(n_targets_list, dep_list)
|
| 588 |
+
print(f" Corr(n_targets, NB dep): r={r_corr:.4f}, p={p_corr:.4f}")
|
| 589 |
+
net_results["ntargets_dep_spearman_r"] = float(r_corr)
|
| 590 |
+
net_results["ntargets_dep_spearman_p"] = float(p_corr)
|
| 591 |
+
|
| 592 |
+
results[net_name] = net_results
|
| 593 |
+
|
| 594 |
+
# Save results
|
| 595 |
+
with open(res_dir / "nb_specific_depmap.json", "w") as f:
|
| 596 |
+
json.dump(results, f, indent=2)
|
| 597 |
+
|
| 598 |
+
# Figure: grouped bar chart comparing NB-specific vs pan-cancer
|
| 599 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 600 |
+
|
| 601 |
+
# Panel 1: Hub vs non-hub dependency by dataset and scope
|
| 602 |
+
datasets = [n for n in ["neuroblastoma", "pancreas", "dentate_gyrus"] if n in results]
|
| 603 |
+
x = np.arange(len(datasets))
|
| 604 |
+
width = 0.2
|
| 605 |
+
|
| 606 |
+
for offset, (scope, label, color) in enumerate([
|
| 607 |
+
("nb_hub_mean_dep", "Hub (NB)", "darkred"),
|
| 608 |
+
("nb_nonhub_mean_dep", "Non-hub (NB)", "salmon"),
|
| 609 |
+
("all_hub_mean_dep", "Hub (pan-cancer)", "darkblue"),
|
| 610 |
+
("all_nonhub_mean_dep", "Non-hub (pan-cancer)", "lightblue"),
|
| 611 |
+
]):
|
| 612 |
+
vals = [results.get(d, {}).get(scope, 0) for d in datasets]
|
| 613 |
+
axes[0].bar(x + (offset - 1.5) * width, vals, width, label=label,
|
| 614 |
+
color=color, edgecolor="black", linewidth=0.3)
|
| 615 |
+
|
| 616 |
+
axes[0].set_xticks(x)
|
| 617 |
+
axes[0].set_xticklabels(datasets, fontsize=9)
|
| 618 |
+
axes[0].set_ylabel("Mean CRISPR dependency\n(more negative = more essential)")
|
| 619 |
+
axes[0].set_title("Hub RBP Essentiality: NB-Specific vs Pan-Cancer")
|
| 620 |
+
axes[0].legend(fontsize=7, loc="upper right")
|
| 621 |
+
axes[0].axhline(0, color="gray", linestyle="--", alpha=0.3)
|
| 622 |
+
|
| 623 |
+
# Panel 2: NB-specificity for NB network hubs
|
| 624 |
+
if "neuroblastoma" in results:
|
| 625 |
+
nb_res = results["neuroblastoma"]
|
| 626 |
+
categories = []
|
| 627 |
+
values = []
|
| 628 |
+
colors = []
|
| 629 |
+
if "hub_nb_mean" in nb_res:
|
| 630 |
+
categories.append("NB hub\n(in NB lines)")
|
| 631 |
+
values.append(nb_res["hub_nb_mean"])
|
| 632 |
+
colors.append("darkred")
|
| 633 |
+
if "hub_non_nb_mean" in nb_res:
|
| 634 |
+
categories.append("NB hub\n(in non-NB)")
|
| 635 |
+
values.append(nb_res["hub_non_nb_mean"])
|
| 636 |
+
colors.append("lightcoral")
|
| 637 |
+
if "nb_nonhub_mean_dep" in nb_res:
|
| 638 |
+
categories.append("Non-hub\n(in NB lines)")
|
| 639 |
+
values.append(nb_res["nb_nonhub_mean_dep"])
|
| 640 |
+
colors.append("gray")
|
| 641 |
+
|
| 642 |
+
if values:
|
| 643 |
+
axes[1].bar(categories, values, color=colors, edgecolor="black", linewidth=0.5)
|
| 644 |
+
axes[1].set_ylabel("Mean CRISPR dependency")
|
| 645 |
+
axes[1].set_title("NB Hub RBPs: Tissue-Specific Essentiality")
|
| 646 |
+
if "nb_specificity_p" in nb_res:
|
| 647 |
+
axes[1].text(0.5, 0.95, f"NB vs non-NB: p={nb_res['nb_specificity_p']:.4f}",
|
| 648 |
+
transform=axes[1].transAxes, ha="center", va="top", fontsize=9)
|
| 649 |
+
|
| 650 |
+
fig.suptitle("Neuroblastoma-Specific DepMap Validation", fontsize=13, y=1.02)
|
| 651 |
+
fig.tight_layout()
|
| 652 |
+
save_fig(fig, "nb_specific_depmap")
|
| 653 |
+
|
| 654 |
+
return results
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# =========================================================================
|
| 658 |
+
# FIX A: Per-Cell sci-fate Ablation
|
| 659 |
+
# =========================================================================
|
| 660 |
+
def fix_a_per_cell_scifate():
|
| 661 |
+
"""Compare per-cell correlations: scPTR gamma vs raw u/s ratio."""
|
| 662 |
+
print("\n" + "=" * 60)
|
| 663 |
+
print("FIX A: PER-CELL SCI-FATE ABLATION")
|
| 664 |
+
print("=" * 60)
|
| 665 |
+
|
| 666 |
+
res_dir = OUTPUT_DIR / "results"
|
| 667 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 668 |
+
|
| 669 |
+
# Import sci-fate loading functions
|
| 670 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 671 |
+
|
| 672 |
+
# Load raw sci-fate data
|
| 673 |
+
adata_raw = load_scifate_data()
|
| 674 |
+
|
| 675 |
+
# Prepare for scPTR
|
| 676 |
+
adata = prepare_for_scptr(adata_raw)
|
| 677 |
+
|
| 678 |
+
# Run scPTR pipeline
|
| 679 |
+
scptr.pp.filter_genes(adata)
|
| 680 |
+
scptr.pp.normalize_layers(adata)
|
| 681 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 682 |
+
scptr.pp.smooth_layers(adata)
|
| 683 |
+
scptr.tl.estimate_beta(adata)
|
| 684 |
+
scptr.tl.estimate_gamma(adata)
|
| 685 |
+
print(f" Pipeline complete: {adata.shape}")
|
| 686 |
+
|
| 687 |
+
# Get gamma matrix (smoothed, beta-normalized)
|
| 688 |
+
gamma = adata.layers["gamma"] # cells x genes
|
| 689 |
+
|
| 690 |
+
# Compute raw u/s ratio (unsmoothed)
|
| 691 |
+
u_layer = adata.layers.get("Mu", adata.layers.get("unspliced"))
|
| 692 |
+
s_layer = adata.layers.get("Ms", adata.layers.get("spliced"))
|
| 693 |
+
u = u_layer.toarray() if hasattr(u_layer, 'toarray') else np.asarray(u_layer)
|
| 694 |
+
s = s_layer.toarray() if hasattr(s_layer, 'toarray') else np.asarray(s_layer)
|
| 695 |
+
|
| 696 |
+
# Raw u/s ratio with same safeguard as scPTR
|
| 697 |
+
raw_ratio = np.zeros_like(gamma)
|
| 698 |
+
s_safe = np.where(s > 0.01, s, 1.0)
|
| 699 |
+
raw_ratio = u / s_safe
|
| 700 |
+
raw_ratio[s < 0.01] = 0
|
| 701 |
+
|
| 702 |
+
# Compute ground truth new/old ratio per cell
|
| 703 |
+
# Map back to the genes that survived filtering
|
| 704 |
+
total_raw = np.asarray(adata_raw.X.toarray() if hasattr(adata_raw.X, 'toarray') else adata_raw.X)
|
| 705 |
+
new_raw = np.asarray(adata_raw.layers["new"].toarray() if hasattr(adata_raw.layers["new"], 'toarray') else adata_raw.layers["new"])
|
| 706 |
+
old_raw = total_raw - new_raw
|
| 707 |
+
|
| 708 |
+
# Match genes between adata (filtered) and adata_raw
|
| 709 |
+
raw_gene_map = {g: i for i, g in enumerate(adata_raw.var_names)}
|
| 710 |
+
filtered_in_raw = [raw_gene_map[g] for g in adata.var_names if g in raw_gene_map]
|
| 711 |
+
genes_in_both = [g for g in adata.var_names if g in raw_gene_map]
|
| 712 |
+
|
| 713 |
+
if len(genes_in_both) < len(adata.var_names):
|
| 714 |
+
print(f" [WARNING] {len(adata.var_names) - len(genes_in_both)} genes not matched")
|
| 715 |
+
|
| 716 |
+
# Ground truth per cell: new/old ratio for each gene
|
| 717 |
+
gt_new = new_raw[:, filtered_in_raw]
|
| 718 |
+
gt_old = old_raw[:, filtered_in_raw]
|
| 719 |
+
gt_ratio = np.zeros_like(gt_new, dtype=float)
|
| 720 |
+
valid_gt = gt_old > 0.1
|
| 721 |
+
gt_ratio[valid_gt] = gt_new[valid_gt] / gt_old[valid_gt]
|
| 722 |
+
gt_ratio[~valid_gt] = np.nan
|
| 723 |
+
|
| 724 |
+
# Get corresponding columns from gamma and raw_ratio
|
| 725 |
+
gene_idx_in_filtered = [list(adata.var_names).index(g) for g in genes_in_both]
|
| 726 |
+
gamma_matched = gamma[:, gene_idx_in_filtered]
|
| 727 |
+
raw_matched = raw_ratio[:, gene_idx_in_filtered]
|
| 728 |
+
|
| 729 |
+
n_cells = adata.n_obs
|
| 730 |
+
print(f" Computing per-cell correlations for {n_cells} cells...")
|
| 731 |
+
|
| 732 |
+
# Per-cell: Spearman(gamma_vector, gt_vector) and Spearman(raw_vector, gt_vector)
|
| 733 |
+
gamma_corrs = np.full(n_cells, np.nan)
|
| 734 |
+
raw_corrs = np.full(n_cells, np.nan)
|
| 735 |
+
gamma_cvs = np.full(n_cells, np.nan)
|
| 736 |
+
raw_cvs = np.full(n_cells, np.nan)
|
| 737 |
+
|
| 738 |
+
min_genes_per_cell = 20
|
| 739 |
+
|
| 740 |
+
for i in range(n_cells):
|
| 741 |
+
gt_i = gt_ratio[i]
|
| 742 |
+
gamma_i = gamma_matched[i]
|
| 743 |
+
raw_i = raw_matched[i]
|
| 744 |
+
|
| 745 |
+
# Mask: need valid gt AND nonzero method value
|
| 746 |
+
valid = np.isfinite(gt_i) & (gt_i > 0) & (gamma_i > 0) & (raw_i > 0)
|
| 747 |
+
n_valid = valid.sum()
|
| 748 |
+
|
| 749 |
+
if n_valid >= min_genes_per_cell:
|
| 750 |
+
r_gamma, _ = stats.spearmanr(gamma_i[valid], gt_i[valid])
|
| 751 |
+
r_raw, _ = stats.spearmanr(raw_i[valid], gt_i[valid])
|
| 752 |
+
gamma_corrs[i] = r_gamma
|
| 753 |
+
raw_corrs[i] = r_raw
|
| 754 |
+
|
| 755 |
+
# CV: coefficient of variation (std/mean) — lower = less noisy
|
| 756 |
+
gamma_cv = np.std(gamma_i[valid]) / (np.mean(gamma_i[valid]) + 1e-10)
|
| 757 |
+
raw_cv = np.std(raw_i[valid]) / (np.mean(raw_i[valid]) + 1e-10)
|
| 758 |
+
gamma_cvs[i] = gamma_cv
|
| 759 |
+
raw_cvs[i] = raw_cv
|
| 760 |
+
|
| 761 |
+
valid_cells = np.isfinite(gamma_corrs) & np.isfinite(raw_corrs)
|
| 762 |
+
n_valid_cells = valid_cells.sum()
|
| 763 |
+
print(f" Valid cells: {n_valid_cells}/{n_cells}")
|
| 764 |
+
|
| 765 |
+
if n_valid_cells < 10:
|
| 766 |
+
print(" Too few valid cells, aborting Fix A")
|
| 767 |
+
return {}
|
| 768 |
+
|
| 769 |
+
# Summary statistics
|
| 770 |
+
mean_gamma_corr = np.nanmean(gamma_corrs[valid_cells])
|
| 771 |
+
mean_raw_corr = np.nanmean(raw_corrs[valid_cells])
|
| 772 |
+
med_gamma_corr = np.nanmedian(gamma_corrs[valid_cells])
|
| 773 |
+
med_raw_corr = np.nanmedian(raw_corrs[valid_cells])
|
| 774 |
+
|
| 775 |
+
print(f"\n Per-cell correlation with ground truth:")
|
| 776 |
+
print(f" scPTR gamma: mean={mean_gamma_corr:.4f}, median={med_gamma_corr:.4f}")
|
| 777 |
+
print(f" Raw u/s: mean={mean_raw_corr:.4f}, median={med_raw_corr:.4f}")
|
| 778 |
+
|
| 779 |
+
# Wilcoxon signed-rank test (paired)
|
| 780 |
+
w_stat, wilcox_p = stats.wilcoxon(
|
| 781 |
+
gamma_corrs[valid_cells], raw_corrs[valid_cells],
|
| 782 |
+
alternative="greater")
|
| 783 |
+
print(f" Wilcoxon signed-rank (gamma > raw): p={wilcox_p:.4e}")
|
| 784 |
+
|
| 785 |
+
# Fraction of cells where gamma beats raw
|
| 786 |
+
gamma_better = (gamma_corrs[valid_cells] > raw_corrs[valid_cells]).sum()
|
| 787 |
+
raw_better = (raw_corrs[valid_cells] > gamma_corrs[valid_cells]).sum()
|
| 788 |
+
print(f" gamma beats raw: {gamma_better}/{n_valid_cells} ({100*gamma_better/n_valid_cells:.1f}%)")
|
| 789 |
+
print(f" raw beats gamma: {raw_better}/{n_valid_cells} ({100*raw_better/n_valid_cells:.1f}%)")
|
| 790 |
+
|
| 791 |
+
# CV comparison
|
| 792 |
+
valid_cv = np.isfinite(gamma_cvs) & np.isfinite(raw_cvs)
|
| 793 |
+
if valid_cv.sum() > 10:
|
| 794 |
+
mean_gamma_cv = np.nanmean(gamma_cvs[valid_cv])
|
| 795 |
+
mean_raw_cv = np.nanmean(raw_cvs[valid_cv])
|
| 796 |
+
w_cv, cv_p = stats.wilcoxon(
|
| 797 |
+
gamma_cvs[valid_cv], raw_cvs[valid_cv],
|
| 798 |
+
alternative="less")
|
| 799 |
+
print(f"\n Coefficient of variation (noise):")
|
| 800 |
+
print(f" scPTR gamma: mean CV={mean_gamma_cv:.4f}")
|
| 801 |
+
print(f" Raw u/s: mean CV={mean_raw_cv:.4f}")
|
| 802 |
+
print(f" Wilcoxon (gamma < raw): p={cv_p:.4e}")
|
| 803 |
+
else:
|
| 804 |
+
mean_gamma_cv = np.nan
|
| 805 |
+
mean_raw_cv = np.nan
|
| 806 |
+
cv_p = np.nan
|
| 807 |
+
|
| 808 |
+
results = {
|
| 809 |
+
"n_cells_total": int(n_cells),
|
| 810 |
+
"n_cells_valid": int(n_valid_cells),
|
| 811 |
+
"mean_gamma_corr": float(mean_gamma_corr),
|
| 812 |
+
"mean_raw_corr": float(mean_raw_corr),
|
| 813 |
+
"median_gamma_corr": float(med_gamma_corr),
|
| 814 |
+
"median_raw_corr": float(med_raw_corr),
|
| 815 |
+
"wilcoxon_p": float(wilcox_p),
|
| 816 |
+
"gamma_better_frac": float(gamma_better / n_valid_cells),
|
| 817 |
+
"raw_better_frac": float(raw_better / n_valid_cells),
|
| 818 |
+
"mean_gamma_cv": float(mean_gamma_cv) if np.isfinite(mean_gamma_cv) else None,
|
| 819 |
+
"mean_raw_cv": float(mean_raw_cv) if np.isfinite(mean_raw_cv) else None,
|
| 820 |
+
"cv_wilcoxon_p": float(cv_p) if np.isfinite(cv_p) else None,
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
with open(res_dir / "per_cell_scifate.json", "w") as f:
|
| 824 |
+
json.dump(results, f, indent=2)
|
| 825 |
+
|
| 826 |
+
# Figure: paired distribution comparison
|
| 827 |
+
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
|
| 828 |
+
|
| 829 |
+
# Panel 1: histogram of per-cell correlations
|
| 830 |
+
bins = np.linspace(-0.5, 1.0, 50)
|
| 831 |
+
axes[0].hist(gamma_corrs[valid_cells], bins=bins, alpha=0.6,
|
| 832 |
+
label=f"scPTR gamma (mean={mean_gamma_corr:.3f})",
|
| 833 |
+
color="steelblue", edgecolor="white")
|
| 834 |
+
axes[0].hist(raw_corrs[valid_cells], bins=bins, alpha=0.6,
|
| 835 |
+
label=f"Raw u/s (mean={mean_raw_corr:.3f})",
|
| 836 |
+
color="salmon", edgecolor="white")
|
| 837 |
+
axes[0].set_xlabel("Per-cell Spearman r with ground truth")
|
| 838 |
+
axes[0].set_ylabel("Number of cells")
|
| 839 |
+
axes[0].set_title(f"Per-Cell Correlation with Ground Truth\n"
|
| 840 |
+
f"(Wilcoxon p={wilcox_p:.2e})")
|
| 841 |
+
axes[0].legend(fontsize=8)
|
| 842 |
+
|
| 843 |
+
# Panel 2: scatter gamma_corr vs raw_corr
|
| 844 |
+
axes[1].scatter(raw_corrs[valid_cells], gamma_corrs[valid_cells],
|
| 845 |
+
alpha=0.1, s=3, c="steelblue")
|
| 846 |
+
lims = [min(axes[1].get_xlim()[0], axes[1].get_ylim()[0]),
|
| 847 |
+
max(axes[1].get_xlim()[1], axes[1].get_ylim()[1])]
|
| 848 |
+
axes[1].plot(lims, lims, "k--", alpha=0.3, lw=1)
|
| 849 |
+
axes[1].set_xlabel("Raw u/s per-cell r")
|
| 850 |
+
axes[1].set_ylabel("scPTR gamma per-cell r")
|
| 851 |
+
axes[1].set_title(f"gamma better: {gamma_better}/{n_valid_cells} "
|
| 852 |
+
f"({100*gamma_better/n_valid_cells:.0f}%)")
|
| 853 |
+
|
| 854 |
+
# Panel 3: difference distribution
|
| 855 |
+
diff = gamma_corrs[valid_cells] - raw_corrs[valid_cells]
|
| 856 |
+
axes[2].hist(diff, bins=50, color="steelblue", alpha=0.8, edgecolor="white")
|
| 857 |
+
axes[2].axvline(0, color="red", linestyle="--", alpha=0.5)
|
| 858 |
+
axes[2].axvline(np.mean(diff), color="black", linestyle="-", alpha=0.8,
|
| 859 |
+
label=f"Mean diff={np.mean(diff):.4f}")
|
| 860 |
+
axes[2].set_xlabel("Difference (gamma r - raw r)")
|
| 861 |
+
axes[2].set_ylabel("Number of cells")
|
| 862 |
+
axes[2].set_title("Per-Cell Improvement")
|
| 863 |
+
axes[2].legend(fontsize=8)
|
| 864 |
+
|
| 865 |
+
fig.suptitle("Per-Cell sci-fate Ablation: scPTR gamma vs Raw u/s Ratio",
|
| 866 |
+
fontsize=13, y=1.02)
|
| 867 |
+
fig.tight_layout()
|
| 868 |
+
save_fig(fig, "per_cell_scifate")
|
| 869 |
+
|
| 870 |
+
return results
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
# =========================================================================
|
| 874 |
+
# FIX E: Biological Coherence Ablation
|
| 875 |
+
# =========================================================================
|
| 876 |
+
def fix_e_coherence_ablation():
|
| 877 |
+
"""Run GSEA on sub-clusters from each method to test biological coherence."""
|
| 878 |
+
print("\n" + "=" * 60)
|
| 879 |
+
print("FIX E: BIOLOGICAL COHERENCE ABLATION")
|
| 880 |
+
print("=" * 60)
|
| 881 |
+
|
| 882 |
+
from sklearn.decomposition import PCA
|
| 883 |
+
from sklearn.cluster import KMeans
|
| 884 |
+
from sklearn.metrics import silhouette_score
|
| 885 |
+
from statsmodels.stats.multitest import multipletests
|
| 886 |
+
|
| 887 |
+
res_dir = OUTPUT_DIR / "results"
|
| 888 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 889 |
+
|
| 890 |
+
# Expected tissue-appropriate pathways
|
| 891 |
+
expected_pathways = {
|
| 892 |
+
"pancreas": [
|
| 893 |
+
"endoplasmic reticulum", "autophagy", "protein folding",
|
| 894 |
+
"unfolded protein", "er stress", "insulin", "secretion",
|
| 895 |
+
"pancrea", "endocrine", "exocrine",
|
| 896 |
+
],
|
| 897 |
+
"dentate_gyrus": [
|
| 898 |
+
"synaptic", "long-term potentiation", "spliceosome", "neuron",
|
| 899 |
+
"axon", "dendrite", "glutamat", "gaba", "hippocampus",
|
| 900 |
+
"neurogenesis", "myelination",
|
| 901 |
+
],
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
all_results = []
|
| 905 |
+
pathway_details = []
|
| 906 |
+
|
| 907 |
+
for dataset_name in ["pancreas", "dentate_gyrus"]:
|
| 908 |
+
print(f"\n--- {dataset_name} ---")
|
| 909 |
+
|
| 910 |
+
# Load dataset
|
| 911 |
+
if dataset_name == "pancreas":
|
| 912 |
+
adata = scptr.datasets.pancreas()
|
| 913 |
+
else:
|
| 914 |
+
adata = scptr.datasets.dentate_gyrus()
|
| 915 |
+
|
| 916 |
+
adata = run_pipeline(adata, dataset_name)
|
| 917 |
+
|
| 918 |
+
gamma = adata.layers["gamma"]
|
| 919 |
+
clusters = adata.obs["clusters"]
|
| 920 |
+
|
| 921 |
+
# Get layers for ablation methods
|
| 922 |
+
u_layer = adata.layers.get("Mu", adata.layers.get("unspliced"))
|
| 923 |
+
s_layer = adata.layers.get("Ms", adata.layers.get("spliced"))
|
| 924 |
+
u = u_layer.toarray() if hasattr(u_layer, 'toarray') else np.asarray(u_layer)
|
| 925 |
+
s = s_layer.toarray() if hasattr(s_layer, 'toarray') else np.asarray(s_layer)
|
| 926 |
+
expr = adata.X.toarray() if hasattr(adata.X, 'toarray') else np.asarray(adata.X)
|
| 927 |
+
|
| 928 |
+
# Raw u/s ratio
|
| 929 |
+
raw_ratio = np.zeros_like(gamma)
|
| 930 |
+
s_safe = np.where(s > 0.01, s, 1.0)
|
| 931 |
+
raw_ratio = u / s_safe
|
| 932 |
+
raw_ratio[s < 0.01] = 0
|
| 933 |
+
|
| 934 |
+
methods = {
|
| 935 |
+
"scPTR_gamma": gamma,
|
| 936 |
+
"raw_u_s_ratio": raw_ratio,
|
| 937 |
+
"unspliced_only": u,
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
# Load UTR features for UTR length enrichment test
|
| 941 |
+
utr_df = pd.read_csv(
|
| 942 |
+
PROJECT_ROOT / "src" / "scptr" / "benchmark" / "data" / "mouse_utr_features.csv")
|
| 943 |
+
utr_map = {row["gene"].upper(): row for _, row in utr_df.iterrows()}
|
| 944 |
+
|
| 945 |
+
# Determine organism for GSEA
|
| 946 |
+
sample_gene = adata.var_names[0]
|
| 947 |
+
organism = "mouse" if sample_gene[0].isupper() and sample_gene[1:].islower() else "human"
|
| 948 |
+
|
| 949 |
+
for cluster_name in sorted(clusters.unique()):
|
| 950 |
+
mask = (clusters == cluster_name).values
|
| 951 |
+
n_cells = mask.sum()
|
| 952 |
+
if n_cells < 50:
|
| 953 |
+
continue
|
| 954 |
+
|
| 955 |
+
# Pre-compute expression PCA for invisibility check
|
| 956 |
+
expr_sub = expr[mask]
|
| 957 |
+
nonzero_expr = (expr_sub > 0).mean(axis=0)
|
| 958 |
+
good_expr = nonzero_expr >= 0.05
|
| 959 |
+
if good_expr.sum() < 20:
|
| 960 |
+
continue
|
| 961 |
+
n_expr_pcs = min(15, n_cells - 1, good_expr.sum() - 1)
|
| 962 |
+
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
|
| 963 |
+
expr_pcs = pca_expr.fit_transform(expr_sub[:, good_expr])
|
| 964 |
+
|
| 965 |
+
# Check if ANY method finds invisible sub-clusters
|
| 966 |
+
any_invisible = False
|
| 967 |
+
for method_name, data in methods.items():
|
| 968 |
+
data_sub = data[mask]
|
| 969 |
+
nonzero = (data_sub > 0).mean(axis=0)
|
| 970 |
+
good = nonzero >= 0.05
|
| 971 |
+
if good.sum() < 20:
|
| 972 |
+
continue
|
| 973 |
+
data_filtered = data_sub[:, good]
|
| 974 |
+
n_pcs = min(15, n_cells - 1, data_filtered.shape[1] - 1)
|
| 975 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 976 |
+
pcs = pca.fit_transform(data_filtered)
|
| 977 |
+
|
| 978 |
+
for k in [2, 3]:
|
| 979 |
+
if n_cells < k * 10:
|
| 980 |
+
continue
|
| 981 |
+
km = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 982 |
+
labels = km.fit_predict(pcs)
|
| 983 |
+
if min(np.bincount(labels)) < 10:
|
| 984 |
+
continue
|
| 985 |
+
sil = silhouette_score(pcs, labels)
|
| 986 |
+
sil_expr = silhouette_score(expr_pcs, labels)
|
| 987 |
+
if sil - sil_expr > 0.05:
|
| 988 |
+
any_invisible = True
|
| 989 |
+
break
|
| 990 |
+
if any_invisible:
|
| 991 |
+
break
|
| 992 |
+
|
| 993 |
+
if not any_invisible:
|
| 994 |
+
continue
|
| 995 |
+
|
| 996 |
+
print(f"\n {cluster_name} ({n_cells} cells) — invisible in at least one method")
|
| 997 |
+
|
| 998 |
+
for method_name, data in methods.items():
|
| 999 |
+
data_sub = data[mask]
|
| 1000 |
+
nonzero = (data_sub > 0).mean(axis=0)
|
| 1001 |
+
good = nonzero >= 0.05
|
| 1002 |
+
if good.sum() < 20:
|
| 1003 |
+
continue
|
| 1004 |
+
|
| 1005 |
+
data_filtered = data_sub[:, good]
|
| 1006 |
+
gene_names_filtered = adata.var_names[good]
|
| 1007 |
+
n_pcs = min(15, n_cells - 1, data_filtered.shape[1] - 1)
|
| 1008 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 1009 |
+
pcs = pca.fit_transform(data_filtered)
|
| 1010 |
+
|
| 1011 |
+
best_sil = -1
|
| 1012 |
+
best_labels = None
|
| 1013 |
+
best_k = 1
|
| 1014 |
+
for k in [2, 3]:
|
| 1015 |
+
if n_cells < k * 10:
|
| 1016 |
+
continue
|
| 1017 |
+
km = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 1018 |
+
labels = km.fit_predict(pcs)
|
| 1019 |
+
if min(np.bincount(labels)) < 10:
|
| 1020 |
+
continue
|
| 1021 |
+
sil = silhouette_score(pcs, labels)
|
| 1022 |
+
if sil > best_sil:
|
| 1023 |
+
best_sil = sil
|
| 1024 |
+
best_labels = labels
|
| 1025 |
+
best_k = k
|
| 1026 |
+
|
| 1027 |
+
if best_labels is None or best_k <= 1:
|
| 1028 |
+
continue
|
| 1029 |
+
|
| 1030 |
+
sil_expr_val = silhouette_score(expr_pcs, best_labels)
|
| 1031 |
+
invisibility = best_sil - sil_expr_val
|
| 1032 |
+
|
| 1033 |
+
# Find differentially degraded genes between sub-clusters
|
| 1034 |
+
diff_results = []
|
| 1035 |
+
for gi, gene in enumerate(gene_names_filtered):
|
| 1036 |
+
groups = [data_filtered[best_labels == j, gi] for j in range(best_k)]
|
| 1037 |
+
if all(len(g) >= 5 for g in groups):
|
| 1038 |
+
if best_k == 2:
|
| 1039 |
+
_, p_val = stats.mannwhitneyu(groups[0], groups[1],
|
| 1040 |
+
alternative='two-sided')
|
| 1041 |
+
else:
|
| 1042 |
+
_, p_val = stats.kruskal(*groups)
|
| 1043 |
+
|
| 1044 |
+
medians = [np.median(g) for g in groups]
|
| 1045 |
+
max_med = max(medians)
|
| 1046 |
+
min_med = min(medians)
|
| 1047 |
+
log_fc = np.log2((max_med + 0.01) / (min_med + 0.01))
|
| 1048 |
+
diff_results.append({"gene": gene, "p_value": p_val,
|
| 1049 |
+
"log2_fc": log_fc})
|
| 1050 |
+
|
| 1051 |
+
if not diff_results:
|
| 1052 |
+
continue
|
| 1053 |
+
|
| 1054 |
+
diff_df = pd.DataFrame(diff_results)
|
| 1055 |
+
_, diff_df["fdr"], _, _ = multipletests(diff_df["p_value"], method="fdr_bh")
|
| 1056 |
+
sig_genes = diff_df[diff_df["fdr"] < 0.05].sort_values("log2_fc", ascending=False)
|
| 1057 |
+
|
| 1058 |
+
gene_list = sig_genes["gene"].tolist()
|
| 1059 |
+
|
| 1060 |
+
# UTR length enrichment: sig genes vs background
|
| 1061 |
+
sig_utr_lengths = []
|
| 1062 |
+
bg_utr_lengths = []
|
| 1063 |
+
for g in gene_list:
|
| 1064 |
+
if g.upper() in utr_map:
|
| 1065 |
+
sig_utr_lengths.append(utr_map[g.upper()]["utr_length"])
|
| 1066 |
+
for g in adata.var_names:
|
| 1067 |
+
if g.upper() in utr_map:
|
| 1068 |
+
bg_utr_lengths.append(utr_map[g.upper()]["utr_length"])
|
| 1069 |
+
|
| 1070 |
+
utr_p = np.nan
|
| 1071 |
+
if len(sig_utr_lengths) >= 5 and len(bg_utr_lengths) >= 5:
|
| 1072 |
+
_, utr_p = stats.mannwhitneyu(
|
| 1073 |
+
sig_utr_lengths, bg_utr_lengths, alternative="greater")
|
| 1074 |
+
|
| 1075 |
+
# Run GSEA via gseapy Enrichr API
|
| 1076 |
+
n_sig_pathways = 0
|
| 1077 |
+
n_expected_pathways = 0
|
| 1078 |
+
pathway_terms = []
|
| 1079 |
+
|
| 1080 |
+
if len(gene_list) >= 5:
|
| 1081 |
+
try:
|
| 1082 |
+
import gseapy as gp
|
| 1083 |
+
gene_sets = ["GO_Biological_Process_2023",
|
| 1084 |
+
"KEGG_2019_Mouse" if organism == "mouse" else "KEGG_2021_Human"]
|
| 1085 |
+
|
| 1086 |
+
enr = gp.enrichr(gene_list=gene_list,
|
| 1087 |
+
gene_sets=gene_sets,
|
| 1088 |
+
organism=organism,
|
| 1089 |
+
outdir=None,
|
| 1090 |
+
no_plot=True)
|
| 1091 |
+
|
| 1092 |
+
enr_df = enr.results
|
| 1093 |
+
sig_enr = enr_df[enr_df["Adjusted P-value"] < 0.1]
|
| 1094 |
+
n_sig_pathways = len(sig_enr)
|
| 1095 |
+
|
| 1096 |
+
# Check for expected tissue pathways
|
| 1097 |
+
expected = expected_pathways.get(dataset_name, [])
|
| 1098 |
+
for _, row in sig_enr.iterrows():
|
| 1099 |
+
term_lower = row["Term"].lower()
|
| 1100 |
+
pathway_terms.append(row["Term"])
|
| 1101 |
+
for kw in expected:
|
| 1102 |
+
if kw in term_lower:
|
| 1103 |
+
n_expected_pathways += 1
|
| 1104 |
+
break
|
| 1105 |
+
|
| 1106 |
+
except Exception as e:
|
| 1107 |
+
print(f" [WARNING] GSEA failed for {method_name}/{cluster_name}: {e}")
|
| 1108 |
+
|
| 1109 |
+
result_entry = {
|
| 1110 |
+
"dataset": dataset_name,
|
| 1111 |
+
"cluster": cluster_name,
|
| 1112 |
+
"method": method_name,
|
| 1113 |
+
"n_cells": int(n_cells),
|
| 1114 |
+
"n_subclusters": int(best_k),
|
| 1115 |
+
"sil_method": float(best_sil),
|
| 1116 |
+
"sil_expr": float(sil_expr_val),
|
| 1117 |
+
"invisibility": float(invisibility),
|
| 1118 |
+
"n_diff_genes": int(len(sig_genes)),
|
| 1119 |
+
"n_sig_pathways": int(n_sig_pathways),
|
| 1120 |
+
"n_expected_pathways": int(n_expected_pathways),
|
| 1121 |
+
"mean_utr_length_sig": float(np.mean(sig_utr_lengths)) if sig_utr_lengths else None,
|
| 1122 |
+
"mean_utr_length_bg": float(np.mean(bg_utr_lengths)) if bg_utr_lengths else None,
|
| 1123 |
+
"utr_enrichment_p": float(utr_p) if np.isfinite(utr_p) else None,
|
| 1124 |
+
}
|
| 1125 |
+
all_results.append(result_entry)
|
| 1126 |
+
|
| 1127 |
+
if pathway_terms:
|
| 1128 |
+
for term in pathway_terms[:5]:
|
| 1129 |
+
pathway_details.append({
|
| 1130 |
+
"dataset": dataset_name,
|
| 1131 |
+
"cluster": cluster_name,
|
| 1132 |
+
"method": method_name,
|
| 1133 |
+
"pathway": term,
|
| 1134 |
+
})
|
| 1135 |
+
|
| 1136 |
+
print(f" {method_name}: sil={best_sil:.3f}, invis={invisibility:.3f}, "
|
| 1137 |
+
f"diff_genes={len(sig_genes)}, sig_pathways={n_sig_pathways}, "
|
| 1138 |
+
f"expected={n_expected_pathways}")
|
| 1139 |
+
|
| 1140 |
+
results_df = pd.DataFrame(all_results)
|
| 1141 |
+
results_df.to_csv(res_dir / "coherence_ablation.csv", index=False)
|
| 1142 |
+
|
| 1143 |
+
if pathway_details:
|
| 1144 |
+
pd.DataFrame(pathway_details).to_csv(
|
| 1145 |
+
res_dir / "coherence_ablation_pathways.csv", index=False)
|
| 1146 |
+
|
| 1147 |
+
# Summary
|
| 1148 |
+
if len(results_df) > 0:
|
| 1149 |
+
print("\n Summary: mean metrics by method")
|
| 1150 |
+
summary = results_df.groupby("method").agg(
|
| 1151 |
+
mean_invisibility=("invisibility", "mean"),
|
| 1152 |
+
mean_sig_pathways=("n_sig_pathways", "mean"),
|
| 1153 |
+
total_sig_pathways=("n_sig_pathways", "sum"),
|
| 1154 |
+
mean_expected=("n_expected_pathways", "mean"),
|
| 1155 |
+
total_expected=("n_expected_pathways", "sum"),
|
| 1156 |
+
mean_diff_genes=("n_diff_genes", "mean"),
|
| 1157 |
+
)
|
| 1158 |
+
for method, row in summary.iterrows():
|
| 1159 |
+
print(f" {method:<20s}: pathways={row['total_sig_pathways']:.0f} "
|
| 1160 |
+
f"(expected={row['total_expected']:.0f}), "
|
| 1161 |
+
f"diff_genes={row['mean_diff_genes']:.0f}, "
|
| 1162 |
+
f"invis={row['mean_invisibility']:.3f}")
|
| 1163 |
+
|
| 1164 |
+
# Save JSON summary
|
| 1165 |
+
json_results = {
|
| 1166 |
+
"n_clusters_tested": len(results_df["cluster"].unique()) if len(results_df) > 0 else 0,
|
| 1167 |
+
"summary_by_method": {},
|
| 1168 |
+
}
|
| 1169 |
+
if len(results_df) > 0:
|
| 1170 |
+
for method in ["scPTR_gamma", "raw_u_s_ratio", "unspliced_only"]:
|
| 1171 |
+
sub = results_df[results_df["method"] == method]
|
| 1172 |
+
if len(sub) > 0:
|
| 1173 |
+
json_results["summary_by_method"][method] = {
|
| 1174 |
+
"n_clusters": int(len(sub)),
|
| 1175 |
+
"mean_invisibility": float(sub["invisibility"].mean()),
|
| 1176 |
+
"total_sig_pathways": int(sub["n_sig_pathways"].sum()),
|
| 1177 |
+
"total_expected_pathways": int(sub["n_expected_pathways"].sum()),
|
| 1178 |
+
"mean_diff_genes": float(sub["n_diff_genes"].mean()),
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
with open(res_dir / "coherence_ablation.json", "w") as f:
|
| 1182 |
+
json.dump(json_results, f, indent=2)
|
| 1183 |
+
|
| 1184 |
+
# Figure
|
| 1185 |
+
if len(results_df) > 0:
|
| 1186 |
+
methods_order = ["unspliced_only", "raw_u_s_ratio", "scPTR_gamma"]
|
| 1187 |
+
method_labels = ["Unspliced\nonly", "Raw u/s\nratio", "scPTR\ngamma"]
|
| 1188 |
+
colors = ["lightblue", "orange", "steelblue"]
|
| 1189 |
+
|
| 1190 |
+
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
|
| 1191 |
+
|
| 1192 |
+
# Panel 1: total significant GSEA pathways
|
| 1193 |
+
vals = []
|
| 1194 |
+
for m in methods_order:
|
| 1195 |
+
sub = results_df[results_df["method"] == m]
|
| 1196 |
+
vals.append(sub["n_sig_pathways"].sum() if len(sub) > 0 else 0)
|
| 1197 |
+
axes[0].bar(method_labels, vals, color=colors, edgecolor="black", linewidth=0.5)
|
| 1198 |
+
axes[0].set_ylabel("Total significant pathways (FDR<0.1)")
|
| 1199 |
+
axes[0].set_title("GSEA Pathway Enrichment")
|
| 1200 |
+
for i, v in enumerate(vals):
|
| 1201 |
+
axes[0].text(i, v + 0.3, str(int(v)), ha="center", fontsize=10, fontweight="bold")
|
| 1202 |
+
|
| 1203 |
+
# Panel 2: expected tissue pathways
|
| 1204 |
+
vals_exp = []
|
| 1205 |
+
for m in methods_order:
|
| 1206 |
+
sub = results_df[results_df["method"] == m]
|
| 1207 |
+
vals_exp.append(sub["n_expected_pathways"].sum() if len(sub) > 0 else 0)
|
| 1208 |
+
axes[1].bar(method_labels, vals_exp, color=colors, edgecolor="black", linewidth=0.5)
|
| 1209 |
+
axes[1].set_ylabel("Tissue-appropriate pathways found")
|
| 1210 |
+
axes[1].set_title("Expected Pathway Hits")
|
| 1211 |
+
for i, v in enumerate(vals_exp):
|
| 1212 |
+
axes[1].text(i, v + 0.2, str(int(v)), ha="center", fontsize=10, fontweight="bold")
|
| 1213 |
+
|
| 1214 |
+
# Panel 3: mean invisibility
|
| 1215 |
+
vals_inv = []
|
| 1216 |
+
for m in methods_order:
|
| 1217 |
+
sub = results_df[results_df["method"] == m]
|
| 1218 |
+
vals_inv.append(sub["invisibility"].mean() if len(sub) > 0 else 0)
|
| 1219 |
+
axes[2].bar(method_labels, vals_inv, color=colors, edgecolor="black", linewidth=0.5)
|
| 1220 |
+
axes[2].set_ylabel("Mean invisibility score")
|
| 1221 |
+
axes[2].set_title("Invisibility Score")
|
| 1222 |
+
axes[2].axhline(0, color="gray", linestyle="--", alpha=0.3)
|
| 1223 |
+
|
| 1224 |
+
fig.suptitle("Biological Coherence Ablation", fontsize=13, y=1.02)
|
| 1225 |
+
fig.tight_layout()
|
| 1226 |
+
save_fig(fig, "coherence_ablation")
|
| 1227 |
+
|
| 1228 |
+
return json_results
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
+
# =========================================================================
|
| 1232 |
+
# MAIN
|
| 1233 |
+
# =========================================================================
|
| 1234 |
+
def main():
|
| 1235 |
+
set_figure_style()
|
| 1236 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 1237 |
+
(OUTPUT_DIR / "results").mkdir(parents=True, exist_ok=True)
|
| 1238 |
+
(OUTPUT_DIR / "figures").mkdir(parents=True, exist_ok=True)
|
| 1239 |
+
|
| 1240 |
+
all_results = {}
|
| 1241 |
+
|
| 1242 |
+
# Fix B (fastest — CSV only)
|
| 1243 |
+
print("\n" + "#" * 60)
|
| 1244 |
+
print("# FIX B: 3' UTR SEQUENCE VALIDATION")
|
| 1245 |
+
print("#" * 60)
|
| 1246 |
+
all_results["fix_b_utr"] = fix_b_utr_validation()
|
| 1247 |
+
|
| 1248 |
+
# Fix D (fast — CSV only)
|
| 1249 |
+
print("\n" + "#" * 60)
|
| 1250 |
+
print("# FIX D: CROSS-DATASET HUB CONSISTENCY")
|
| 1251 |
+
print("#" * 60)
|
| 1252 |
+
all_results["fix_d_hub_consistency"] = fix_d_hub_consistency()
|
| 1253 |
+
|
| 1254 |
+
# Fix C (moderate — loads large CSV)
|
| 1255 |
+
print("\n" + "#" * 60)
|
| 1256 |
+
print("# FIX C: NEUROBLASTOMA-SPECIFIC DepMap")
|
| 1257 |
+
print("#" * 60)
|
| 1258 |
+
all_results["fix_c_nb_depmap"] = fix_c_nb_depmap()
|
| 1259 |
+
|
| 1260 |
+
# Fix A (moderate — loads sci-fate data)
|
| 1261 |
+
print("\n" + "#" * 60)
|
| 1262 |
+
print("# FIX A: PER-CELL SCI-FATE ABLATION")
|
| 1263 |
+
print("#" * 60)
|
| 1264 |
+
all_results["fix_a_per_cell"] = fix_a_per_cell_scifate()
|
| 1265 |
+
|
| 1266 |
+
# Fix E (slowest — loads 2 datasets + GSEA API)
|
| 1267 |
+
print("\n" + "#" * 60)
|
| 1268 |
+
print("# FIX E: BIOLOGICAL COHERENCE ABLATION")
|
| 1269 |
+
print("#" * 60)
|
| 1270 |
+
all_results["fix_e_coherence"] = fix_e_coherence_ablation()
|
| 1271 |
+
|
| 1272 |
+
# Save combined results
|
| 1273 |
+
with open(OUTPUT_DIR / "results" / "all_comprehensive_fixes.json", "w") as f:
|
| 1274 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 1275 |
+
|
| 1276 |
+
print("\n" + "=" * 60)
|
| 1277 |
+
print("ALL COMPREHENSIVE FIXES COMPLETE")
|
| 1278 |
+
print("=" * 60)
|
| 1279 |
+
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
if __name__ == "__main__":
|
| 1283 |
+
main()
|
analyses/run_comprehensive_improvements.py
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Comprehensive improvements addressing 5 remaining weaknesses.
|
| 3 |
+
|
| 4 |
+
Experiment A: Network Target GO Enrichment (Weakness #1 — network validation)
|
| 5 |
+
Experiment B: Pathway-Level Cross-Dataset Consistency (Weakness #4 — low gene-level r)
|
| 6 |
+
Experiment C: Gamma vs Raw u/s on Downstream Tasks (Weakness #2 — marginal advantage)
|
| 7 |
+
Experiment D: NB Network Split-Half Robustness (Weakness #3 — single patient)
|
| 8 |
+
Experiment E: Corrected vs Uncorrected Network Quality (Weakness #5 — destabilizing bias)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import sys
|
| 15 |
+
import warnings
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use("Agg")
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from scipy import stats
|
| 24 |
+
from scipy.stats import hypergeom
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 27 |
+
from _common import set_figure_style
|
| 28 |
+
|
| 29 |
+
import scptr
|
| 30 |
+
|
| 31 |
+
# Force unbuffered stdout for progress visibility
|
| 32 |
+
sys.stdout.reconfigure(line_buffering=True)
|
| 33 |
+
|
| 34 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "comprehensive_improvements"
|
| 35 |
+
CACHE_DIR = Path(__file__).parent.parent / ".cache"
|
| 36 |
+
WEAKNESS_DIR = Path(__file__).parent.parent / "output" / "weakness_fixes" / "results"
|
| 37 |
+
TIER3_DIR = Path(__file__).parent.parent / "output" / "tier3" / "results"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_go_library():
|
| 41 |
+
"""Load GO BP gene sets from local cache (no network calls)."""
|
| 42 |
+
cache_file = CACHE_DIR / "go_bp_2023.json"
|
| 43 |
+
if cache_file.exists():
|
| 44 |
+
with open(cache_file) as f:
|
| 45 |
+
go_lib = json.load(f)
|
| 46 |
+
return go_lib
|
| 47 |
+
|
| 48 |
+
# Fallback: try to download and cache
|
| 49 |
+
try:
|
| 50 |
+
import gseapy as gp
|
| 51 |
+
go_lib = gp.get_library("GO_Biological_Process_2023")
|
| 52 |
+
with open(cache_file, "w") as f:
|
| 53 |
+
json.dump(go_lib, f)
|
| 54 |
+
return go_lib
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f" Failed to load GO library: {e}")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def hypergeometric_enrichment(gene_list, go_lib, background_size, alpha=0.05):
|
| 61 |
+
"""Run local hypergeometric GO enrichment (no API calls).
|
| 62 |
+
|
| 63 |
+
Returns list of (term, p_value, overlap, term_size) for significant terms.
|
| 64 |
+
"""
|
| 65 |
+
gene_set = set(g.upper() for g in gene_list)
|
| 66 |
+
k = len(gene_set) # drawn genes
|
| 67 |
+
N = background_size # population size
|
| 68 |
+
|
| 69 |
+
results = []
|
| 70 |
+
for term_name, term_genes in go_lib.items():
|
| 71 |
+
term_upper = set(g.upper() for g in term_genes)
|
| 72 |
+
K = len(term_upper) # successes in population
|
| 73 |
+
if K < 5 or K > N * 0.5: # skip very small or very large terms
|
| 74 |
+
continue
|
| 75 |
+
overlap = gene_set & term_upper
|
| 76 |
+
x = len(overlap)
|
| 77 |
+
if x < 2:
|
| 78 |
+
continue
|
| 79 |
+
# P(X >= x) under hypergeometric
|
| 80 |
+
p_val = hypergeom.sf(x - 1, N, K, k)
|
| 81 |
+
results.append((term_name, p_val, x, K))
|
| 82 |
+
|
| 83 |
+
# BH correction
|
| 84 |
+
if not results:
|
| 85 |
+
return []
|
| 86 |
+
results.sort(key=lambda r: r[1])
|
| 87 |
+
n_tests = len(results)
|
| 88 |
+
corrected = []
|
| 89 |
+
for i, (term, p, overlap, size) in enumerate(results):
|
| 90 |
+
adj_p = p * n_tests / (i + 1)
|
| 91 |
+
corrected.append((term, adj_p, overlap, size))
|
| 92 |
+
|
| 93 |
+
# Enforce monotonicity
|
| 94 |
+
min_p = 1.0
|
| 95 |
+
for i in range(len(corrected) - 1, -1, -1):
|
| 96 |
+
min_p = min(min_p, corrected[i][1])
|
| 97 |
+
corrected[i] = (corrected[i][0], min_p, corrected[i][2], corrected[i][3])
|
| 98 |
+
|
| 99 |
+
sig = [(t, p, o, s) for t, p, o, s in corrected if p < alpha]
|
| 100 |
+
return sig
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def save_fig(fig, name, subdir="figures"):
|
| 104 |
+
out_dir = OUTPUT_DIR / subdir
|
| 105 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
path = out_dir / f"{name}.png"
|
| 107 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 108 |
+
plt.close(fig)
|
| 109 |
+
print(f" Saved: {path}")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def run_pipeline(adata, name):
|
| 113 |
+
"""Run standard scPTR pipeline."""
|
| 114 |
+
print(f"\n--- Pipeline: {name} ---")
|
| 115 |
+
scptr.pp.filter_genes(adata)
|
| 116 |
+
scptr.pp.normalize_layers(adata)
|
| 117 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 118 |
+
scptr.pp.smooth_layers(adata)
|
| 119 |
+
scptr.tl.estimate_beta(adata)
|
| 120 |
+
scptr.tl.estimate_gamma(adata)
|
| 121 |
+
scptr.tl.variance_decomposition(adata)
|
| 122 |
+
scptr.tl.pt_states(adata)
|
| 123 |
+
scptr.tl.pt_velocity(adata)
|
| 124 |
+
print(f" Done: {adata.shape}")
|
| 125 |
+
return adata
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def get_expression(adata):
|
| 129 |
+
if hasattr(adata.X, 'toarray'):
|
| 130 |
+
return adata.X.toarray()
|
| 131 |
+
return np.asarray(adata.X)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def get_rbps_in_data(adata):
|
| 135 |
+
rbp_path = Path(__file__).parent.parent / "src" / "scptr" / "tools" / "data" / "known_rbps.csv"
|
| 136 |
+
rbps = pd.read_csv(rbp_path)["gene_symbol"].tolist()
|
| 137 |
+
gene_map = {g.upper(): i for i, g in enumerate(adata.var_names)}
|
| 138 |
+
result = {}
|
| 139 |
+
for r in rbps:
|
| 140 |
+
if r.upper() in gene_map:
|
| 141 |
+
result[r.upper()] = gene_map[r.upper()]
|
| 142 |
+
return result
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def get_target_indices(adata, n_targets=200):
|
| 146 |
+
gamma = adata.layers["gamma"]
|
| 147 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 148 |
+
informative = nonzero_frac >= 0.1
|
| 149 |
+
gamma_var = np.var(gamma[:, informative], axis=0)
|
| 150 |
+
n = min(n_targets, informative.sum())
|
| 151 |
+
top_idx = np.argsort(gamma_var)[-n:]
|
| 152 |
+
return np.where(informative)[0][top_idx]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# =========================================================================
|
| 156 |
+
# EXPERIMENT A: Network Target GO Enrichment
|
| 157 |
+
# =========================================================================
|
| 158 |
+
def experiment_a_go_enrichment():
|
| 159 |
+
"""Test whether predicted RBP targets share biological functions (GO enrichment).
|
| 160 |
+
|
| 161 |
+
Uses local hypergeometric tests with cached GO BP gene sets — no API calls.
|
| 162 |
+
"""
|
| 163 |
+
print(f"\n{'='*60}")
|
| 164 |
+
print("EXPERIMENT A: NETWORK TARGET GO ENRICHMENT")
|
| 165 |
+
print(f"{'='*60}")
|
| 166 |
+
|
| 167 |
+
res_dir = OUTPUT_DIR / "results"
|
| 168 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 169 |
+
|
| 170 |
+
# Load GO gene sets from local cache
|
| 171 |
+
print(" Loading GO Biological Process gene sets (local cache)...")
|
| 172 |
+
go_lib = load_go_library()
|
| 173 |
+
if go_lib is None:
|
| 174 |
+
return None
|
| 175 |
+
print(f" Loaded {len(go_lib)} GO BP terms")
|
| 176 |
+
|
| 177 |
+
# Load corrected networks
|
| 178 |
+
networks = {}
|
| 179 |
+
network_files = {
|
| 180 |
+
"pancreas": WEAKNESS_DIR / "corrected_network_pancreas.csv",
|
| 181 |
+
"dentate_gyrus": WEAKNESS_DIR / "corrected_network_dentate_gyrus.csv",
|
| 182 |
+
"neuroblastoma": TIER3_DIR / "neuroblastoma_network_corrected.csv",
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
for name, path in network_files.items():
|
| 186 |
+
if path.exists():
|
| 187 |
+
df = pd.read_csv(path)
|
| 188 |
+
print(f" {name}: {len(df)} edges")
|
| 189 |
+
networks[name] = df
|
| 190 |
+
else:
|
| 191 |
+
print(f" {name}: file not found at {path}")
|
| 192 |
+
|
| 193 |
+
# Estimate background gene count per organism
|
| 194 |
+
# Use ~20,000 as a reasonable genome-wide background
|
| 195 |
+
BACKGROUND_SIZE = 20000
|
| 196 |
+
|
| 197 |
+
all_results = {}
|
| 198 |
+
|
| 199 |
+
for ds_name, edges_df in networks.items():
|
| 200 |
+
print(f"\n --- {ds_name} ---")
|
| 201 |
+
|
| 202 |
+
rbp_col = "rbp"
|
| 203 |
+
target_col = "target"
|
| 204 |
+
|
| 205 |
+
# Build RBP -> target sets
|
| 206 |
+
rbp_targets = {}
|
| 207 |
+
for rbp, grp in edges_df.groupby(rbp_col):
|
| 208 |
+
targets = set(grp[target_col].tolist())
|
| 209 |
+
rbp_targets[rbp] = targets
|
| 210 |
+
|
| 211 |
+
# Background gene set (all unique targets in network)
|
| 212 |
+
all_targets = set()
|
| 213 |
+
for t in rbp_targets.values():
|
| 214 |
+
all_targets |= t
|
| 215 |
+
|
| 216 |
+
# Filter to RBPs with >= 10 targets
|
| 217 |
+
eligible_rbps = {r: t for r, t in rbp_targets.items() if len(t) >= 10}
|
| 218 |
+
print(f" RBPs with >= 10 targets: {len(eligible_rbps)}")
|
| 219 |
+
|
| 220 |
+
if not eligible_rbps:
|
| 221 |
+
all_results[ds_name] = {"n_eligible_rbps": 0}
|
| 222 |
+
continue
|
| 223 |
+
|
| 224 |
+
# Run local hypergeometric enrichment for each eligible RBP
|
| 225 |
+
rbp_enrichment_results = []
|
| 226 |
+
n_with_sig = 0
|
| 227 |
+
|
| 228 |
+
for rbp, targets in eligible_rbps.items():
|
| 229 |
+
gene_list = list(targets)
|
| 230 |
+
sig_terms = hypergeometric_enrichment(gene_list, go_lib, BACKGROUND_SIZE)
|
| 231 |
+
n_sig = len(sig_terms)
|
| 232 |
+
has_sig = n_sig > 0
|
| 233 |
+
if has_sig:
|
| 234 |
+
n_with_sig += 1
|
| 235 |
+
|
| 236 |
+
top_terms = [t[0] for t in sig_terms[:5]]
|
| 237 |
+
|
| 238 |
+
rbp_enrichment_results.append({
|
| 239 |
+
"rbp": rbp,
|
| 240 |
+
"n_targets": len(targets),
|
| 241 |
+
"n_sig_terms": n_sig,
|
| 242 |
+
"has_sig": has_sig,
|
| 243 |
+
"top_terms": top_terms,
|
| 244 |
+
})
|
| 245 |
+
|
| 246 |
+
frac_with_sig = n_with_sig / max(len(eligible_rbps), 1)
|
| 247 |
+
print(f" RBPs with >= 1 significant GO term: {n_with_sig}/{len(eligible_rbps)} ({frac_with_sig:.1%})")
|
| 248 |
+
|
| 249 |
+
# Known biology concordance
|
| 250 |
+
known_biology = {
|
| 251 |
+
"ELAVL1": ["mRNA stability", "mRNA stabilization", "RNA stability"],
|
| 252 |
+
"RBFOX1": ["neuron", "neuronal", "synap", "axon"],
|
| 253 |
+
"RBFOX2": ["neuron", "neuronal", "synap", "splicing"],
|
| 254 |
+
"RBFOX3": ["neuron", "neuronal", "synap"],
|
| 255 |
+
"SRSF3": ["splic", "mRNA processing", "RNA processing"],
|
| 256 |
+
"HNRNPA1": ["splic", "mRNA processing", "RNA processing"],
|
| 257 |
+
"YBX1": ["translation", "mRNA", "RNA"],
|
| 258 |
+
"CELF2": ["splic", "neuron", "mRNA"],
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
concordance_hits = []
|
| 262 |
+
for rbp_res in rbp_enrichment_results:
|
| 263 |
+
rbp = rbp_res["rbp"]
|
| 264 |
+
if rbp in known_biology and rbp_res["top_terms"]:
|
| 265 |
+
expected_keywords = known_biology[rbp]
|
| 266 |
+
all_terms_str = " ".join(rbp_res["top_terms"]).lower()
|
| 267 |
+
matched = [kw for kw in expected_keywords if kw.lower() in all_terms_str]
|
| 268 |
+
if matched:
|
| 269 |
+
concordance_hits.append({"rbp": rbp, "matched_keywords": matched})
|
| 270 |
+
print(f" Known biology match: {rbp} -> {matched}")
|
| 271 |
+
|
| 272 |
+
# Cross-RBP specificity (Jaccard between enriched GO term sets)
|
| 273 |
+
enriched_term_sets = {}
|
| 274 |
+
for rbp_res in rbp_enrichment_results:
|
| 275 |
+
if rbp_res["top_terms"]:
|
| 276 |
+
enriched_term_sets[rbp_res["rbp"]] = set(rbp_res["top_terms"])
|
| 277 |
+
|
| 278 |
+
jaccard_values = []
|
| 279 |
+
rbp_list = list(enriched_term_sets.keys())
|
| 280 |
+
for i in range(len(rbp_list)):
|
| 281 |
+
for j in range(i + 1, len(rbp_list)):
|
| 282 |
+
s1 = enriched_term_sets[rbp_list[i]]
|
| 283 |
+
s2 = enriched_term_sets[rbp_list[j]]
|
| 284 |
+
union = s1 | s2
|
| 285 |
+
if union:
|
| 286 |
+
jaccard_values.append(len(s1 & s2) / len(union))
|
| 287 |
+
|
| 288 |
+
mean_jaccard = np.mean(jaccard_values) if jaccard_values else 0
|
| 289 |
+
print(f" Cross-RBP GO term Jaccard (specificity): {mean_jaccard:.3f} (lower = more specific)")
|
| 290 |
+
|
| 291 |
+
# Bootstrap null: random gene sets from GENOME-WIDE background
|
| 292 |
+
# (not from network targets, which are already enriched for biology)
|
| 293 |
+
print(f" Running bootstrap null (100 random genome-wide sets per RBP)...")
|
| 294 |
+
n_bootstrap = 100
|
| 295 |
+
rng = np.random.RandomState(42)
|
| 296 |
+
# Build genome-wide gene list from GO library (covers ~20K genes)
|
| 297 |
+
genome_genes = set()
|
| 298 |
+
for genes in go_lib.values():
|
| 299 |
+
genome_genes.update(g.upper() for g in genes)
|
| 300 |
+
genome_genes_list = sorted(genome_genes)
|
| 301 |
+
bootstrap_fracs = []
|
| 302 |
+
|
| 303 |
+
test_rbps = list(eligible_rbps.items())[:min(10, len(eligible_rbps))]
|
| 304 |
+
for rbp, targets in test_rbps:
|
| 305 |
+
n_t = len(targets)
|
| 306 |
+
null_sig_count = 0
|
| 307 |
+
for _ in range(n_bootstrap):
|
| 308 |
+
random_genes = rng.choice(genome_genes_list,
|
| 309 |
+
size=min(n_t, len(genome_genes_list)),
|
| 310 |
+
replace=False).tolist()
|
| 311 |
+
sig_null = hypergeometric_enrichment(random_genes, go_lib, BACKGROUND_SIZE)
|
| 312 |
+
if sig_null:
|
| 313 |
+
null_sig_count += 1
|
| 314 |
+
bootstrap_fracs.append(null_sig_count / n_bootstrap)
|
| 315 |
+
|
| 316 |
+
mean_null_frac = np.mean(bootstrap_fracs) if bootstrap_fracs else 0
|
| 317 |
+
print(f" Bootstrap null fraction with sig GO term: {mean_null_frac:.3f}")
|
| 318 |
+
print(f" Enrichment over null: {frac_with_sig / max(mean_null_frac, 0.01):.1f}x")
|
| 319 |
+
|
| 320 |
+
all_results[ds_name] = {
|
| 321 |
+
"n_eligible_rbps": len(eligible_rbps),
|
| 322 |
+
"n_with_sig_go": n_with_sig,
|
| 323 |
+
"frac_with_sig_go": float(frac_with_sig),
|
| 324 |
+
"mean_cross_rbp_jaccard": float(mean_jaccard),
|
| 325 |
+
"n_known_biology_matches": len(concordance_hits),
|
| 326 |
+
"concordance_hits": concordance_hits,
|
| 327 |
+
"bootstrap_null_frac": float(mean_null_frac),
|
| 328 |
+
"per_rbp": rbp_enrichment_results,
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
# Save results
|
| 332 |
+
with open(res_dir / "go_enrichment.json", "w") as f:
|
| 333 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 334 |
+
|
| 335 |
+
# Summary figure
|
| 336 |
+
ds_names = list(all_results.keys())
|
| 337 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 338 |
+
|
| 339 |
+
# Panel 1: Fraction with significant GO terms
|
| 340 |
+
fracs = [all_results[d].get("frac_with_sig_go", 0) for d in ds_names]
|
| 341 |
+
null_fracs = [all_results[d].get("bootstrap_null_frac", 0) for d in ds_names]
|
| 342 |
+
x = np.arange(len(ds_names))
|
| 343 |
+
width = 0.35
|
| 344 |
+
axes[0].bar(x - width / 2, fracs, width, label="Real RBP targets",
|
| 345 |
+
color="#1976D2", edgecolor="black", linewidth=0.5)
|
| 346 |
+
axes[0].bar(x + width / 2, null_fracs, width, label="Random gene sets (null)",
|
| 347 |
+
color="#BDBDBD", edgecolor="black", linewidth=0.5)
|
| 348 |
+
axes[0].set_xticks(x)
|
| 349 |
+
axes[0].set_xticklabels(ds_names, fontsize=9)
|
| 350 |
+
axes[0].set_ylabel("Fraction with >= 1 sig GO term")
|
| 351 |
+
axes[0].set_title("GO Enrichment: Real vs Random Targets")
|
| 352 |
+
axes[0].legend(fontsize=8)
|
| 353 |
+
axes[0].set_ylim(0, 1.1)
|
| 354 |
+
for i, (f, n) in enumerate(zip(fracs, null_fracs)):
|
| 355 |
+
axes[0].text(i - width / 2, f + 0.02, f"{f:.0%}", ha="center", fontsize=8)
|
| 356 |
+
axes[0].text(i + width / 2, n + 0.02, f"{n:.0%}", ha="center", fontsize=8)
|
| 357 |
+
|
| 358 |
+
# Panel 2: Cross-RBP Jaccard (specificity)
|
| 359 |
+
jaccards = [all_results[d].get("mean_cross_rbp_jaccard", 0) for d in ds_names]
|
| 360 |
+
axes[1].bar(x, jaccards, color="#43A047", edgecolor="black", linewidth=0.5)
|
| 361 |
+
axes[1].set_xticks(x)
|
| 362 |
+
axes[1].set_xticklabels(ds_names, fontsize=9)
|
| 363 |
+
axes[1].set_ylabel("Mean Jaccard (lower = more specific)")
|
| 364 |
+
axes[1].set_title("Cross-RBP GO Term Specificity")
|
| 365 |
+
for i, j in enumerate(jaccards):
|
| 366 |
+
axes[1].text(i, j + 0.005, f"{j:.3f}", ha="center", fontsize=9)
|
| 367 |
+
|
| 368 |
+
fig.suptitle("Experiment A: Network Target GO Enrichment", fontsize=13)
|
| 369 |
+
fig.tight_layout()
|
| 370 |
+
save_fig(fig, "experiment_a_go_enrichment")
|
| 371 |
+
|
| 372 |
+
# Print summary
|
| 373 |
+
print(f"\n EXPERIMENT A SUMMARY:")
|
| 374 |
+
for ds_name, res in all_results.items():
|
| 375 |
+
print(f" {ds_name}: {res.get('frac_with_sig_go', 0):.0%} RBPs with sig GO terms "
|
| 376 |
+
f"(null: {res.get('bootstrap_null_frac', 0):.0%}, "
|
| 377 |
+
f"concordance: {res.get('n_known_biology_matches', 0)} hits)")
|
| 378 |
+
|
| 379 |
+
return all_results
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
# =========================================================================
|
| 383 |
+
# EXPERIMENT B: Pathway-Level Cross-Dataset Consistency
|
| 384 |
+
# =========================================================================
|
| 385 |
+
def experiment_b_pathway_consistency(datasets):
|
| 386 |
+
"""Show pathway-level gamma consistency is higher than gene-level."""
|
| 387 |
+
print(f"\n{'='*60}")
|
| 388 |
+
print("EXPERIMENT B: PATHWAY-LEVEL CROSS-DATASET CONSISTENCY")
|
| 389 |
+
print(f"{'='*60}")
|
| 390 |
+
|
| 391 |
+
res_dir = OUTPUT_DIR / "results"
|
| 392 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 393 |
+
|
| 394 |
+
# Load GO gene sets from local cache
|
| 395 |
+
print(" Loading GO Biological Process gene sets (local cache)...")
|
| 396 |
+
go_lib = load_go_library()
|
| 397 |
+
if go_lib is None:
|
| 398 |
+
return None
|
| 399 |
+
print(f" Loaded {len(go_lib)} GO BP terms")
|
| 400 |
+
|
| 401 |
+
# Compute per-gene median gamma for each dataset
|
| 402 |
+
gamma_medians = {}
|
| 403 |
+
for name, adata in datasets.items():
|
| 404 |
+
gamma = adata.layers["gamma"]
|
| 405 |
+
gamma_med = np.median(gamma, axis=0)
|
| 406 |
+
gamma_medians[name] = pd.Series(gamma_med, index=[g.upper() for g in adata.var_names])
|
| 407 |
+
|
| 408 |
+
names = sorted(datasets.keys())
|
| 409 |
+
results = []
|
| 410 |
+
|
| 411 |
+
for i, name_a in enumerate(names):
|
| 412 |
+
for name_b in names[i + 1:]:
|
| 413 |
+
print(f"\n --- {name_a} vs {name_b} ---")
|
| 414 |
+
|
| 415 |
+
ga = gamma_medians[name_a]
|
| 416 |
+
gb = gamma_medians[name_b]
|
| 417 |
+
|
| 418 |
+
# Shared genes
|
| 419 |
+
shared = sorted(set(ga.index) & set(gb.index))
|
| 420 |
+
if len(shared) < 50:
|
| 421 |
+
continue
|
| 422 |
+
|
| 423 |
+
# Gene-level correlation (baseline)
|
| 424 |
+
ga_shared = ga[shared].values
|
| 425 |
+
gb_shared = gb[shared].values
|
| 426 |
+
valid = np.isfinite(ga_shared) & np.isfinite(gb_shared)
|
| 427 |
+
r_gene, p_gene = stats.spearmanr(ga_shared[valid], gb_shared[valid])
|
| 428 |
+
print(f" Gene-level Spearman r: {r_gene:.4f} (n={valid.sum()})")
|
| 429 |
+
|
| 430 |
+
# Pathway-level: for each GO term with >= 10 shared genes,
|
| 431 |
+
# compute mean gamma in each dataset
|
| 432 |
+
pathway_gamma_a = []
|
| 433 |
+
pathway_gamma_b = []
|
| 434 |
+
pathway_names = []
|
| 435 |
+
pathway_sizes = []
|
| 436 |
+
|
| 437 |
+
for term_name, term_genes in go_lib.items():
|
| 438 |
+
# Convert term genes to uppercase for matching
|
| 439 |
+
term_genes_upper = set(g.upper() for g in term_genes)
|
| 440 |
+
term_shared = term_genes_upper & set(shared)
|
| 441 |
+
|
| 442 |
+
if len(term_shared) < 10:
|
| 443 |
+
continue
|
| 444 |
+
|
| 445 |
+
genes_list = sorted(term_shared)
|
| 446 |
+
idx = [shared.index(g) for g in genes_list]
|
| 447 |
+
|
| 448 |
+
mean_a = np.mean(ga_shared[idx])
|
| 449 |
+
mean_b = np.mean(gb_shared[idx])
|
| 450 |
+
|
| 451 |
+
if np.isfinite(mean_a) and np.isfinite(mean_b):
|
| 452 |
+
pathway_gamma_a.append(mean_a)
|
| 453 |
+
pathway_gamma_b.append(mean_b)
|
| 454 |
+
pathway_names.append(term_name)
|
| 455 |
+
pathway_sizes.append(len(term_shared))
|
| 456 |
+
|
| 457 |
+
if len(pathway_gamma_a) < 20:
|
| 458 |
+
print(f" Too few pathways with >= 10 shared genes: {len(pathway_gamma_a)}")
|
| 459 |
+
continue
|
| 460 |
+
|
| 461 |
+
r_pathway, p_pathway = stats.spearmanr(pathway_gamma_a, pathway_gamma_b)
|
| 462 |
+
print(f" Pathway-level Spearman r: {r_pathway:.4f} (n={len(pathway_gamma_a)} pathways)")
|
| 463 |
+
print(f" Improvement: {r_pathway:.3f} vs {r_gene:.3f} (gene-level)")
|
| 464 |
+
|
| 465 |
+
results.append({
|
| 466 |
+
"pair": f"{name_a} vs {name_b}",
|
| 467 |
+
"gene_level_r": float(r_gene),
|
| 468 |
+
"gene_level_p": float(p_gene),
|
| 469 |
+
"n_shared_genes": int(valid.sum()),
|
| 470 |
+
"pathway_level_r": float(r_pathway),
|
| 471 |
+
"pathway_level_p": float(p_pathway),
|
| 472 |
+
"n_pathways": len(pathway_gamma_a),
|
| 473 |
+
"mean_pathway_size": float(np.mean(pathway_sizes)),
|
| 474 |
+
})
|
| 475 |
+
|
| 476 |
+
# Save results
|
| 477 |
+
with open(res_dir / "pathway_consistency.json", "w") as f:
|
| 478 |
+
json.dump(results, f, indent=2)
|
| 479 |
+
|
| 480 |
+
# Summary figure
|
| 481 |
+
if results:
|
| 482 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 483 |
+
pairs = [r["pair"] for r in results]
|
| 484 |
+
gene_rs = [r["gene_level_r"] for r in results]
|
| 485 |
+
pathway_rs = [r["pathway_level_r"] for r in results]
|
| 486 |
+
|
| 487 |
+
x = np.arange(len(pairs))
|
| 488 |
+
width = 0.35
|
| 489 |
+
ax.bar(x - width / 2, gene_rs, width, label="Gene-level",
|
| 490 |
+
color="#E53935", edgecolor="black", linewidth=0.5)
|
| 491 |
+
ax.bar(x + width / 2, pathway_rs, width, label="Pathway-level",
|
| 492 |
+
color="#1976D2", edgecolor="black", linewidth=0.5)
|
| 493 |
+
ax.set_xticks(x)
|
| 494 |
+
ax.set_xticklabels([p.replace(" vs ", "\nvs\n") for p in pairs], fontsize=8)
|
| 495 |
+
ax.set_ylabel("Spearman r")
|
| 496 |
+
ax.set_title("Gamma Consistency: Gene vs Pathway Level")
|
| 497 |
+
ax.legend()
|
| 498 |
+
for i, (g, p) in enumerate(zip(gene_rs, pathway_rs)):
|
| 499 |
+
ax.text(i - width / 2, g + 0.01, f"{g:.3f}", ha="center", fontsize=8)
|
| 500 |
+
ax.text(i + width / 2, p + 0.01, f"{p:.3f}", ha="center", fontsize=8)
|
| 501 |
+
|
| 502 |
+
fig.tight_layout()
|
| 503 |
+
save_fig(fig, "experiment_b_pathway_consistency")
|
| 504 |
+
|
| 505 |
+
print(f"\n EXPERIMENT B SUMMARY:")
|
| 506 |
+
for r in results:
|
| 507 |
+
print(f" {r['pair']}: gene r={r['gene_level_r']:.3f} -> pathway r={r['pathway_level_r']:.3f} "
|
| 508 |
+
f"({r['n_pathways']} pathways)")
|
| 509 |
+
|
| 510 |
+
return results
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
# =========================================================================
|
| 514 |
+
# EXPERIMENT C: Gamma vs Raw u/s on Downstream Tasks
|
| 515 |
+
# =========================================================================
|
| 516 |
+
def experiment_c_gamma_advantage(datasets):
|
| 517 |
+
"""Demonstrate gamma's downstream task advantage over raw u/s ratio."""
|
| 518 |
+
print(f"\n{'='*60}")
|
| 519 |
+
print("EXPERIMENT C: GAMMA vs RAW U/S ON DOWNSTREAM TASKS")
|
| 520 |
+
print(f"{'='*60}")
|
| 521 |
+
|
| 522 |
+
from sklearn.decomposition import PCA
|
| 523 |
+
from sklearn.cluster import KMeans
|
| 524 |
+
from sklearn.metrics import silhouette_score
|
| 525 |
+
|
| 526 |
+
res_dir = OUTPUT_DIR / "results"
|
| 527 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 528 |
+
|
| 529 |
+
all_results = {}
|
| 530 |
+
|
| 531 |
+
for ds_name, adata in datasets.items():
|
| 532 |
+
if ds_name == "scifate":
|
| 533 |
+
continue # Only pancreas and DG have expression clusters for comparison
|
| 534 |
+
print(f"\n --- {ds_name} ---")
|
| 535 |
+
|
| 536 |
+
gamma = adata.layers["gamma"]
|
| 537 |
+
Ms = adata.layers["Ms"]
|
| 538 |
+
Mu = adata.layers["Mu"]
|
| 539 |
+
|
| 540 |
+
# Construct smooth_ratio: same as gamma but WITHOUT beta multiplication
|
| 541 |
+
reliable = Ms >= 0.01
|
| 542 |
+
smooth_ratio = np.where(reliable, Mu / np.where(reliable, Ms, 1.0), 0.0)
|
| 543 |
+
|
| 544 |
+
# Same per-gene 99th percentile clip as gamma
|
| 545 |
+
for gi in range(smooth_ratio.shape[1]):
|
| 546 |
+
col = smooth_ratio[:, gi]
|
| 547 |
+
pos = col[col > 0]
|
| 548 |
+
if len(pos) > 10:
|
| 549 |
+
cap = np.percentile(pos, 99)
|
| 550 |
+
smooth_ratio[:, gi] = np.clip(col, 0, cap)
|
| 551 |
+
|
| 552 |
+
# Global cap at 10x 99th percentile of gene medians
|
| 553 |
+
gene_medians = np.median(smooth_ratio, axis=0)
|
| 554 |
+
pos_medians = gene_medians[gene_medians > 0]
|
| 555 |
+
if len(pos_medians) > 0:
|
| 556 |
+
global_cap = 10 * np.percentile(pos_medians, 99)
|
| 557 |
+
smooth_ratio = np.clip(smooth_ratio, 0, global_cap)
|
| 558 |
+
|
| 559 |
+
print(f" Gamma shape: {gamma.shape}, max={gamma.max():.4f}")
|
| 560 |
+
print(f" Smooth ratio shape: {smooth_ratio.shape}, max={smooth_ratio.max():.4f}")
|
| 561 |
+
|
| 562 |
+
# Get expression clusters
|
| 563 |
+
clusters = adata.obs.get("clusters", adata.obs.get("cell_type"))
|
| 564 |
+
if clusters is None:
|
| 565 |
+
print(f" No cluster labels found, skipping")
|
| 566 |
+
continue
|
| 567 |
+
clusters = clusters.astype(str)
|
| 568 |
+
|
| 569 |
+
# ----- Task 1: PT State Discovery (Invisible States) -----
|
| 570 |
+
print(f"\n Task 1: Invisible State Discovery")
|
| 571 |
+
|
| 572 |
+
invisible_results = {"gamma": [], "smooth_ratio": []}
|
| 573 |
+
|
| 574 |
+
for method_name, layer_data in [("gamma", gamma), ("smooth_ratio", smooth_ratio)]:
|
| 575 |
+
for cluster_name in sorted(clusters.unique()):
|
| 576 |
+
mask = (clusters == cluster_name).values
|
| 577 |
+
n_cells = mask.sum()
|
| 578 |
+
if n_cells < 50:
|
| 579 |
+
continue
|
| 580 |
+
|
| 581 |
+
sub = layer_data[mask]
|
| 582 |
+
n_pcs = min(15, n_cells - 1, sub.shape[1] - 1)
|
| 583 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 584 |
+
pcs = pca.fit_transform(sub)
|
| 585 |
+
|
| 586 |
+
best_k, best_sil, best_labels = 1, -1, np.zeros(n_cells, dtype=int)
|
| 587 |
+
for k in [2, 3]:
|
| 588 |
+
if n_cells < k * 10:
|
| 589 |
+
continue
|
| 590 |
+
km = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 591 |
+
labels = km.fit_predict(pcs)
|
| 592 |
+
if min(np.bincount(labels)) < 10:
|
| 593 |
+
continue
|
| 594 |
+
sil = silhouette_score(pcs, labels)
|
| 595 |
+
if sil > best_sil:
|
| 596 |
+
best_k, best_sil, best_labels = k, sil, labels
|
| 597 |
+
|
| 598 |
+
# Expression silhouette for same labels
|
| 599 |
+
expr_sub = get_expression(adata)[mask]
|
| 600 |
+
n_expr_pcs = min(15, n_cells - 1, expr_sub.shape[1] - 1)
|
| 601 |
+
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
|
| 602 |
+
expr_pcs = pca_expr.fit_transform(expr_sub)
|
| 603 |
+
|
| 604 |
+
if best_k > 1:
|
| 605 |
+
sil_method = best_sil
|
| 606 |
+
sil_expr = silhouette_score(expr_pcs, best_labels)
|
| 607 |
+
else:
|
| 608 |
+
sil_method = 0
|
| 609 |
+
sil_expr = 0
|
| 610 |
+
|
| 611 |
+
is_invisible = sil_method > 0.1 and sil_expr < 0.1
|
| 612 |
+
|
| 613 |
+
invisible_results[method_name].append({
|
| 614 |
+
"cluster": cluster_name,
|
| 615 |
+
"n_cells": n_cells,
|
| 616 |
+
"sil_method": float(sil_method),
|
| 617 |
+
"sil_expr": float(sil_expr),
|
| 618 |
+
"invisibility": float(sil_method - sil_expr),
|
| 619 |
+
"is_invisible": is_invisible,
|
| 620 |
+
})
|
| 621 |
+
|
| 622 |
+
# Count invisible states for each method
|
| 623 |
+
gamma_invisible = sum(1 for r in invisible_results["gamma"] if r["is_invisible"])
|
| 624 |
+
ratio_invisible = sum(1 for r in invisible_results["smooth_ratio"] if r["is_invisible"])
|
| 625 |
+
gamma_mean_invis = np.mean([r["invisibility"] for r in invisible_results["gamma"]])
|
| 626 |
+
ratio_mean_invis = np.mean([r["invisibility"] for r in invisible_results["smooth_ratio"]])
|
| 627 |
+
|
| 628 |
+
print(f" Gamma: {gamma_invisible} invisible states, mean invisibility={gamma_mean_invis:.3f}")
|
| 629 |
+
print(f" Smooth ratio: {ratio_invisible} invisible states, mean invisibility={ratio_mean_invis:.3f}")
|
| 630 |
+
|
| 631 |
+
# ----- Task 2: Cell-Type Variance Explained (eta-squared) -----
|
| 632 |
+
print(f"\n Task 2: Cell-Type Variance Explained (eta-squared)")
|
| 633 |
+
|
| 634 |
+
cluster_labels = clusters.values
|
| 635 |
+
unique_clusters = np.unique(cluster_labels)
|
| 636 |
+
|
| 637 |
+
def compute_eta_squared(data, labels, unique_labels):
|
| 638 |
+
"""Compute eta-squared (fraction of variance explained by groups)."""
|
| 639 |
+
n = data.shape[0]
|
| 640 |
+
grand_mean = data.mean(axis=0)
|
| 641 |
+
ss_total = np.sum((data - grand_mean) ** 2, axis=0)
|
| 642 |
+
|
| 643 |
+
ss_between = np.zeros(data.shape[1])
|
| 644 |
+
for cl in unique_labels:
|
| 645 |
+
mask_cl = labels == cl
|
| 646 |
+
n_cl = mask_cl.sum()
|
| 647 |
+
if n_cl == 0:
|
| 648 |
+
continue
|
| 649 |
+
group_mean = data[mask_cl].mean(axis=0)
|
| 650 |
+
ss_between += n_cl * (group_mean - grand_mean) ** 2
|
| 651 |
+
|
| 652 |
+
eta_sq = ss_between / np.clip(ss_total, 1e-10, None)
|
| 653 |
+
return eta_sq
|
| 654 |
+
|
| 655 |
+
eta_gamma = compute_eta_squared(gamma, cluster_labels, unique_clusters)
|
| 656 |
+
eta_ratio = compute_eta_squared(smooth_ratio, cluster_labels, unique_clusters)
|
| 657 |
+
|
| 658 |
+
# Filter to informative genes
|
| 659 |
+
informative = (gamma > 0).mean(axis=0) >= 0.1
|
| 660 |
+
eta_gamma_info = eta_gamma[informative]
|
| 661 |
+
eta_ratio_info = eta_ratio[informative]
|
| 662 |
+
|
| 663 |
+
gamma_wins = (eta_gamma_info > eta_ratio_info).sum()
|
| 664 |
+
ratio_wins = (eta_ratio_info > eta_gamma_info).sum()
|
| 665 |
+
total = len(eta_gamma_info)
|
| 666 |
+
|
| 667 |
+
print(f" Gamma eta-sq > smooth ratio: {gamma_wins}/{total} ({100*gamma_wins/total:.1f}%)")
|
| 668 |
+
print(f" Mean eta-sq — gamma: {eta_gamma_info.mean():.4f}, smooth ratio: {eta_ratio_info.mean():.4f}")
|
| 669 |
+
|
| 670 |
+
# Wilcoxon test
|
| 671 |
+
w_stat, w_p = stats.wilcoxon(eta_gamma_info, eta_ratio_info)
|
| 672 |
+
print(f" Wilcoxon signed-rank p: {w_p:.2e}")
|
| 673 |
+
|
| 674 |
+
all_results[ds_name] = {
|
| 675 |
+
"invisible_states": {
|
| 676 |
+
"gamma_n_invisible": gamma_invisible,
|
| 677 |
+
"smooth_ratio_n_invisible": ratio_invisible,
|
| 678 |
+
"gamma_mean_invisibility": float(gamma_mean_invis),
|
| 679 |
+
"smooth_ratio_mean_invisibility": float(ratio_mean_invis),
|
| 680 |
+
"per_cluster": invisible_results,
|
| 681 |
+
},
|
| 682 |
+
"eta_squared": {
|
| 683 |
+
"gamma_wins": int(gamma_wins),
|
| 684 |
+
"ratio_wins": int(ratio_wins),
|
| 685 |
+
"n_genes": int(total),
|
| 686 |
+
"gamma_mean": float(eta_gamma_info.mean()),
|
| 687 |
+
"ratio_mean": float(eta_ratio_info.mean()),
|
| 688 |
+
"wilcoxon_p": float(w_p),
|
| 689 |
+
},
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
# Save results
|
| 693 |
+
with open(res_dir / "gamma_advantage.json", "w") as f:
|
| 694 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 695 |
+
|
| 696 |
+
# Summary figure
|
| 697 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
|
| 698 |
+
|
| 699 |
+
# Panel 1: Invisible state counts
|
| 700 |
+
ds_labels = list(all_results.keys())
|
| 701 |
+
gamma_invis = [all_results[d]["invisible_states"]["gamma_n_invisible"] for d in ds_labels]
|
| 702 |
+
ratio_invis = [all_results[d]["invisible_states"]["smooth_ratio_n_invisible"] for d in ds_labels]
|
| 703 |
+
x = np.arange(len(ds_labels))
|
| 704 |
+
width = 0.35
|
| 705 |
+
axes[0].bar(x - width / 2, gamma_invis, width, label="scPTR gamma",
|
| 706 |
+
color="#1976D2", edgecolor="black", linewidth=0.5)
|
| 707 |
+
axes[0].bar(x + width / 2, ratio_invis, width, label="Smooth u/s ratio (no beta)",
|
| 708 |
+
color="#E53935", edgecolor="black", linewidth=0.5)
|
| 709 |
+
axes[0].set_xticks(x)
|
| 710 |
+
axes[0].set_xticklabels(ds_labels, fontsize=9)
|
| 711 |
+
axes[0].set_ylabel("Number of invisible states")
|
| 712 |
+
axes[0].set_title("Invisible State Discovery")
|
| 713 |
+
axes[0].legend(fontsize=8)
|
| 714 |
+
for i, (g, r) in enumerate(zip(gamma_invis, ratio_invis)):
|
| 715 |
+
axes[0].text(i - width / 2, g + 0.1, str(g), ha="center", fontsize=9)
|
| 716 |
+
axes[0].text(i + width / 2, r + 0.1, str(r), ha="center", fontsize=9)
|
| 717 |
+
|
| 718 |
+
# Panel 2: Eta-squared comparison
|
| 719 |
+
gamma_means = [all_results[d]["eta_squared"]["gamma_mean"] for d in ds_labels]
|
| 720 |
+
ratio_means = [all_results[d]["eta_squared"]["ratio_mean"] for d in ds_labels]
|
| 721 |
+
axes[1].bar(x - width / 2, gamma_means, width, label="scPTR gamma",
|
| 722 |
+
color="#1976D2", edgecolor="black", linewidth=0.5)
|
| 723 |
+
axes[1].bar(x + width / 2, ratio_means, width, label="Smooth u/s ratio",
|
| 724 |
+
color="#E53935", edgecolor="black", linewidth=0.5)
|
| 725 |
+
axes[1].set_xticks(x)
|
| 726 |
+
axes[1].set_xticklabels(ds_labels, fontsize=9)
|
| 727 |
+
axes[1].set_ylabel("Mean eta-squared")
|
| 728 |
+
axes[1].set_title("Cell-Type Variance Explained")
|
| 729 |
+
axes[1].legend(fontsize=8)
|
| 730 |
+
for i, (g, r) in enumerate(zip(gamma_means, ratio_means)):
|
| 731 |
+
axes[1].text(i - width / 2, g + 0.001, f"{g:.4f}", ha="center", fontsize=8)
|
| 732 |
+
axes[1].text(i + width / 2, r + 0.001, f"{r:.4f}", ha="center", fontsize=8)
|
| 733 |
+
|
| 734 |
+
fig.suptitle("Experiment C: Gamma vs Smooth Ratio Downstream Tasks", fontsize=13)
|
| 735 |
+
fig.tight_layout()
|
| 736 |
+
save_fig(fig, "experiment_c_gamma_advantage")
|
| 737 |
+
|
| 738 |
+
print(f"\n EXPERIMENT C SUMMARY:")
|
| 739 |
+
for ds_name, res in all_results.items():
|
| 740 |
+
inv = res["invisible_states"]
|
| 741 |
+
eta = res["eta_squared"]
|
| 742 |
+
print(f" {ds_name}: invisible states gamma={inv['gamma_n_invisible']} "
|
| 743 |
+
f"vs ratio={inv['smooth_ratio_n_invisible']}; "
|
| 744 |
+
f"eta-sq gamma={eta['gamma_mean']:.4f} vs ratio={eta['ratio_mean']:.4f} "
|
| 745 |
+
f"(p={eta['wilcoxon_p']:.2e})")
|
| 746 |
+
|
| 747 |
+
return all_results
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
# =========================================================================
|
| 751 |
+
# EXPERIMENT D: NB Network Split-Half Robustness
|
| 752 |
+
# =========================================================================
|
| 753 |
+
def experiment_d_nb_robustness():
|
| 754 |
+
"""Show NB network is internally robust via split-half cross-validation."""
|
| 755 |
+
print(f"\n{'='*60}")
|
| 756 |
+
print("EXPERIMENT D: NB NETWORK SPLIT-HALF ROBUSTNESS")
|
| 757 |
+
print(f"{'='*60}")
|
| 758 |
+
|
| 759 |
+
import scanpy as sc
|
| 760 |
+
|
| 761 |
+
res_dir = OUTPUT_DIR / "results"
|
| 762 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 763 |
+
|
| 764 |
+
# Load NB data
|
| 765 |
+
h5ad_path = CACHE_DIR / "neuroblastoma.h5ad"
|
| 766 |
+
if not h5ad_path.exists():
|
| 767 |
+
print(f" NB data not found at {h5ad_path}")
|
| 768 |
+
return None
|
| 769 |
+
|
| 770 |
+
print(" Loading neuroblastoma dataset...")
|
| 771 |
+
adata_full = sc.read_h5ad(str(h5ad_path))
|
| 772 |
+
sc.pp.filter_genes(adata_full, min_cells=50)
|
| 773 |
+
adata_full.layers["raw_spliced"] = adata_full.layers["spliced"].copy()
|
| 774 |
+
adata_full.layers["raw_unspliced"] = adata_full.layers["unspliced"].copy()
|
| 775 |
+
print(f" Full dataset: {adata_full.shape}")
|
| 776 |
+
|
| 777 |
+
def run_nb_pipeline(adata):
|
| 778 |
+
"""Run scPTR pipeline on NB data."""
|
| 779 |
+
scptr.pp.filter_genes(adata)
|
| 780 |
+
scptr.pp.normalize_layers(adata)
|
| 781 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 782 |
+
scptr.pp.smooth_layers(adata)
|
| 783 |
+
scptr.tl.estimate_beta(adata)
|
| 784 |
+
scptr.tl.estimate_gamma(adata)
|
| 785 |
+
return adata
|
| 786 |
+
|
| 787 |
+
def infer_network(adata):
|
| 788 |
+
"""Run partial-correlation network inference (library-size corrected)."""
|
| 789 |
+
gamma = adata.layers["gamma"]
|
| 790 |
+
expr = get_expression(adata)
|
| 791 |
+
rbps = get_rbps_in_data(adata)
|
| 792 |
+
n_cells = adata.n_obs
|
| 793 |
+
|
| 794 |
+
# Library size
|
| 795 |
+
lib_size = expr.sum(axis=1)
|
| 796 |
+
lib_rank = stats.rankdata(lib_size)
|
| 797 |
+
lib_rank_centered = lib_rank - lib_rank.mean()
|
| 798 |
+
lib_ss = np.dot(lib_rank_centered, lib_rank_centered)
|
| 799 |
+
|
| 800 |
+
if lib_ss < 1e-10:
|
| 801 |
+
return pd.DataFrame()
|
| 802 |
+
|
| 803 |
+
# Target indices
|
| 804 |
+
informative = (gamma > 0).mean(axis=0) >= 0.1
|
| 805 |
+
if informative.sum() < 20:
|
| 806 |
+
return pd.DataFrame()
|
| 807 |
+
gamma_var = np.var(gamma[:, informative], axis=0)
|
| 808 |
+
n_targets = min(200, informative.sum())
|
| 809 |
+
top_var_idx = np.argsort(gamma_var)[-n_targets:]
|
| 810 |
+
info_indices = np.where(informative)[0]
|
| 811 |
+
target_indices = info_indices[top_var_idx]
|
| 812 |
+
|
| 813 |
+
# Pre-compute residualized gamma ranks
|
| 814 |
+
gamma_resid_map = {}
|
| 815 |
+
for ti in target_indices:
|
| 816 |
+
t_gamma = gamma[:, ti]
|
| 817 |
+
if np.std(t_gamma) < 1e-8:
|
| 818 |
+
continue
|
| 819 |
+
t_rank = stats.rankdata(t_gamma)
|
| 820 |
+
t_rank_c = t_rank - t_rank.mean()
|
| 821 |
+
slope = np.dot(lib_rank_centered, t_rank_c) / lib_ss
|
| 822 |
+
resid = t_rank - slope * lib_rank
|
| 823 |
+
resid_c = resid - resid.mean()
|
| 824 |
+
resid_std = np.sqrt(np.dot(resid_c, resid_c))
|
| 825 |
+
if resid_std > 1e-8:
|
| 826 |
+
gamma_resid_map[ti] = (resid_c, resid_std)
|
| 827 |
+
|
| 828 |
+
edges = []
|
| 829 |
+
for rbp_upper, rbp_idx in rbps.items():
|
| 830 |
+
rbp_expr = expr[:, rbp_idx]
|
| 831 |
+
if np.std(rbp_expr) < 1e-6:
|
| 832 |
+
continue
|
| 833 |
+
|
| 834 |
+
rbp_rank = stats.rankdata(rbp_expr)
|
| 835 |
+
rbp_rank_c = rbp_rank - rbp_rank.mean()
|
| 836 |
+
slope_rbp = np.dot(lib_rank_centered, rbp_rank_c) / lib_ss
|
| 837 |
+
rbp_resid = rbp_rank - slope_rbp * lib_rank
|
| 838 |
+
rbp_resid_c = rbp_resid - rbp_resid.mean()
|
| 839 |
+
rbp_resid_std = np.sqrt(np.dot(rbp_resid_c, rbp_resid_c))
|
| 840 |
+
if rbp_resid_std < 1e-8:
|
| 841 |
+
continue
|
| 842 |
+
|
| 843 |
+
for ti in target_indices:
|
| 844 |
+
if ti not in gamma_resid_map:
|
| 845 |
+
continue
|
| 846 |
+
g_resid_c, g_resid_std = gamma_resid_map[ti]
|
| 847 |
+
r_corr = np.dot(rbp_resid_c, g_resid_c) / (rbp_resid_std * g_resid_std)
|
| 848 |
+
r_corr = np.clip(r_corr, -1.0, 1.0)
|
| 849 |
+
df = n_cells - 3
|
| 850 |
+
t_val = r_corr * np.sqrt(df / (1 - r_corr ** 2 + 1e-12))
|
| 851 |
+
p_corr = 2 * stats.t.sf(abs(t_val), df)
|
| 852 |
+
|
| 853 |
+
if p_corr < 0.05 / (len(rbps) * n_targets):
|
| 854 |
+
edges.append({
|
| 855 |
+
"rbp": rbp_upper,
|
| 856 |
+
"target": adata.var_names[ti],
|
| 857 |
+
"r": float(r_corr),
|
| 858 |
+
})
|
| 859 |
+
|
| 860 |
+
return pd.DataFrame(edges) if edges else pd.DataFrame(columns=["rbp", "target", "r"])
|
| 861 |
+
|
| 862 |
+
def get_top_hubs(edges_df, n=20):
|
| 863 |
+
if len(edges_df) == 0:
|
| 864 |
+
return []
|
| 865 |
+
hub_counts = edges_df.groupby("rbp").size().sort_values(ascending=False)
|
| 866 |
+
return list(hub_counts.head(n).index)
|
| 867 |
+
|
| 868 |
+
# Run full-data network first
|
| 869 |
+
print("\n Running full-data pipeline...")
|
| 870 |
+
adata_full_processed = adata_full.copy()
|
| 871 |
+
adata_full_processed = run_nb_pipeline(adata_full_processed)
|
| 872 |
+
full_edges = infer_network(adata_full_processed)
|
| 873 |
+
full_hubs = get_top_hubs(full_edges, n=20)
|
| 874 |
+
full_hub_counts = full_edges.groupby("rbp").size() if len(full_edges) > 0 else pd.Series(dtype=int)
|
| 875 |
+
print(f" Full data: {len(full_edges)} edges, top hubs: {full_hubs[:5]}")
|
| 876 |
+
|
| 877 |
+
# Split-half replicates
|
| 878 |
+
n_replicates = 5
|
| 879 |
+
rng = np.random.RandomState(42)
|
| 880 |
+
n_cells = adata_full.n_obs
|
| 881 |
+
|
| 882 |
+
replicate_results = []
|
| 883 |
+
|
| 884 |
+
for rep_i in range(n_replicates):
|
| 885 |
+
print(f"\n Replicate {rep_i + 1}/{n_replicates}...")
|
| 886 |
+
|
| 887 |
+
# Random split
|
| 888 |
+
perm = rng.permutation(n_cells)
|
| 889 |
+
half1_idx = perm[:n_cells // 2]
|
| 890 |
+
half2_idx = perm[n_cells // 2:]
|
| 891 |
+
|
| 892 |
+
half_hubs = []
|
| 893 |
+
half_hub_counts_list = []
|
| 894 |
+
|
| 895 |
+
for half_name, cell_idx in [("half1", half1_idx), ("half2", half2_idx)]:
|
| 896 |
+
adata_half = adata_full[cell_idx].copy()
|
| 897 |
+
# Restore raw layers
|
| 898 |
+
adata_half.layers["spliced"] = adata_half.layers["raw_spliced"].copy()
|
| 899 |
+
adata_half.layers["unspliced"] = adata_half.layers["raw_unspliced"].copy()
|
| 900 |
+
|
| 901 |
+
try:
|
| 902 |
+
adata_half = run_nb_pipeline(adata_half)
|
| 903 |
+
edges_half = infer_network(adata_half)
|
| 904 |
+
hubs = get_top_hubs(edges_half, n=20)
|
| 905 |
+
hub_counts = edges_half.groupby("rbp").size() if len(edges_half) > 0 else pd.Series(dtype=int)
|
| 906 |
+
print(f" {half_name}: {len(edges_half)} edges, {len(hubs)} hubs")
|
| 907 |
+
except Exception as e:
|
| 908 |
+
print(f" {half_name}: pipeline failed: {e}")
|
| 909 |
+
hubs = []
|
| 910 |
+
hub_counts = pd.Series(dtype=int)
|
| 911 |
+
|
| 912 |
+
half_hubs.append(set(hubs))
|
| 913 |
+
half_hub_counts_list.append(hub_counts)
|
| 914 |
+
|
| 915 |
+
# Compare halves
|
| 916 |
+
if half_hubs[0] and half_hubs[1]:
|
| 917 |
+
union = half_hubs[0] | half_hubs[1]
|
| 918 |
+
intersection = half_hubs[0] & half_hubs[1]
|
| 919 |
+
jaccard = len(intersection) / len(union) if union else 0
|
| 920 |
+
|
| 921 |
+
# Hub count correlation (all shared RBPs)
|
| 922 |
+
shared_rbps = sorted(set(half_hub_counts_list[0].index) & set(half_hub_counts_list[1].index))
|
| 923 |
+
if len(shared_rbps) >= 5:
|
| 924 |
+
c1 = [half_hub_counts_list[0].get(r, 0) for r in shared_rbps]
|
| 925 |
+
c2 = [half_hub_counts_list[1].get(r, 0) for r in shared_rbps]
|
| 926 |
+
r_hub, p_hub = stats.spearmanr(c1, c2)
|
| 927 |
+
else:
|
| 928 |
+
r_hub, p_hub = np.nan, np.nan
|
| 929 |
+
|
| 930 |
+
# Compare each half to full data hubs
|
| 931 |
+
jaccard_h1_full = len(half_hubs[0] & set(full_hubs)) / len(half_hubs[0] | set(full_hubs)) if (half_hubs[0] | set(full_hubs)) else 0
|
| 932 |
+
jaccard_h2_full = len(half_hubs[1] & set(full_hubs)) / len(half_hubs[1] | set(full_hubs)) if (half_hubs[1] | set(full_hubs)) else 0
|
| 933 |
+
|
| 934 |
+
print(f" Half-half Jaccard (top-20 hubs): {jaccard:.3f}")
|
| 935 |
+
print(f" Hub count Spearman r: {r_hub:.3f}")
|
| 936 |
+
print(f" Half1-vs-full Jaccard: {jaccard_h1_full:.3f}, Half2-vs-full: {jaccard_h2_full:.3f}")
|
| 937 |
+
|
| 938 |
+
replicate_results.append({
|
| 939 |
+
"replicate": rep_i + 1,
|
| 940 |
+
"jaccard_half_half": float(jaccard),
|
| 941 |
+
"hub_count_spearman_r": float(r_hub) if not np.isnan(r_hub) else None,
|
| 942 |
+
"jaccard_half1_full": float(jaccard_h1_full),
|
| 943 |
+
"jaccard_half2_full": float(jaccard_h2_full),
|
| 944 |
+
"n_shared_rbps": len(shared_rbps),
|
| 945 |
+
"overlap_hubs": sorted(intersection),
|
| 946 |
+
})
|
| 947 |
+
else:
|
| 948 |
+
replicate_results.append({
|
| 949 |
+
"replicate": rep_i + 1,
|
| 950 |
+
"jaccard_half_half": 0,
|
| 951 |
+
"hub_count_spearman_r": None,
|
| 952 |
+
"jaccard_half1_full": 0,
|
| 953 |
+
"jaccard_half2_full": 0,
|
| 954 |
+
})
|
| 955 |
+
|
| 956 |
+
# Summary statistics
|
| 957 |
+
jaccards = [r["jaccard_half_half"] for r in replicate_results]
|
| 958 |
+
hub_rs = [r["hub_count_spearman_r"] for r in replicate_results if r["hub_count_spearman_r"] is not None]
|
| 959 |
+
|
| 960 |
+
mean_jaccard = np.mean(jaccards)
|
| 961 |
+
std_jaccard = np.std(jaccards)
|
| 962 |
+
mean_hub_r = np.mean(hub_rs) if hub_rs else np.nan
|
| 963 |
+
|
| 964 |
+
print(f"\n SUMMARY:")
|
| 965 |
+
print(f" Mean Jaccard (top-20 hubs): {mean_jaccard:.3f} +/- {std_jaccard:.3f}")
|
| 966 |
+
print(f" Mean hub count Spearman r: {mean_hub_r:.3f}")
|
| 967 |
+
|
| 968 |
+
results = {
|
| 969 |
+
"full_data_n_edges": len(full_edges),
|
| 970 |
+
"full_data_top_hubs": full_hubs,
|
| 971 |
+
"n_replicates": n_replicates,
|
| 972 |
+
"mean_jaccard": float(mean_jaccard),
|
| 973 |
+
"std_jaccard": float(std_jaccard),
|
| 974 |
+
"mean_hub_count_r": float(mean_hub_r) if not np.isnan(mean_hub_r) else None,
|
| 975 |
+
"replicates": replicate_results,
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
with open(res_dir / "nb_split_half.json", "w") as f:
|
| 979 |
+
json.dump(results, f, indent=2, default=str)
|
| 980 |
+
|
| 981 |
+
# Figure
|
| 982 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 983 |
+
|
| 984 |
+
# Panel 1: Jaccard per replicate
|
| 985 |
+
axes[0].bar(range(1, n_replicates + 1), jaccards, color="#1976D2",
|
| 986 |
+
edgecolor="black", linewidth=0.5)
|
| 987 |
+
axes[0].axhline(y=mean_jaccard, color="red", linestyle="--",
|
| 988 |
+
label=f"Mean={mean_jaccard:.3f}")
|
| 989 |
+
axes[0].set_xlabel("Replicate")
|
| 990 |
+
axes[0].set_ylabel("Jaccard similarity (top-20 hubs)")
|
| 991 |
+
axes[0].set_title("Split-Half Hub Consistency")
|
| 992 |
+
axes[0].legend()
|
| 993 |
+
axes[0].set_ylim(0, 1)
|
| 994 |
+
|
| 995 |
+
# Panel 2: Hub count correlation
|
| 996 |
+
if hub_rs:
|
| 997 |
+
axes[1].bar(range(1, len(hub_rs) + 1), hub_rs, color="#43A047",
|
| 998 |
+
edgecolor="black", linewidth=0.5)
|
| 999 |
+
axes[1].axhline(y=mean_hub_r, color="red", linestyle="--",
|
| 1000 |
+
label=f"Mean={mean_hub_r:.3f}")
|
| 1001 |
+
axes[1].set_xlabel("Replicate")
|
| 1002 |
+
axes[1].set_ylabel("Spearman r (hub target counts)")
|
| 1003 |
+
axes[1].set_title("Split-Half Hub Count Correlation")
|
| 1004 |
+
axes[1].legend()
|
| 1005 |
+
axes[1].set_ylim(-0.5, 1)
|
| 1006 |
+
|
| 1007 |
+
fig.suptitle("Experiment D: NB Network Split-Half Robustness", fontsize=13)
|
| 1008 |
+
fig.tight_layout()
|
| 1009 |
+
save_fig(fig, "experiment_d_nb_robustness")
|
| 1010 |
+
|
| 1011 |
+
return results
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
# =========================================================================
|
| 1015 |
+
# EXPERIMENT E: Corrected vs Uncorrected Network Quality
|
| 1016 |
+
# =========================================================================
|
| 1017 |
+
def experiment_e_correction_quality(go_results):
|
| 1018 |
+
"""Compare GO enrichment quality between corrected and uncorrected networks."""
|
| 1019 |
+
print(f"\n{'='*60}")
|
| 1020 |
+
print("EXPERIMENT E: CORRECTED vs UNCORRECTED NETWORK QUALITY")
|
| 1021 |
+
print(f"{'='*60}")
|
| 1022 |
+
|
| 1023 |
+
res_dir = OUTPUT_DIR / "results"
|
| 1024 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 1025 |
+
|
| 1026 |
+
# Load uncorrected (raw) network for NB
|
| 1027 |
+
raw_nb_path = TIER3_DIR / "neuroblastoma_network_raw.csv"
|
| 1028 |
+
corr_nb_path = TIER3_DIR / "neuroblastoma_network_corrected.csv"
|
| 1029 |
+
|
| 1030 |
+
# Also check for raw pancreas edges from gap_analysis
|
| 1031 |
+
raw_panc_path = Path(__file__).parent.parent / "output" / "gap_analysis" / "results" / "network" / "pancreas" / "network_edges.csv"
|
| 1032 |
+
|
| 1033 |
+
networks_to_compare = {}
|
| 1034 |
+
|
| 1035 |
+
if raw_nb_path.exists() and corr_nb_path.exists():
|
| 1036 |
+
raw_nb = pd.read_csv(raw_nb_path)
|
| 1037 |
+
corr_nb = pd.read_csv(corr_nb_path)
|
| 1038 |
+
networks_to_compare["neuroblastoma"] = {"raw": raw_nb, "corrected": corr_nb}
|
| 1039 |
+
print(f" NB raw: {len(raw_nb)} edges, corrected: {len(corr_nb)} edges")
|
| 1040 |
+
|
| 1041 |
+
if raw_panc_path.exists():
|
| 1042 |
+
raw_panc = pd.read_csv(raw_panc_path)
|
| 1043 |
+
corr_panc_path = WEAKNESS_DIR / "corrected_network_pancreas.csv"
|
| 1044 |
+
if corr_panc_path.exists():
|
| 1045 |
+
corr_panc = pd.read_csv(corr_panc_path)
|
| 1046 |
+
networks_to_compare["pancreas"] = {"raw": raw_panc, "corrected": corr_panc}
|
| 1047 |
+
print(f" Pancreas raw: {len(raw_panc)} edges, corrected: {len(corr_panc)} edges")
|
| 1048 |
+
|
| 1049 |
+
if not networks_to_compare:
|
| 1050 |
+
print(" No raw/corrected network pairs found")
|
| 1051 |
+
return None
|
| 1052 |
+
|
| 1053 |
+
# Load GO library from local cache
|
| 1054 |
+
go_lib = load_go_library()
|
| 1055 |
+
if go_lib is None:
|
| 1056 |
+
return None
|
| 1057 |
+
|
| 1058 |
+
BACKGROUND_SIZE = 20000
|
| 1059 |
+
|
| 1060 |
+
all_results = {}
|
| 1061 |
+
|
| 1062 |
+
for ds_name, net_pair in networks_to_compare.items():
|
| 1063 |
+
print(f"\n --- {ds_name} ---")
|
| 1064 |
+
|
| 1065 |
+
for method_name, edges_df in net_pair.items():
|
| 1066 |
+
print(f"\n {method_name} network ({len(edges_df)} edges):")
|
| 1067 |
+
|
| 1068 |
+
rbp_col = "rbp"
|
| 1069 |
+
target_col = "target"
|
| 1070 |
+
|
| 1071 |
+
# Build RBP -> target sets
|
| 1072 |
+
rbp_targets = {}
|
| 1073 |
+
for rbp, grp in edges_df.groupby(rbp_col):
|
| 1074 |
+
rbp_key = rbp.upper() if isinstance(rbp, str) else str(rbp)
|
| 1075 |
+
rbp_targets[rbp_key] = set(str(t) for t in grp[target_col])
|
| 1076 |
+
|
| 1077 |
+
eligible = {r: t for r, t in rbp_targets.items() if len(t) >= 10}
|
| 1078 |
+
print(f" RBPs with >= 10 targets: {len(eligible)}")
|
| 1079 |
+
|
| 1080 |
+
n_with_sig = 0
|
| 1081 |
+
for rbp, targets in eligible.items():
|
| 1082 |
+
gene_list = list(targets)
|
| 1083 |
+
sig_terms = hypergeometric_enrichment(gene_list, go_lib, BACKGROUND_SIZE)
|
| 1084 |
+
if sig_terms:
|
| 1085 |
+
n_with_sig += 1
|
| 1086 |
+
|
| 1087 |
+
frac = n_with_sig / max(len(eligible), 1)
|
| 1088 |
+
print(f" Fraction with sig GO: {n_with_sig}/{len(eligible)} ({frac:.1%})")
|
| 1089 |
+
|
| 1090 |
+
key = f"{ds_name}_{method_name}"
|
| 1091 |
+
all_results[key] = {
|
| 1092 |
+
"dataset": ds_name,
|
| 1093 |
+
"method": method_name,
|
| 1094 |
+
"n_edges": len(edges_df),
|
| 1095 |
+
"n_eligible_rbps": len(eligible),
|
| 1096 |
+
"n_with_sig_go": n_with_sig,
|
| 1097 |
+
"frac_with_sig_go": float(frac),
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
# Save results
|
| 1101 |
+
with open(res_dir / "correction_quality.json", "w") as f:
|
| 1102 |
+
json.dump(all_results, f, indent=2)
|
| 1103 |
+
|
| 1104 |
+
# Also compare destabilizing fractions
|
| 1105 |
+
print("\n Destabilizing fraction comparison:")
|
| 1106 |
+
for ds_name, net_pair in networks_to_compare.items():
|
| 1107 |
+
for method_name, edges_df in net_pair.items():
|
| 1108 |
+
# Find the correlation column
|
| 1109 |
+
r_col = None
|
| 1110 |
+
for c in ["r", "spearman_r"]:
|
| 1111 |
+
if c in edges_df.columns:
|
| 1112 |
+
r_col = c
|
| 1113 |
+
break
|
| 1114 |
+
if r_col:
|
| 1115 |
+
destab_frac = (edges_df[r_col] > 0).mean()
|
| 1116 |
+
print(f" {ds_name} {method_name}: {destab_frac:.1%} destabilizing")
|
| 1117 |
+
|
| 1118 |
+
# Summary figure
|
| 1119 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 1120 |
+
|
| 1121 |
+
labels = []
|
| 1122 |
+
raw_fracs = []
|
| 1123 |
+
corr_fracs = []
|
| 1124 |
+
|
| 1125 |
+
for ds_name in networks_to_compare:
|
| 1126 |
+
raw_key = f"{ds_name}_raw"
|
| 1127 |
+
corr_key = f"{ds_name}_corrected"
|
| 1128 |
+
if raw_key in all_results and corr_key in all_results:
|
| 1129 |
+
labels.append(ds_name)
|
| 1130 |
+
raw_fracs.append(all_results[raw_key]["frac_with_sig_go"])
|
| 1131 |
+
corr_fracs.append(all_results[corr_key]["frac_with_sig_go"])
|
| 1132 |
+
|
| 1133 |
+
if labels:
|
| 1134 |
+
x = np.arange(len(labels))
|
| 1135 |
+
width = 0.35
|
| 1136 |
+
ax.bar(x - width / 2, raw_fracs, width, label="Raw (uncorrected)",
|
| 1137 |
+
color="#E53935", edgecolor="black", linewidth=0.5)
|
| 1138 |
+
ax.bar(x + width / 2, corr_fracs, width, label="Library-size corrected",
|
| 1139 |
+
color="#1976D2", edgecolor="black", linewidth=0.5)
|
| 1140 |
+
ax.set_xticks(x)
|
| 1141 |
+
ax.set_xticklabels(labels, fontsize=9)
|
| 1142 |
+
ax.set_ylabel("Fraction of RBPs with sig GO enrichment")
|
| 1143 |
+
ax.set_title("GO Enrichment: Raw vs Corrected Networks")
|
| 1144 |
+
ax.legend()
|
| 1145 |
+
ax.set_ylim(0, 1.1)
|
| 1146 |
+
for i, (r, c) in enumerate(zip(raw_fracs, corr_fracs)):
|
| 1147 |
+
ax.text(i - width / 2, r + 0.02, f"{r:.0%}", ha="center", fontsize=9)
|
| 1148 |
+
ax.text(i + width / 2, c + 0.02, f"{c:.0%}", ha="center", fontsize=9)
|
| 1149 |
+
|
| 1150 |
+
fig.tight_layout()
|
| 1151 |
+
save_fig(fig, "experiment_e_correction_quality")
|
| 1152 |
+
|
| 1153 |
+
print(f"\n EXPERIMENT E SUMMARY:")
|
| 1154 |
+
for ds_name in networks_to_compare:
|
| 1155 |
+
raw_key = f"{ds_name}_raw"
|
| 1156 |
+
corr_key = f"{ds_name}_corrected"
|
| 1157 |
+
if raw_key in all_results and corr_key in all_results:
|
| 1158 |
+
print(f" {ds_name}: raw GO={all_results[raw_key]['frac_with_sig_go']:.0%} "
|
| 1159 |
+
f"-> corrected GO={all_results[corr_key]['frac_with_sig_go']:.0%}")
|
| 1160 |
+
|
| 1161 |
+
return all_results
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
# =========================================================================
|
| 1165 |
+
# MAIN
|
| 1166 |
+
# =========================================================================
|
| 1167 |
+
def main():
|
| 1168 |
+
set_figure_style()
|
| 1169 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 1170 |
+
(OUTPUT_DIR / "results").mkdir(parents=True, exist_ok=True)
|
| 1171 |
+
(OUTPUT_DIR / "figures").mkdir(parents=True, exist_ok=True)
|
| 1172 |
+
|
| 1173 |
+
# ===== Experiment A: GO Enrichment (CSV-only + API, fast) =====
|
| 1174 |
+
go_results = experiment_a_go_enrichment()
|
| 1175 |
+
|
| 1176 |
+
# ===== Experiment E: Correction Quality (reuses GO, fast) =====
|
| 1177 |
+
correction_results = experiment_e_correction_quality(go_results)
|
| 1178 |
+
|
| 1179 |
+
# ===== Load datasets for experiments B, C =====
|
| 1180 |
+
print(f"\n{'='*60}")
|
| 1181 |
+
print("LOADING DATASETS FOR EXPERIMENTS B, C")
|
| 1182 |
+
print(f"{'='*60}")
|
| 1183 |
+
|
| 1184 |
+
adata_pan = scptr.datasets.pancreas()
|
| 1185 |
+
adata_pan = run_pipeline(adata_pan, "pancreas")
|
| 1186 |
+
|
| 1187 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 1188 |
+
adata_dg = run_pipeline(adata_dg, "dentate_gyrus")
|
| 1189 |
+
|
| 1190 |
+
# sci-fate
|
| 1191 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 1192 |
+
adata_sf_raw = load_scifate_data()
|
| 1193 |
+
adata_sf = prepare_for_scptr(adata_sf_raw)
|
| 1194 |
+
adata_sf = run_pipeline(adata_sf, "scifate")
|
| 1195 |
+
|
| 1196 |
+
datasets = {
|
| 1197 |
+
"pancreas": adata_pan,
|
| 1198 |
+
"dentate_gyrus": adata_dg,
|
| 1199 |
+
"scifate": adata_sf,
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
# ===== Experiment B: Pathway Consistency =====
|
| 1203 |
+
pathway_results = experiment_b_pathway_consistency(datasets)
|
| 1204 |
+
|
| 1205 |
+
# ===== Experiment C: Gamma Advantage =====
|
| 1206 |
+
gamma_adv_results = experiment_c_gamma_advantage(datasets)
|
| 1207 |
+
|
| 1208 |
+
# ===== Experiment D: NB Split-Half Robustness (slowest) =====
|
| 1209 |
+
nb_results = experiment_d_nb_robustness()
|
| 1210 |
+
|
| 1211 |
+
# ===== FINAL SUMMARY =====
|
| 1212 |
+
print(f"\n{'='*60}")
|
| 1213 |
+
print("COMPREHENSIVE IMPROVEMENTS COMPLETE")
|
| 1214 |
+
print(f"{'='*60}")
|
| 1215 |
+
|
| 1216 |
+
print("\n Experiment A (GO Enrichment):")
|
| 1217 |
+
if go_results:
|
| 1218 |
+
for ds, res in go_results.items():
|
| 1219 |
+
print(f" {ds}: {res.get('frac_with_sig_go', 0):.0%} RBPs enriched "
|
| 1220 |
+
f"(null: {res.get('bootstrap_null_frac', 0):.0%})")
|
| 1221 |
+
|
| 1222 |
+
print("\n Experiment B (Pathway Consistency):")
|
| 1223 |
+
if pathway_results:
|
| 1224 |
+
for r in pathway_results:
|
| 1225 |
+
print(f" {r['pair']}: gene r={r['gene_level_r']:.3f} -> "
|
| 1226 |
+
f"pathway r={r['pathway_level_r']:.3f}")
|
| 1227 |
+
|
| 1228 |
+
print("\n Experiment C (Gamma Advantage):")
|
| 1229 |
+
if gamma_adv_results:
|
| 1230 |
+
for ds, res in gamma_adv_results.items():
|
| 1231 |
+
inv = res["invisible_states"]
|
| 1232 |
+
eta = res["eta_squared"]
|
| 1233 |
+
print(f" {ds}: invisible gamma={inv['gamma_n_invisible']} "
|
| 1234 |
+
f"vs ratio={inv['smooth_ratio_n_invisible']}; "
|
| 1235 |
+
f"eta-sq p={eta['wilcoxon_p']:.2e}")
|
| 1236 |
+
|
| 1237 |
+
print("\n Experiment D (NB Robustness):")
|
| 1238 |
+
if nb_results:
|
| 1239 |
+
print(f" Mean Jaccard (top-20): {nb_results['mean_jaccard']:.3f} "
|
| 1240 |
+
f"+/- {nb_results['std_jaccard']:.3f}")
|
| 1241 |
+
|
| 1242 |
+
print("\n Experiment E (Correction Quality):")
|
| 1243 |
+
if correction_results:
|
| 1244 |
+
for key, res in correction_results.items():
|
| 1245 |
+
print(f" {key}: {res['frac_with_sig_go']:.0%} sig GO")
|
| 1246 |
+
|
| 1247 |
+
print(f"\n All results saved to: {OUTPUT_DIR.resolve()}")
|
| 1248 |
+
|
| 1249 |
+
|
| 1250 |
+
if __name__ == "__main__":
|
| 1251 |
+
main()
|
analyses/run_deep_benchmark.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Comprehensive benchmark: DeepPTR vs analytical scPTR on synthetic + real data.
|
| 3 |
+
|
| 4 |
+
Runs:
|
| 5 |
+
1. Synthetic recovery: gamma correlation, CI coverage, latent CCA
|
| 6 |
+
2. Real datasets (pancreas, dentate gyrus): analytical vs DeepPTR
|
| 7 |
+
- Half-life correlation (mouse + human references)
|
| 8 |
+
- ARE/NMD enrichment
|
| 9 |
+
- Subsampling robustness
|
| 10 |
+
- Analytical vs DeepPTR gamma agreement
|
| 11 |
+
3. sci-fate metabolic labeling: ground-truth validation for both methods
|
| 12 |
+
|
| 13 |
+
All results saved to output/deep_benchmark/.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
# Thread control — MUST be set before any numpy/torch import
|
| 19 |
+
import os
|
| 20 |
+
os.environ["OMP_NUM_THREADS"] = "4"
|
| 21 |
+
os.environ["MKL_NUM_THREADS"] = "4"
|
| 22 |
+
os.environ["OPENBLAS_NUM_THREADS"] = "4"
|
| 23 |
+
os.environ["NUMEXPR_NUM_THREADS"] = "4"
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import matplotlib
|
| 31 |
+
matplotlib.use("Agg")
|
| 32 |
+
import matplotlib.pyplot as plt
|
| 33 |
+
import numpy as np
|
| 34 |
+
import pandas as pd
|
| 35 |
+
from scipy import stats
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
torch.set_num_threads(4)
|
| 39 |
+
|
| 40 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 41 |
+
from _common import set_figure_style
|
| 42 |
+
|
| 43 |
+
import scptr
|
| 44 |
+
|
| 45 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "deep_benchmark"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def save_fig(fig, name, subdir="figures"):
|
| 49 |
+
if fig is None:
|
| 50 |
+
print(f" [WARNING] {name}: plot returned None, skipping save")
|
| 51 |
+
return
|
| 52 |
+
out_dir = OUTPUT_DIR / subdir
|
| 53 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
path = out_dir / f"{name}.png"
|
| 55 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 56 |
+
plt.close(fig)
|
| 57 |
+
print(f" Saved: {path}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def ensure_dirs():
|
| 61 |
+
for sub in ("figures", "results"):
|
| 62 |
+
(OUTPUT_DIR / sub).mkdir(parents=True, exist_ok=True)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ============================================================================
|
| 66 |
+
# 1. SYNTHETIC RECOVERY
|
| 67 |
+
# ============================================================================
|
| 68 |
+
|
| 69 |
+
def run_synthetic_benchmark():
|
| 70 |
+
"""End-to-end DeepPTR on synthetic kinetic data with known ground truth."""
|
| 71 |
+
from scptr.deep.synthetic import (
|
| 72 |
+
generate_kinetic_data,
|
| 73 |
+
gamma_recovery,
|
| 74 |
+
ci_coverage,
|
| 75 |
+
latent_recovery,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
print("=" * 60)
|
| 79 |
+
print("1. SYNTHETIC RECOVERY BENCHMARK")
|
| 80 |
+
print("=" * 60)
|
| 81 |
+
|
| 82 |
+
adata, truth = generate_kinetic_data(
|
| 83 |
+
n_cells=1500, n_genes=100, n_cell_types=5,
|
| 84 |
+
dispersion=10.0, sparsity=0.3, seed=0,
|
| 85 |
+
)
|
| 86 |
+
print(f" Generated: {adata.shape}, {truth['gamma'].shape}")
|
| 87 |
+
|
| 88 |
+
# Fit DeepPTR (compact model for CPU)
|
| 89 |
+
torch.set_num_threads(4)
|
| 90 |
+
t0 = time.time()
|
| 91 |
+
model, history = scptr.deep.fit_deepptr(
|
| 92 |
+
adata,
|
| 93 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 94 |
+
batch_size=256, max_epochs=150, kl_warmup_epochs=20,
|
| 95 |
+
patience=15, n_posterior_samples=20,
|
| 96 |
+
device="cpu", seed=0, verbose=True,
|
| 97 |
+
)
|
| 98 |
+
elapsed = time.time() - t0
|
| 99 |
+
print(f" Training: {len(history.train_loss)} epochs in {elapsed:.1f}s")
|
| 100 |
+
|
| 101 |
+
# Evaluate
|
| 102 |
+
gamma_r = gamma_recovery(truth["gamma"], adata.layers["gamma"], per_gene=True)
|
| 103 |
+
gamma_r_global = gamma_recovery(truth["gamma"], adata.layers["gamma"], per_gene=False)
|
| 104 |
+
ci_cov = ci_coverage(truth["gamma"], adata.layers["gamma"], adata.layers["gamma_var"])
|
| 105 |
+
z_T_r = latent_recovery(truth["z_T"], adata.obsm["X_z_T"])
|
| 106 |
+
z_PT_r = latent_recovery(truth["z_PT"], adata.obsm["X_z_PT"])
|
| 107 |
+
|
| 108 |
+
results = {
|
| 109 |
+
"gamma_recovery_per_gene": gamma_r,
|
| 110 |
+
"gamma_recovery_global": gamma_r_global,
|
| 111 |
+
"ci_coverage_95": ci_cov,
|
| 112 |
+
"latent_recovery_T": z_T_r,
|
| 113 |
+
"latent_recovery_PT": z_PT_r,
|
| 114 |
+
"n_epochs": len(history.train_loss),
|
| 115 |
+
"final_train_loss": history.train_loss[-1],
|
| 116 |
+
"final_val_loss": history.val_loss[-1],
|
| 117 |
+
"training_time_s": elapsed,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
print(f"\n Gamma recovery (per-gene median Spearman r): {gamma_r:.4f}")
|
| 121 |
+
print(f" Gamma recovery (global Spearman r): {gamma_r_global:.4f}")
|
| 122 |
+
print(f" 95% CI coverage: {ci_cov:.4f}")
|
| 123 |
+
print(f" Latent recovery z_T (mean CCA): {z_T_r:.4f}")
|
| 124 |
+
print(f" Latent recovery z_PT (mean CCA): {z_PT_r:.4f}")
|
| 125 |
+
|
| 126 |
+
with open(OUTPUT_DIR / "results" / "synthetic_recovery.json", "w") as f:
|
| 127 |
+
json.dump(results, f, indent=2)
|
| 128 |
+
|
| 129 |
+
# Training curve plot
|
| 130 |
+
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
| 131 |
+
epochs = range(1, len(history.train_loss) + 1)
|
| 132 |
+
axes[0].plot(epochs, history.train_loss, label="train")
|
| 133 |
+
axes[0].plot(epochs, history.val_loss, label="val")
|
| 134 |
+
axes[0].set_xlabel("Epoch")
|
| 135 |
+
axes[0].set_ylabel("Loss")
|
| 136 |
+
axes[0].set_title("Total Loss")
|
| 137 |
+
axes[0].legend()
|
| 138 |
+
|
| 139 |
+
axes[1].plot(epochs, history.train_recon, label="train")
|
| 140 |
+
axes[1].plot(epochs, history.val_recon, label="val")
|
| 141 |
+
axes[1].set_xlabel("Epoch")
|
| 142 |
+
axes[1].set_ylabel("Reconstruction Loss")
|
| 143 |
+
axes[1].set_title("Reconstruction")
|
| 144 |
+
axes[1].legend()
|
| 145 |
+
|
| 146 |
+
axes[2].plot(epochs, history.kl_weight, "k-")
|
| 147 |
+
axes[2].set_xlabel("Epoch")
|
| 148 |
+
axes[2].set_ylabel("KL Weight")
|
| 149 |
+
axes[2].set_title("KL Annealing")
|
| 150 |
+
|
| 151 |
+
fig.suptitle(f"DeepPTR Training (synthetic, gamma r={gamma_r:.3f})", y=1.02)
|
| 152 |
+
fig.tight_layout()
|
| 153 |
+
save_fig(fig, "synthetic_training_curves")
|
| 154 |
+
|
| 155 |
+
return results
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ============================================================================
|
| 159 |
+
# 2. REAL DATA: PANCREAS + DENTATE GYRUS
|
| 160 |
+
# ============================================================================
|
| 161 |
+
|
| 162 |
+
def preprocess_for_analytical(adata, cluster_key="clusters"):
|
| 163 |
+
"""Standard scPTR preprocessing + analytical gamma."""
|
| 164 |
+
scptr.pp.filter_genes(adata)
|
| 165 |
+
scptr.pp.normalize_layers(adata)
|
| 166 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 167 |
+
scptr.pp.smooth_layers(adata)
|
| 168 |
+
scptr.tl.estimate_beta(adata)
|
| 169 |
+
scptr.tl.estimate_gamma(adata)
|
| 170 |
+
return adata
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def select_top_genes(adata, n_top=500):
|
| 174 |
+
"""Select top genes by unspliced signal for DeepPTR (reduces dim for CPU speed).
|
| 175 |
+
|
| 176 |
+
Uses total unspliced counts × fraction of cells expressing as the ranking.
|
| 177 |
+
Returns a view of adata with only the selected genes.
|
| 178 |
+
"""
|
| 179 |
+
from scipy.sparse import issparse
|
| 180 |
+
|
| 181 |
+
u = adata.layers["unspliced"]
|
| 182 |
+
if issparse(u):
|
| 183 |
+
u = np.asarray(u.todense())
|
| 184 |
+
u = np.asarray(u, dtype=np.float32)
|
| 185 |
+
|
| 186 |
+
# Rank by: total counts * fraction nonzero (rewards both signal and breadth)
|
| 187 |
+
total_counts = u.sum(axis=0)
|
| 188 |
+
frac_nonzero = (u > 0).mean(axis=0)
|
| 189 |
+
score = total_counts * frac_nonzero
|
| 190 |
+
|
| 191 |
+
top_idx = np.argsort(score)[::-1][:n_top]
|
| 192 |
+
top_idx = np.sort(top_idx) # keep original order
|
| 193 |
+
|
| 194 |
+
gene_names = adata.var_names[top_idx]
|
| 195 |
+
print(f" Selected top {len(gene_names)} genes for DeepPTR (from {adata.n_vars})")
|
| 196 |
+
adata_sub = adata[:, gene_names].copy()
|
| 197 |
+
|
| 198 |
+
# Ensure dense layers for efficient DataLoader conversion
|
| 199 |
+
from scipy.sparse import issparse as _issparse
|
| 200 |
+
for key in ("spliced", "unspliced"):
|
| 201 |
+
if key in adata_sub.layers and _issparse(adata_sub.layers[key]):
|
| 202 |
+
adata_sub.layers[key] = np.asarray(adata_sub.layers[key].todense())
|
| 203 |
+
return adata_sub
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def run_halflife_comparison(adata, adata_deep, dataset_name):
|
| 207 |
+
"""Compare half-life correlations: analytical vs DeepPTR."""
|
| 208 |
+
hl_mouse = scptr.datasets.herzog2017_halflives()
|
| 209 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 210 |
+
|
| 211 |
+
results = {}
|
| 212 |
+
for ref_name, hl_df in [("mouse_herzog", hl_mouse), ("human_schofield", hl_human)]:
|
| 213 |
+
# Analytical
|
| 214 |
+
corr_an = scptr.benchmark.correlate_with_halflives(adata, hl_df)
|
| 215 |
+
# DeepPTR
|
| 216 |
+
corr_dp = scptr.benchmark.correlate_with_halflives(adata_deep, hl_df)
|
| 217 |
+
|
| 218 |
+
results[ref_name] = {
|
| 219 |
+
"analytical": {
|
| 220 |
+
"spearman_r": corr_an["spearman_r"],
|
| 221 |
+
"pearson_r": corr_an["pearson_r"],
|
| 222 |
+
"n_genes": corr_an["n_genes"],
|
| 223 |
+
},
|
| 224 |
+
"deepptr": {
|
| 225 |
+
"spearman_r": corr_dp["spearman_r"],
|
| 226 |
+
"pearson_r": corr_dp["pearson_r"],
|
| 227 |
+
"n_genes": corr_dp["n_genes"],
|
| 228 |
+
},
|
| 229 |
+
}
|
| 230 |
+
print(f" {ref_name}:")
|
| 231 |
+
print(f" Analytical: Spearman r = {corr_an['spearman_r']:.4f} (n={corr_an['n_genes']})")
|
| 232 |
+
print(f" DeepPTR: Spearman r = {corr_dp['spearman_r']:.4f} (n={corr_dp['n_genes']})")
|
| 233 |
+
|
| 234 |
+
return results
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def run_enrichment_comparison(adata, adata_deep, dataset_name):
|
| 238 |
+
"""Compare ARE/NMD enrichment: analytical vs DeepPTR."""
|
| 239 |
+
results = {}
|
| 240 |
+
for test_name, test_fn in [("ARE", scptr.benchmark.are_enrichment),
|
| 241 |
+
("NMD", scptr.benchmark.nmd_enrichment)]:
|
| 242 |
+
res_an = test_fn(adata)
|
| 243 |
+
res_dp = test_fn(adata_deep)
|
| 244 |
+
|
| 245 |
+
results[test_name] = {
|
| 246 |
+
"analytical": {
|
| 247 |
+
"U_statistic": float(res_an.get("U_statistic", np.nan)),
|
| 248 |
+
"p_value": float(res_an.get("p_value", np.nan)),
|
| 249 |
+
"n_genes_in_set": int(res_an.get("n_genes_in_set", 0)),
|
| 250 |
+
},
|
| 251 |
+
"deepptr": {
|
| 252 |
+
"U_statistic": float(res_dp.get("U_statistic", np.nan)),
|
| 253 |
+
"p_value": float(res_dp.get("p_value", np.nan)),
|
| 254 |
+
"n_genes_in_set": int(res_dp.get("n_genes_in_set", 0)),
|
| 255 |
+
},
|
| 256 |
+
}
|
| 257 |
+
p_an = res_an.get("p_value", np.nan)
|
| 258 |
+
p_dp = res_dp.get("p_value", np.nan)
|
| 259 |
+
print(f" {test_name}: analytical p={p_an:.2e}, DeepPTR p={p_dp:.2e}")
|
| 260 |
+
|
| 261 |
+
return results
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def run_gamma_agreement(adata, adata_deep, dataset_name):
|
| 265 |
+
"""Correlate per-gene median gamma: analytical vs DeepPTR on shared genes."""
|
| 266 |
+
gamma_an_s = pd.Series(
|
| 267 |
+
np.median(adata.layers["gamma"], axis=0), index=adata.var_names
|
| 268 |
+
)
|
| 269 |
+
gamma_dp_s = pd.Series(
|
| 270 |
+
np.median(adata_deep.layers["gamma"], axis=0), index=adata_deep.var_names
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Match on shared genes
|
| 274 |
+
shared = gamma_an_s.index.intersection(gamma_dp_s.index)
|
| 275 |
+
g_an = gamma_an_s[shared].values.astype(float)
|
| 276 |
+
g_dp = gamma_dp_s[shared].values.astype(float)
|
| 277 |
+
|
| 278 |
+
mask = (g_an > 0) & (g_dp > 0) & np.isfinite(g_an) & np.isfinite(g_dp)
|
| 279 |
+
g_an = g_an[mask]
|
| 280 |
+
g_dp = g_dp[mask]
|
| 281 |
+
|
| 282 |
+
if len(g_an) < 3:
|
| 283 |
+
print(f" Analytical vs DeepPTR gamma: too few shared genes ({len(g_an)})")
|
| 284 |
+
return {"spearman_r": np.nan, "pearson_r": np.nan, "n_genes": 0}
|
| 285 |
+
|
| 286 |
+
sp_r, sp_p = stats.spearmanr(g_an, g_dp)
|
| 287 |
+
pe_r, pe_p = stats.pearsonr(np.log1p(g_an), np.log1p(g_dp))
|
| 288 |
+
|
| 289 |
+
result = {
|
| 290 |
+
"spearman_r": float(sp_r),
|
| 291 |
+
"spearman_p": float(sp_p),
|
| 292 |
+
"pearson_r": float(pe_r),
|
| 293 |
+
"pearson_p": float(pe_p),
|
| 294 |
+
"n_genes": int(mask.sum()),
|
| 295 |
+
}
|
| 296 |
+
print(f" Analytical vs DeepPTR gamma: Spearman r = {sp_r:.4f} (n={mask.sum()})")
|
| 297 |
+
|
| 298 |
+
# Scatter plot
|
| 299 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 300 |
+
ax.scatter(g_an, g_dp, alpha=0.15, s=8, c="steelblue")
|
| 301 |
+
ax.set_xscale("log")
|
| 302 |
+
ax.set_yscale("log")
|
| 303 |
+
ax.set_xlabel("Analytical median gamma")
|
| 304 |
+
ax.set_ylabel("DeepPTR median gamma")
|
| 305 |
+
ax.set_title(f"{dataset_name}: Analytical vs DeepPTR (r={sp_r:.3f}, n={mask.sum()})")
|
| 306 |
+
lims = [min(g_an.min(), g_dp.min()), max(g_an.max(), g_dp.max())]
|
| 307 |
+
ax.plot(lims, lims, "k--", alpha=0.3, lw=1)
|
| 308 |
+
save_fig(fig, f"{dataset_name}_analytical_vs_deepptr")
|
| 309 |
+
|
| 310 |
+
return result
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def run_real_dataset(name, adata_loader, cluster_key="clusters"):
|
| 314 |
+
"""Full benchmark for one real dataset."""
|
| 315 |
+
print(f"\n{'=' * 60}")
|
| 316 |
+
print(f"2. REAL DATA: {name.upper()}")
|
| 317 |
+
print("=" * 60)
|
| 318 |
+
|
| 319 |
+
# Load and preprocess
|
| 320 |
+
print(f"\n--- Loading {name} ---")
|
| 321 |
+
adata = adata_loader()
|
| 322 |
+
print(f" Shape: {adata.shape}")
|
| 323 |
+
|
| 324 |
+
print(f"\n--- Preprocessing (analytical) ---")
|
| 325 |
+
preprocess_for_analytical(adata, cluster_key=cluster_key)
|
| 326 |
+
gamma_an = adata.layers["gamma"]
|
| 327 |
+
gamma_med_an = np.median(gamma_an, axis=0)
|
| 328 |
+
print(f" Analytical gamma: median of medians = {np.median(gamma_med_an):.4f}")
|
| 329 |
+
|
| 330 |
+
# DeepPTR: preprocess, select top genes, then fit
|
| 331 |
+
print(f"\n--- Running DeepPTR ---")
|
| 332 |
+
adata_deep = adata_loader()
|
| 333 |
+
scptr.pp.filter_genes(adata_deep)
|
| 334 |
+
scptr.pp.normalize_layers(adata_deep)
|
| 335 |
+
scptr.pp.neighbors(adata_deep, n_neighbors=30)
|
| 336 |
+
scptr.pp.smooth_layers(adata_deep)
|
| 337 |
+
scptr.tl.estimate_beta(adata_deep)
|
| 338 |
+
# Select top genes to keep training tractable on CPU
|
| 339 |
+
adata_deep = select_top_genes(adata_deep, n_top=300)
|
| 340 |
+
|
| 341 |
+
torch.set_num_threads(4) # Reset after TF/scanpy imports
|
| 342 |
+
t0 = time.time()
|
| 343 |
+
model, history = scptr.deep.fit_deepptr(
|
| 344 |
+
adata_deep,
|
| 345 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 346 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 347 |
+
patience=15, n_posterior_samples=15,
|
| 348 |
+
device="cpu", seed=0, verbose=True,
|
| 349 |
+
)
|
| 350 |
+
elapsed = time.time() - t0
|
| 351 |
+
n_epochs = len(history.train_loss)
|
| 352 |
+
print(f" DeepPTR: {n_epochs} epochs in {elapsed:.1f}s")
|
| 353 |
+
|
| 354 |
+
gamma_dp = adata_deep.layers["gamma"]
|
| 355 |
+
gamma_med_dp = np.median(gamma_dp, axis=0)
|
| 356 |
+
print(f" DeepPTR gamma: median of medians = {np.median(gamma_med_dp):.4f}")
|
| 357 |
+
|
| 358 |
+
# --- Benchmarks ---
|
| 359 |
+
all_results = {
|
| 360 |
+
"dataset": name,
|
| 361 |
+
"n_cells": adata.n_obs,
|
| 362 |
+
"n_genes": adata.n_vars,
|
| 363 |
+
"deepptr_epochs": n_epochs,
|
| 364 |
+
"deepptr_time_s": elapsed,
|
| 365 |
+
"deepptr_final_val_loss": history.val_loss[-1],
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
# Half-life correlations
|
| 369 |
+
print(f"\n--- Half-life correlations ---")
|
| 370 |
+
hl_results = run_halflife_comparison(adata, adata_deep, name)
|
| 371 |
+
all_results["halflife"] = hl_results
|
| 372 |
+
|
| 373 |
+
# ARE/NMD enrichment
|
| 374 |
+
print(f"\n--- ARE/NMD enrichment ---")
|
| 375 |
+
try:
|
| 376 |
+
enrich_results = run_enrichment_comparison(adata, adata_deep, name)
|
| 377 |
+
all_results["enrichment"] = enrich_results
|
| 378 |
+
except Exception as e:
|
| 379 |
+
print(f" Enrichment failed: {e}")
|
| 380 |
+
all_results["enrichment"] = {"error": str(e)}
|
| 381 |
+
|
| 382 |
+
# Analytical vs DeepPTR agreement
|
| 383 |
+
print(f"\n--- Analytical vs DeepPTR agreement ---")
|
| 384 |
+
agree = run_gamma_agreement(adata, adata_deep, name)
|
| 385 |
+
all_results["gamma_agreement"] = agree
|
| 386 |
+
|
| 387 |
+
# Subsampling robustness (DeepPTR only — analytical already known)
|
| 388 |
+
print(f"\n--- Subsampling robustness (analytical) ---")
|
| 389 |
+
try:
|
| 390 |
+
rob_an = scptr.benchmark.subsampling_robustness(
|
| 391 |
+
adata, fractions=[0.5, 0.8], n_repeats=2
|
| 392 |
+
)
|
| 393 |
+
print(f" Analytical: median r @ 30% = {rob_an[rob_an['fraction']==0.3]['spearman_r'].median():.4f}")
|
| 394 |
+
all_results["robustness_analytical"] = rob_an.to_dict(orient="records")
|
| 395 |
+
except Exception as e:
|
| 396 |
+
print(f" Robustness failed: {e}")
|
| 397 |
+
|
| 398 |
+
# Training curve
|
| 399 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
|
| 400 |
+
epochs = range(1, n_epochs + 1)
|
| 401 |
+
axes[0].plot(epochs, history.train_loss, label="train")
|
| 402 |
+
axes[0].plot(epochs, history.val_loss, label="val")
|
| 403 |
+
axes[0].set_xlabel("Epoch")
|
| 404 |
+
axes[0].set_ylabel("Loss")
|
| 405 |
+
axes[0].set_title(f"{name}: Training Loss")
|
| 406 |
+
axes[0].legend()
|
| 407 |
+
|
| 408 |
+
axes[1].plot(epochs, history.train_recon, label="train recon")
|
| 409 |
+
axes[1].plot(epochs, history.train_kl, label="train KL")
|
| 410 |
+
axes[1].set_xlabel("Epoch")
|
| 411 |
+
axes[1].set_ylabel("Loss Component")
|
| 412 |
+
axes[1].set_title(f"{name}: Loss Components")
|
| 413 |
+
axes[1].legend()
|
| 414 |
+
fig.tight_layout()
|
| 415 |
+
save_fig(fig, f"{name}_training_curves")
|
| 416 |
+
|
| 417 |
+
# Uncertainty visualization
|
| 418 |
+
gamma_var = adata_deep.layers["gamma_var"]
|
| 419 |
+
mean_var = np.mean(gamma_var, axis=0)
|
| 420 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 421 |
+
ax.scatter(gamma_med_dp, mean_var, alpha=0.2, s=8, c="steelblue")
|
| 422 |
+
ax.set_xscale("log")
|
| 423 |
+
ax.set_yscale("log")
|
| 424 |
+
ax.set_xlabel("Posterior mean gamma (median over cells)")
|
| 425 |
+
ax.set_ylabel("Posterior variance (mean over cells)")
|
| 426 |
+
ax.set_title(f"{name}: DeepPTR Uncertainty")
|
| 427 |
+
save_fig(fig, f"{name}_uncertainty")
|
| 428 |
+
|
| 429 |
+
# Save
|
| 430 |
+
with open(OUTPUT_DIR / "results" / f"{name}_benchmark.json", "w") as f:
|
| 431 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 432 |
+
|
| 433 |
+
return all_results
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ============================================================================
|
| 437 |
+
# 3. SCI-FATE GROUND TRUTH VALIDATION
|
| 438 |
+
# ============================================================================
|
| 439 |
+
|
| 440 |
+
def run_scifate_benchmark():
|
| 441 |
+
"""Compare analytical vs DeepPTR on sci-fate metabolic labeling data."""
|
| 442 |
+
import gzip
|
| 443 |
+
from scipy.io import mmread
|
| 444 |
+
from scipy.sparse import csc_matrix
|
| 445 |
+
|
| 446 |
+
print(f"\n{'=' * 60}")
|
| 447 |
+
print("3. SCI-FATE METABOLIC LABELING VALIDATION")
|
| 448 |
+
print("=" * 60)
|
| 449 |
+
|
| 450 |
+
CACHE_DIR = Path.home() / ".cache" / "scptr" / "scifate"
|
| 451 |
+
if not CACHE_DIR.exists():
|
| 452 |
+
print(" [SKIP] sci-fate data not cached. Run analyses/run_scifate.py first.")
|
| 453 |
+
return None
|
| 454 |
+
|
| 455 |
+
# Load raw data
|
| 456 |
+
print(" Loading sci-fate data...")
|
| 457 |
+
cell_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_cell_annotate.txt.gz", compression="gzip")
|
| 458 |
+
gene_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_gene_annotate.txt.gz", compression="gzip")
|
| 459 |
+
|
| 460 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count.txt.gz", "rb") as f:
|
| 461 |
+
total_mat = csc_matrix(mmread(f)).T
|
| 462 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count_newly_synthesised.txt.gz", "rb") as f:
|
| 463 |
+
new_mat = csc_matrix(mmread(f)).T
|
| 464 |
+
|
| 465 |
+
import anndata as ad
|
| 466 |
+
adata_raw = ad.AnnData(
|
| 467 |
+
X=total_mat,
|
| 468 |
+
obs=cell_ann.set_index("sample"),
|
| 469 |
+
var=gene_ann.set_index("gene_id"),
|
| 470 |
+
)
|
| 471 |
+
adata_raw.layers["new"] = new_mat
|
| 472 |
+
adata_raw.var_names_make_unique()
|
| 473 |
+
adata_raw.var["gene_id_full"] = adata_raw.var_names.tolist()
|
| 474 |
+
adata_raw.var_names = adata_raw.var["gene_short_name"].values
|
| 475 |
+
adata_raw.var_names_make_unique()
|
| 476 |
+
print(f" Shape: {adata_raw.shape}")
|
| 477 |
+
|
| 478 |
+
# Ground truth
|
| 479 |
+
total = np.asarray(adata_raw.X.toarray() if hasattr(adata_raw.X, "toarray") else adata_raw.X)
|
| 480 |
+
new = np.asarray(adata_raw.layers["new"].toarray() if hasattr(adata_raw.layers["new"], "toarray") else adata_raw.layers["new"])
|
| 481 |
+
old = total - new
|
| 482 |
+
mean_new = new.mean(axis=0)
|
| 483 |
+
mean_old = old.mean(axis=0)
|
| 484 |
+
mean_total = total.mean(axis=0)
|
| 485 |
+
reliable = (mean_total >= 0.5) & (mean_old > 0.1)
|
| 486 |
+
gt_ratio = np.full(adata_raw.n_vars, np.nan)
|
| 487 |
+
gt_ratio[reliable] = mean_new[reliable] / mean_old[reliable]
|
| 488 |
+
print(f" Ground truth: {reliable.sum()} reliable genes")
|
| 489 |
+
|
| 490 |
+
# Prepare for scPTR (unspliced=new, spliced=old)
|
| 491 |
+
keep = mean_total >= 0.5
|
| 492 |
+
if "gene_type" in adata_raw.var.columns:
|
| 493 |
+
is_pc = adata_raw.var["gene_type"] == "protein_coding"
|
| 494 |
+
keep = keep & is_pc.values
|
| 495 |
+
|
| 496 |
+
def make_scptr_adata():
|
| 497 |
+
a = ad.AnnData(
|
| 498 |
+
X=total[:, keep].astype(np.float32),
|
| 499 |
+
obs=adata_raw.obs.copy(),
|
| 500 |
+
var=adata_raw.var.iloc[keep].copy(),
|
| 501 |
+
)
|
| 502 |
+
a.layers["unspliced"] = new[:, keep].astype(np.float32)
|
| 503 |
+
a.layers["spliced"] = old[:, keep].astype(np.float32)
|
| 504 |
+
return a
|
| 505 |
+
|
| 506 |
+
# --- Analytical ---
|
| 507 |
+
print("\n--- Analytical pipeline ---")
|
| 508 |
+
adata_an = make_scptr_adata()
|
| 509 |
+
scptr.pp.filter_genes(adata_an, min_unspliced_counts=1, min_unspliced_cells=1)
|
| 510 |
+
scptr.pp.normalize_layers(adata_an)
|
| 511 |
+
scptr.pp.neighbors(adata_an, n_neighbors=30)
|
| 512 |
+
scptr.pp.smooth_layers(adata_an)
|
| 513 |
+
scptr.tl.estimate_beta(adata_an)
|
| 514 |
+
scptr.tl.estimate_gamma(adata_an)
|
| 515 |
+
gamma_med_an = np.median(adata_an.layers["gamma"], axis=0)
|
| 516 |
+
print(f" Analytical: {adata_an.shape}, median gamma = {np.median(gamma_med_an):.4f}")
|
| 517 |
+
|
| 518 |
+
# --- DeepPTR ---
|
| 519 |
+
print("\n--- DeepPTR ---")
|
| 520 |
+
adata_dp = make_scptr_adata()
|
| 521 |
+
scptr.pp.filter_genes(adata_dp, min_unspliced_counts=1, min_unspliced_cells=1)
|
| 522 |
+
scptr.pp.normalize_layers(adata_dp)
|
| 523 |
+
scptr.pp.neighbors(adata_dp, n_neighbors=30)
|
| 524 |
+
scptr.pp.smooth_layers(adata_dp)
|
| 525 |
+
scptr.tl.estimate_beta(adata_dp)
|
| 526 |
+
adata_dp = select_top_genes(adata_dp, n_top=500)
|
| 527 |
+
|
| 528 |
+
t0 = time.time()
|
| 529 |
+
model, history = scptr.deep.fit_deepptr(
|
| 530 |
+
adata_dp,
|
| 531 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 532 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 533 |
+
patience=15, n_posterior_samples=15,
|
| 534 |
+
device="cpu", seed=0, verbose=True,
|
| 535 |
+
)
|
| 536 |
+
elapsed = time.time() - t0
|
| 537 |
+
gamma_med_dp = np.median(adata_dp.layers["gamma"], axis=0)
|
| 538 |
+
print(f" DeepPTR: {len(history.train_loss)} epochs in {elapsed:.1f}s")
|
| 539 |
+
|
| 540 |
+
# Correlate both with ground truth
|
| 541 |
+
gt_s_an = pd.Series(gt_ratio, index=adata_raw.var_names)
|
| 542 |
+
gamma_s_an = pd.Series(gamma_med_an, index=adata_an.var_names)
|
| 543 |
+
gamma_s_dp = pd.Series(gamma_med_dp, index=adata_dp.var_names)
|
| 544 |
+
|
| 545 |
+
shared_an = gamma_s_an.index.intersection(gt_s_an.dropna().index)
|
| 546 |
+
shared_dp = gamma_s_dp.index.intersection(gt_s_an.dropna().index)
|
| 547 |
+
|
| 548 |
+
def correlate(gamma_s, gt_s, shared):
|
| 549 |
+
g = gamma_s[shared].values.astype(float)
|
| 550 |
+
t = gt_s[shared].values.astype(float)
|
| 551 |
+
mask = np.isfinite(g) & np.isfinite(t) & (g > 0) & (t > 0)
|
| 552 |
+
if mask.sum() < 3:
|
| 553 |
+
return {"spearman_r": np.nan, "n_genes": 0}
|
| 554 |
+
sp_r, sp_p = stats.spearmanr(g[mask], t[mask])
|
| 555 |
+
return {"spearman_r": float(sp_r), "spearman_p": float(sp_p), "n_genes": int(mask.sum())}
|
| 556 |
+
|
| 557 |
+
corr_an = correlate(gamma_s_an, gt_s_an, shared_an)
|
| 558 |
+
corr_dp = correlate(gamma_s_dp, gt_s_an, shared_dp)
|
| 559 |
+
|
| 560 |
+
print(f"\n--- Ground truth correlation (new/old ratio) ---")
|
| 561 |
+
print(f" Analytical: Spearman r = {corr_an['spearman_r']:.4f} (n={corr_an['n_genes']})")
|
| 562 |
+
print(f" DeepPTR: Spearman r = {corr_dp['spearman_r']:.4f} (n={corr_dp['n_genes']})")
|
| 563 |
+
|
| 564 |
+
# Half-life correlation
|
| 565 |
+
print(f"\n--- Half-life correlations ---")
|
| 566 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 567 |
+
corr_hl_an = scptr.benchmark.correlate_with_halflives(adata_an, hl_human)
|
| 568 |
+
corr_hl_dp = scptr.benchmark.correlate_with_halflives(adata_dp, hl_human)
|
| 569 |
+
print(f" Analytical: Spearman r = {corr_hl_an['spearman_r']:.4f} (n={corr_hl_an['n_genes']})")
|
| 570 |
+
print(f" DeepPTR: Spearman r = {corr_hl_dp['spearman_r']:.4f} (n={corr_hl_dp['n_genes']})")
|
| 571 |
+
|
| 572 |
+
# Agreement
|
| 573 |
+
shared_both = gamma_s_an.index.intersection(gamma_s_dp.index)
|
| 574 |
+
g_an = gamma_s_an[shared_both].values
|
| 575 |
+
g_dp = gamma_s_dp[shared_both].values
|
| 576 |
+
mask_both = (g_an > 0) & (g_dp > 0) & np.isfinite(g_an) & np.isfinite(g_dp)
|
| 577 |
+
if mask_both.sum() >= 3:
|
| 578 |
+
agree_r, _ = stats.spearmanr(g_an[mask_both], g_dp[mask_both])
|
| 579 |
+
print(f"\n Analytical vs DeepPTR: Spearman r = {agree_r:.4f} (n={mask_both.sum()})")
|
| 580 |
+
else:
|
| 581 |
+
agree_r = np.nan
|
| 582 |
+
|
| 583 |
+
results = {
|
| 584 |
+
"dataset": "scifate",
|
| 585 |
+
"n_cells": int(adata_an.n_obs),
|
| 586 |
+
"n_genes_analytical": int(adata_an.n_vars),
|
| 587 |
+
"n_genes_deep": int(adata_dp.n_vars),
|
| 588 |
+
"ground_truth_corr": {
|
| 589 |
+
"analytical": corr_an,
|
| 590 |
+
"deepptr": corr_dp,
|
| 591 |
+
},
|
| 592 |
+
"halflife_human": {
|
| 593 |
+
"analytical": {"spearman_r": corr_hl_an["spearman_r"], "n_genes": corr_hl_an["n_genes"]},
|
| 594 |
+
"deepptr": {"spearman_r": corr_hl_dp["spearman_r"], "n_genes": corr_hl_dp["n_genes"]},
|
| 595 |
+
},
|
| 596 |
+
"gamma_agreement": {"spearman_r": float(agree_r), "n_genes": int(mask_both.sum())},
|
| 597 |
+
"deepptr_epochs": len(history.train_loss),
|
| 598 |
+
"deepptr_time_s": elapsed,
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
with open(OUTPUT_DIR / "results" / "scifate_benchmark.json", "w") as f:
|
| 602 |
+
json.dump(results, f, indent=2, default=str)
|
| 603 |
+
|
| 604 |
+
# Scatter: analytical vs DeepPTR vs ground truth
|
| 605 |
+
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
|
| 606 |
+
|
| 607 |
+
# Panel 1: Analytical vs ground truth
|
| 608 |
+
g = gamma_s_an[shared_an].values.astype(float)
|
| 609 |
+
t = gt_s_an[shared_an].values.astype(float)
|
| 610 |
+
m = np.isfinite(g) & np.isfinite(t) & (g > 0) & (t > 0)
|
| 611 |
+
axes[0].scatter(t[m], g[m], alpha=0.1, s=5, c="steelblue")
|
| 612 |
+
axes[0].set_xscale("log")
|
| 613 |
+
axes[0].set_yscale("log")
|
| 614 |
+
axes[0].set_xlabel("Ground truth (new/old ratio)")
|
| 615 |
+
axes[0].set_ylabel("Analytical gamma")
|
| 616 |
+
axes[0].set_title(f"Analytical (r={corr_an['spearman_r']:.3f})")
|
| 617 |
+
|
| 618 |
+
# Panel 2: DeepPTR vs ground truth
|
| 619 |
+
g = gamma_s_dp[shared_dp].values.astype(float)
|
| 620 |
+
t = gt_s_an[shared_dp].values.astype(float)
|
| 621 |
+
m = np.isfinite(g) & np.isfinite(t) & (g > 0) & (t > 0)
|
| 622 |
+
axes[1].scatter(t[m], g[m], alpha=0.1, s=5, c="darkorange")
|
| 623 |
+
axes[1].set_xscale("log")
|
| 624 |
+
axes[1].set_yscale("log")
|
| 625 |
+
axes[1].set_xlabel("Ground truth (new/old ratio)")
|
| 626 |
+
axes[1].set_ylabel("DeepPTR gamma")
|
| 627 |
+
axes[1].set_title(f"DeepPTR (r={corr_dp['spearman_r']:.3f})")
|
| 628 |
+
|
| 629 |
+
# Panel 3: Analytical vs DeepPTR
|
| 630 |
+
if mask_both.sum() >= 3:
|
| 631 |
+
axes[2].scatter(g_an[mask_both], g_dp[mask_both], alpha=0.1, s=5, c="seagreen")
|
| 632 |
+
axes[2].set_xscale("log")
|
| 633 |
+
axes[2].set_yscale("log")
|
| 634 |
+
lims = [min(g_an[mask_both].min(), g_dp[mask_both].min()),
|
| 635 |
+
max(g_an[mask_both].max(), g_dp[mask_both].max())]
|
| 636 |
+
axes[2].plot(lims, lims, "k--", alpha=0.3, lw=1)
|
| 637 |
+
axes[2].set_xlabel("Analytical gamma")
|
| 638 |
+
axes[2].set_ylabel("DeepPTR gamma")
|
| 639 |
+
axes[2].set_title(f"Agreement (r={agree_r:.3f})")
|
| 640 |
+
|
| 641 |
+
fig.suptitle("sci-fate: Analytical vs DeepPTR", y=1.02)
|
| 642 |
+
fig.tight_layout()
|
| 643 |
+
save_fig(fig, "scifate_comparison")
|
| 644 |
+
|
| 645 |
+
return results
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
# ============================================================================
|
| 649 |
+
# 4. SUMMARY TABLE
|
| 650 |
+
# ============================================================================
|
| 651 |
+
|
| 652 |
+
def print_summary(synth, pancreas, dg, scifate):
|
| 653 |
+
"""Print final comparison table."""
|
| 654 |
+
print(f"\n{'=' * 70}")
|
| 655 |
+
print("SUMMARY: Analytical vs DeepPTR")
|
| 656 |
+
print("=" * 70)
|
| 657 |
+
|
| 658 |
+
# Header
|
| 659 |
+
print(f"\n{'Metric':<40} {'Analytical':>12} {'DeepPTR':>12}")
|
| 660 |
+
print("-" * 65)
|
| 661 |
+
|
| 662 |
+
if synth:
|
| 663 |
+
print(f"\n SYNTHETIC RECOVERY")
|
| 664 |
+
print(f" {'Gamma recovery (per-gene r)':<38} {'N/A':>12} {synth['gamma_recovery_per_gene']:>12.4f}")
|
| 665 |
+
print(f" {'95% CI coverage':<38} {'N/A':>12} {synth['ci_coverage_95']:>12.4f}")
|
| 666 |
+
print(f" {'Latent recovery z_T':<38} {'N/A':>12} {synth['latent_recovery_T']:>12.4f}")
|
| 667 |
+
print(f" {'Latent recovery z_PT':<38} {'N/A':>12} {synth['latent_recovery_PT']:>12.4f}")
|
| 668 |
+
|
| 669 |
+
for name, res in [("PANCREAS", pancreas), ("DENTATE GYRUS", dg)]:
|
| 670 |
+
if res is None:
|
| 671 |
+
continue
|
| 672 |
+
print(f"\n {name}")
|
| 673 |
+
for ref in ("mouse_herzog", "human_schofield"):
|
| 674 |
+
if ref in res.get("halflife", {}):
|
| 675 |
+
hl = res["halflife"][ref]
|
| 676 |
+
an_r = hl["analytical"]["spearman_r"]
|
| 677 |
+
dp_r = hl["deepptr"]["spearman_r"]
|
| 678 |
+
print(f" {'Half-life ' + ref:<38} {an_r:>12.4f} {dp_r:>12.4f}")
|
| 679 |
+
if "gamma_agreement" in res:
|
| 680 |
+
print(f" {'Gamma agreement (Spearman r)':<38} {'---':>12} {res['gamma_agreement']['spearman_r']:>12.4f}")
|
| 681 |
+
|
| 682 |
+
if scifate:
|
| 683 |
+
print(f"\n SCI-FATE")
|
| 684 |
+
gt = scifate.get("ground_truth_corr", {})
|
| 685 |
+
if "analytical" in gt and "deepptr" in gt:
|
| 686 |
+
an_r = gt["analytical"]["spearman_r"]
|
| 687 |
+
dp_r = gt["deepptr"]["spearman_r"]
|
| 688 |
+
print(f" {'Ground truth (new/old ratio)':<38} {an_r:>12.4f} {dp_r:>12.4f}")
|
| 689 |
+
hl = scifate.get("halflife_human", {})
|
| 690 |
+
if "analytical" in hl and "deepptr" in hl:
|
| 691 |
+
an_r = hl["analytical"]["spearman_r"]
|
| 692 |
+
dp_r = hl["deepptr"]["spearman_r"]
|
| 693 |
+
print(f" {'Half-life (human Schofield)':<38} {an_r:>12.4f} {dp_r:>12.4f}")
|
| 694 |
+
|
| 695 |
+
print()
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def main():
|
| 699 |
+
set_figure_style()
|
| 700 |
+
ensure_dirs()
|
| 701 |
+
|
| 702 |
+
# 1. Synthetic
|
| 703 |
+
synth_results = run_synthetic_benchmark()
|
| 704 |
+
|
| 705 |
+
# 2. Pancreas
|
| 706 |
+
pancreas_results = run_real_dataset(
|
| 707 |
+
"pancreas", scptr.datasets.pancreas, cluster_key="clusters"
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
# 3. Dentate Gyrus
|
| 711 |
+
dg_results = run_real_dataset(
|
| 712 |
+
"dentate_gyrus", scptr.datasets.dentate_gyrus, cluster_key="clusters"
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
# 4. sci-fate (if data available)
|
| 716 |
+
scifate_results = run_scifate_benchmark()
|
| 717 |
+
|
| 718 |
+
# 5. Summary
|
| 719 |
+
print_summary(synth_results, pancreas_results, dg_results, scifate_results)
|
| 720 |
+
|
| 721 |
+
# Save combined results
|
| 722 |
+
combined = {
|
| 723 |
+
"synthetic": synth_results,
|
| 724 |
+
"pancreas": pancreas_results,
|
| 725 |
+
"dentate_gyrus": dg_results,
|
| 726 |
+
"scifate": scifate_results,
|
| 727 |
+
}
|
| 728 |
+
with open(OUTPUT_DIR / "results" / "combined_benchmark.json", "w") as f:
|
| 729 |
+
json.dump(combined, f, indent=2, default=str)
|
| 730 |
+
|
| 731 |
+
print(f"\nAll results saved to: {OUTPUT_DIR}")
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
if __name__ == "__main__":
|
| 735 |
+
main()
|
analyses/run_deep_benchmark.sh
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
export OMP_NUM_THREADS=4
|
| 3 |
+
export MKL_NUM_THREADS=4
|
| 4 |
+
export OPENBLAS_NUM_THREADS=4
|
| 5 |
+
export NUMEXPR_NUM_THREADS=4
|
| 6 |
+
export CUDA_VISIBLE_DEVICES=""
|
| 7 |
+
exec python -u /home/bcheng/scPTR/analyses/run_deep_benchmark.py "$@"
|
analyses/run_deep_benchmark_v2.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Expanded DeepPTR benchmark v2: deeper analysis beyond basic half-life correlation.
|
| 3 |
+
|
| 4 |
+
Adds to v1:
|
| 5 |
+
1. Enrichment on full gene set (map DeepPTR gamma onto analytical genes)
|
| 6 |
+
2. Per-cell-type gamma patterns (cell-type-specific agreement)
|
| 7 |
+
3. Uncertainty calibration on real data (variance vs prediction error)
|
| 8 |
+
4. DeepPTR subsampling robustness (retrain on subsets)
|
| 9 |
+
5. Cross-dataset consistency (DeepPTR vs analytical)
|
| 10 |
+
6. Latent space structure (z_T/z_PT UMAP colored by cell type)
|
| 11 |
+
7. Gene ranking comparison (top differentially-degraded genes)
|
| 12 |
+
|
| 13 |
+
All results saved to output/deep_benchmark_v2/.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
os.environ["OMP_NUM_THREADS"] = "4"
|
| 20 |
+
os.environ["MKL_NUM_THREADS"] = "4"
|
| 21 |
+
os.environ["OPENBLAS_NUM_THREADS"] = "4"
|
| 22 |
+
os.environ["NUMEXPR_NUM_THREADS"] = "4"
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
import sys
|
| 26 |
+
import time
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import matplotlib
|
| 30 |
+
matplotlib.use("Agg")
|
| 31 |
+
import matplotlib.pyplot as plt
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pandas as pd
|
| 34 |
+
from scipy import stats
|
| 35 |
+
|
| 36 |
+
import torch
|
| 37 |
+
torch.set_num_threads(4)
|
| 38 |
+
|
| 39 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 40 |
+
from _common import set_figure_style
|
| 41 |
+
|
| 42 |
+
import scptr
|
| 43 |
+
|
| 44 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "deep_benchmark_v2"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def save_fig(fig, name, subdir="figures"):
|
| 48 |
+
if fig is None:
|
| 49 |
+
return
|
| 50 |
+
out_dir = OUTPUT_DIR / subdir
|
| 51 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
path = out_dir / f"{name}.png"
|
| 53 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 54 |
+
plt.close(fig)
|
| 55 |
+
print(f" Saved: {path}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def ensure_dirs():
|
| 59 |
+
for sub in ("figures", "results"):
|
| 60 |
+
(OUTPUT_DIR / sub).mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def select_top_genes(adata, n_top=300):
|
| 64 |
+
"""Select top genes by unspliced signal for DeepPTR."""
|
| 65 |
+
from scipy.sparse import issparse
|
| 66 |
+
u = adata.layers["unspliced"]
|
| 67 |
+
if issparse(u):
|
| 68 |
+
u = np.asarray(u.todense())
|
| 69 |
+
u = np.asarray(u, dtype=np.float32)
|
| 70 |
+
score = u.sum(axis=0) * (u > 0).mean(axis=0)
|
| 71 |
+
top_idx = np.sort(np.argsort(score)[::-1][:n_top])
|
| 72 |
+
adata_sub = adata[:, adata.var_names[top_idx]].copy()
|
| 73 |
+
from scipy.sparse import issparse as _iss
|
| 74 |
+
for key in ("spliced", "unspliced"):
|
| 75 |
+
if key in adata_sub.layers and _iss(adata_sub.layers[key]):
|
| 76 |
+
adata_sub.layers[key] = np.asarray(adata_sub.layers[key].todense())
|
| 77 |
+
print(f" Selected top {n_top} genes (from {adata.n_vars})")
|
| 78 |
+
return adata_sub
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def run_analytical_pipeline(adata):
|
| 82 |
+
"""Full analytical scPTR pipeline."""
|
| 83 |
+
scptr.pp.filter_genes(adata)
|
| 84 |
+
scptr.pp.normalize_layers(adata)
|
| 85 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 86 |
+
scptr.pp.smooth_layers(adata)
|
| 87 |
+
scptr.tl.estimate_beta(adata)
|
| 88 |
+
scptr.tl.estimate_gamma(adata)
|
| 89 |
+
return adata
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def fit_deep(adata_deep):
|
| 93 |
+
"""Fit DeepPTR on preprocessed adata (with beta already estimated)."""
|
| 94 |
+
torch.set_num_threads(4)
|
| 95 |
+
model, history = scptr.deep.fit_deepptr(
|
| 96 |
+
adata_deep,
|
| 97 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 98 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 99 |
+
patience=15, n_posterior_samples=15,
|
| 100 |
+
device="cpu", seed=0, verbose=True,
|
| 101 |
+
)
|
| 102 |
+
return model, history
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ============================================================================
|
| 106 |
+
# 1. ENRICHMENT WITH FULL GENE MAPPING
|
| 107 |
+
# ============================================================================
|
| 108 |
+
|
| 109 |
+
def run_enrichment_mapped(adata_an, adata_deep, dataset_name):
|
| 110 |
+
"""Map DeepPTR gamma onto full analytical gene set, then run enrichment.
|
| 111 |
+
|
| 112 |
+
DeepPTR only models top-N genes. For enrichment, we create a hybrid:
|
| 113 |
+
use DeepPTR gamma where available, analytical gamma elsewhere.
|
| 114 |
+
Also test DeepPTR-only genes separately.
|
| 115 |
+
"""
|
| 116 |
+
print(f"\n--- Enrichment (mapped) ---")
|
| 117 |
+
import anndata as ad
|
| 118 |
+
|
| 119 |
+
gamma_an_med = np.median(adata_an.layers["gamma"], axis=0)
|
| 120 |
+
gamma_dp_med = np.median(adata_deep.layers["gamma"], axis=0)
|
| 121 |
+
dp_genes = set(adata_deep.var_names)
|
| 122 |
+
|
| 123 |
+
# Hybrid: prefer DeepPTR where available
|
| 124 |
+
gamma_hybrid = gamma_an_med.copy()
|
| 125 |
+
for i, g in enumerate(adata_an.var_names):
|
| 126 |
+
if g in dp_genes:
|
| 127 |
+
j = list(adata_deep.var_names).index(g)
|
| 128 |
+
gamma_hybrid[i] = gamma_dp_med[j]
|
| 129 |
+
|
| 130 |
+
# Create hybrid adata for enrichment
|
| 131 |
+
adata_hybrid = adata_an.copy()
|
| 132 |
+
adata_hybrid.layers["gamma"] = np.tile(gamma_hybrid, (adata_an.n_obs, 1))
|
| 133 |
+
|
| 134 |
+
results = {}
|
| 135 |
+
for test_name, test_fn in [("ARE", scptr.benchmark.are_enrichment),
|
| 136 |
+
("NMD", scptr.benchmark.nmd_enrichment)]:
|
| 137 |
+
res_an = test_fn(adata_an)
|
| 138 |
+
res_hybrid = test_fn(adata_hybrid)
|
| 139 |
+
|
| 140 |
+
results[test_name] = {
|
| 141 |
+
"analytical": {
|
| 142 |
+
"p_value": float(res_an.get("p_value", np.nan)),
|
| 143 |
+
"n_genes_in_set": int(res_an.get("n_genes_in_set", 0)),
|
| 144 |
+
"median_gamma_in": float(res_an.get("median_gamma_in_set", np.nan)),
|
| 145 |
+
"median_gamma_bg": float(res_an.get("median_gamma_background", np.nan)),
|
| 146 |
+
},
|
| 147 |
+
"hybrid_deepptr": {
|
| 148 |
+
"p_value": float(res_hybrid.get("p_value", np.nan)),
|
| 149 |
+
"n_genes_in_set": int(res_hybrid.get("n_genes_in_set", 0)),
|
| 150 |
+
"median_gamma_in": float(res_hybrid.get("median_gamma_in_set", np.nan)),
|
| 151 |
+
"median_gamma_bg": float(res_hybrid.get("median_gamma_background", np.nan)),
|
| 152 |
+
},
|
| 153 |
+
}
|
| 154 |
+
p_an = res_an.get("p_value", np.nan)
|
| 155 |
+
p_hy = res_hybrid.get("p_value", np.nan)
|
| 156 |
+
print(f" {test_name}: analytical p={p_an:.2e}, hybrid p={p_hy:.2e}")
|
| 157 |
+
|
| 158 |
+
return results
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ============================================================================
|
| 162 |
+
# 2. PER-CELL-TYPE GAMMA AGREEMENT
|
| 163 |
+
# ============================================================================
|
| 164 |
+
|
| 165 |
+
def run_celltype_agreement(adata_an, adata_deep, dataset_name, cluster_key="clusters"):
|
| 166 |
+
"""Compare per-cell-type median gamma between analytical and DeepPTR."""
|
| 167 |
+
print(f"\n--- Per-cell-type gamma agreement ---")
|
| 168 |
+
|
| 169 |
+
if cluster_key not in adata_an.obs.columns:
|
| 170 |
+
print(f" [SKIP] No '{cluster_key}' column")
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
shared_genes = adata_an.var_names.intersection(adata_deep.var_names)
|
| 174 |
+
if len(shared_genes) < 10:
|
| 175 |
+
print(f" [SKIP] Too few shared genes ({len(shared_genes)})")
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
an_idx = [list(adata_an.var_names).index(g) for g in shared_genes]
|
| 179 |
+
dp_idx = [list(adata_deep.var_names).index(g) for g in shared_genes]
|
| 180 |
+
|
| 181 |
+
cell_types = adata_an.obs[cluster_key].unique()
|
| 182 |
+
records = []
|
| 183 |
+
|
| 184 |
+
for ct in sorted(cell_types):
|
| 185 |
+
mask_an = adata_an.obs[cluster_key] == ct
|
| 186 |
+
mask_dp = adata_deep.obs[cluster_key] == ct
|
| 187 |
+
|
| 188 |
+
if mask_an.sum() < 5 or mask_dp.sum() < 5:
|
| 189 |
+
continue
|
| 190 |
+
|
| 191 |
+
gamma_an_ct = np.median(adata_an.layers["gamma"][mask_an][:, an_idx], axis=0)
|
| 192 |
+
gamma_dp_ct = np.median(adata_deep.layers["gamma"][mask_dp][:, dp_idx], axis=0)
|
| 193 |
+
|
| 194 |
+
valid = (gamma_an_ct > 0) & (gamma_dp_ct > 0) & np.isfinite(gamma_an_ct) & np.isfinite(gamma_dp_ct)
|
| 195 |
+
if valid.sum() < 5:
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
sp_r, _ = stats.spearmanr(gamma_an_ct[valid], gamma_dp_ct[valid])
|
| 199 |
+
records.append({
|
| 200 |
+
"cell_type": str(ct),
|
| 201 |
+
"n_cells_an": int(mask_an.sum()),
|
| 202 |
+
"n_cells_dp": int(mask_dp.sum()),
|
| 203 |
+
"n_genes": int(valid.sum()),
|
| 204 |
+
"spearman_r": float(sp_r),
|
| 205 |
+
})
|
| 206 |
+
print(f" {ct}: r={sp_r:.4f} (n_genes={valid.sum()}, n_cells={mask_an.sum()})")
|
| 207 |
+
|
| 208 |
+
if not records:
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
df = pd.DataFrame(records)
|
| 212 |
+
|
| 213 |
+
# Plot
|
| 214 |
+
fig, ax = plt.subplots(figsize=(8, 4))
|
| 215 |
+
ax.barh(df["cell_type"], df["spearman_r"], color="steelblue", alpha=0.7)
|
| 216 |
+
ax.set_xlabel("Spearman r (analytical vs DeepPTR)")
|
| 217 |
+
ax.set_title(f"{dataset_name}: Per-cell-type gamma agreement")
|
| 218 |
+
ax.axvline(x=df["spearman_r"].median(), color="red", ls="--", alpha=0.5,
|
| 219 |
+
label=f"median={df['spearman_r'].median():.3f}")
|
| 220 |
+
ax.legend()
|
| 221 |
+
fig.tight_layout()
|
| 222 |
+
save_fig(fig, f"{dataset_name}_celltype_agreement")
|
| 223 |
+
|
| 224 |
+
return records
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ============================================================================
|
| 228 |
+
# 3. UNCERTAINTY CALIBRATION ON REAL DATA
|
| 229 |
+
# ============================================================================
|
| 230 |
+
|
| 231 |
+
def run_uncertainty_analysis(adata_an, adata_deep, dataset_name):
|
| 232 |
+
"""Evaluate DeepPTR uncertainty: does high variance predict high error?"""
|
| 233 |
+
print(f"\n--- Uncertainty calibration ---")
|
| 234 |
+
|
| 235 |
+
shared_genes = adata_an.var_names.intersection(adata_deep.var_names)
|
| 236 |
+
if len(shared_genes) < 10:
|
| 237 |
+
print(f" [SKIP] Too few shared genes")
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
an_idx = [list(adata_an.var_names).index(g) for g in shared_genes]
|
| 241 |
+
dp_idx = [list(adata_deep.var_names).index(g) for g in shared_genes]
|
| 242 |
+
|
| 243 |
+
# Per-gene: compare variance with squared error vs analytical
|
| 244 |
+
gamma_an = np.median(adata_an.layers["gamma"][:, an_idx], axis=0)
|
| 245 |
+
gamma_dp = np.median(adata_deep.layers["gamma"][:, dp_idx], axis=0)
|
| 246 |
+
gamma_var = np.mean(adata_deep.layers["gamma_var"][:, dp_idx], axis=0)
|
| 247 |
+
|
| 248 |
+
# Prediction error (using analytical as reference)
|
| 249 |
+
valid = (gamma_an > 0) & (gamma_dp > 0) & np.isfinite(gamma_an) & np.isfinite(gamma_dp)
|
| 250 |
+
if valid.sum() < 10:
|
| 251 |
+
print(f" [SKIP] Too few valid genes")
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
error = np.abs(gamma_dp[valid] - gamma_an[valid])
|
| 255 |
+
var = gamma_var[valid]
|
| 256 |
+
|
| 257 |
+
# Does high posterior variance correlate with high error?
|
| 258 |
+
sp_r, sp_p = stats.spearmanr(var, error)
|
| 259 |
+
print(f" Variance-error correlation: Spearman r = {sp_r:.4f} (p={sp_p:.2e})")
|
| 260 |
+
|
| 261 |
+
# Binned calibration: split genes into variance quintiles
|
| 262 |
+
n_bins = 5
|
| 263 |
+
var_ranks = np.argsort(np.argsort(var))
|
| 264 |
+
bin_size = len(var) // n_bins
|
| 265 |
+
bin_errors = []
|
| 266 |
+
bin_vars = []
|
| 267 |
+
for b in range(n_bins):
|
| 268 |
+
mask = (var_ranks >= b * bin_size) & (var_ranks < (b + 1) * bin_size)
|
| 269 |
+
if b == n_bins - 1:
|
| 270 |
+
mask = var_ranks >= b * bin_size
|
| 271 |
+
bin_errors.append(np.median(error[mask]))
|
| 272 |
+
bin_vars.append(np.median(var[mask]))
|
| 273 |
+
|
| 274 |
+
result = {
|
| 275 |
+
"var_error_spearman_r": float(sp_r),
|
| 276 |
+
"var_error_spearman_p": float(sp_p),
|
| 277 |
+
"n_genes": int(valid.sum()),
|
| 278 |
+
"bin_median_var": [float(v) for v in bin_vars],
|
| 279 |
+
"bin_median_error": [float(e) for e in bin_errors],
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
# Plot
|
| 283 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 284 |
+
|
| 285 |
+
# Scatter: variance vs error
|
| 286 |
+
axes[0].scatter(var, error, alpha=0.2, s=8, c="steelblue")
|
| 287 |
+
axes[0].set_xlabel("Mean posterior variance")
|
| 288 |
+
axes[0].set_ylabel("|DeepPTR - Analytical| error")
|
| 289 |
+
axes[0].set_title(f"Variance vs Error (r={sp_r:.3f})")
|
| 290 |
+
axes[0].set_xscale("log")
|
| 291 |
+
axes[0].set_yscale("log")
|
| 292 |
+
|
| 293 |
+
# Binned calibration
|
| 294 |
+
axes[1].bar(range(n_bins), bin_errors, color="steelblue", alpha=0.7)
|
| 295 |
+
axes[1].set_xlabel("Posterior variance quintile (low → high)")
|
| 296 |
+
axes[1].set_ylabel("Median absolute error")
|
| 297 |
+
axes[1].set_title(f"{dataset_name}: Calibration")
|
| 298 |
+
axes[1].set_xticks(range(n_bins))
|
| 299 |
+
axes[1].set_xticklabels([f"Q{i+1}" for i in range(n_bins)])
|
| 300 |
+
|
| 301 |
+
fig.tight_layout()
|
| 302 |
+
save_fig(fig, f"{dataset_name}_uncertainty_calibration")
|
| 303 |
+
|
| 304 |
+
return result
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ============================================================================
|
| 308 |
+
# 4. DEEPPTR SUBSAMPLING ROBUSTNESS
|
| 309 |
+
# ============================================================================
|
| 310 |
+
|
| 311 |
+
def run_deep_subsampling(adata_loader, dataset_name, fractions=(0.5, 0.8)):
|
| 312 |
+
"""Test DeepPTR robustness by retraining on subsampled cells."""
|
| 313 |
+
print(f"\n--- DeepPTR subsampling robustness ---")
|
| 314 |
+
|
| 315 |
+
# Full model
|
| 316 |
+
adata_full = adata_loader()
|
| 317 |
+
scptr.pp.filter_genes(adata_full)
|
| 318 |
+
scptr.pp.normalize_layers(adata_full)
|
| 319 |
+
scptr.pp.neighbors(adata_full, n_neighbors=30)
|
| 320 |
+
scptr.pp.smooth_layers(adata_full)
|
| 321 |
+
scptr.tl.estimate_beta(adata_full)
|
| 322 |
+
adata_full = select_top_genes(adata_full, n_top=300)
|
| 323 |
+
|
| 324 |
+
torch.set_num_threads(4)
|
| 325 |
+
model_full, _ = scptr.deep.fit_deepptr(
|
| 326 |
+
adata_full,
|
| 327 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 328 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 329 |
+
patience=15, n_posterior_samples=10,
|
| 330 |
+
device="cpu", seed=0, verbose=False,
|
| 331 |
+
)
|
| 332 |
+
gamma_full = np.median(adata_full.layers["gamma"], axis=0)
|
| 333 |
+
|
| 334 |
+
records = []
|
| 335 |
+
rng = np.random.RandomState(42)
|
| 336 |
+
|
| 337 |
+
for frac in fractions:
|
| 338 |
+
n_sub = max(int(adata_full.n_obs * frac), 50)
|
| 339 |
+
idx = rng.choice(adata_full.n_obs, size=n_sub, replace=False)
|
| 340 |
+
|
| 341 |
+
adata_sub = adata_full[idx].copy()
|
| 342 |
+
# Ensure dense
|
| 343 |
+
from scipy.sparse import issparse
|
| 344 |
+
for key in ("spliced", "unspliced"):
|
| 345 |
+
if key in adata_sub.layers and issparse(adata_sub.layers[key]):
|
| 346 |
+
adata_sub.layers[key] = np.asarray(adata_sub.layers[key].todense())
|
| 347 |
+
|
| 348 |
+
torch.set_num_threads(4)
|
| 349 |
+
_, _ = scptr.deep.fit_deepptr(
|
| 350 |
+
adata_sub,
|
| 351 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 352 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 353 |
+
patience=15, n_posterior_samples=10,
|
| 354 |
+
device="cpu", seed=0, verbose=False,
|
| 355 |
+
)
|
| 356 |
+
gamma_sub = np.median(adata_sub.layers["gamma"], axis=0)
|
| 357 |
+
|
| 358 |
+
valid = np.isfinite(gamma_full) & np.isfinite(gamma_sub)
|
| 359 |
+
sp_r, _ = stats.spearmanr(gamma_full[valid], gamma_sub[valid])
|
| 360 |
+
|
| 361 |
+
records.append({
|
| 362 |
+
"fraction": frac,
|
| 363 |
+
"n_cells": n_sub,
|
| 364 |
+
"spearman_r": float(sp_r),
|
| 365 |
+
})
|
| 366 |
+
print(f" {frac*100:.0f}%: r={sp_r:.4f} (n_cells={n_sub})")
|
| 367 |
+
|
| 368 |
+
return records
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ============================================================================
|
| 372 |
+
# 5. LATENT SPACE VISUALIZATION
|
| 373 |
+
# ============================================================================
|
| 374 |
+
|
| 375 |
+
def run_latent_analysis(adata_deep, dataset_name, cluster_key="clusters"):
|
| 376 |
+
"""Visualize DeepPTR latent spaces with UMAP."""
|
| 377 |
+
print(f"\n--- Latent space visualization ---")
|
| 378 |
+
import scanpy as sc
|
| 379 |
+
|
| 380 |
+
if "X_z_T" not in adata_deep.obsm or "X_z_PT" not in adata_deep.obsm:
|
| 381 |
+
print(" [SKIP] No latent embeddings found")
|
| 382 |
+
return None
|
| 383 |
+
|
| 384 |
+
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
|
| 385 |
+
|
| 386 |
+
has_ct = cluster_key in adata_deep.obs.columns
|
| 387 |
+
|
| 388 |
+
for ax_idx, (key, title) in enumerate([
|
| 389 |
+
("X_z_T", "z_T (transcription)"),
|
| 390 |
+
("X_z_PT", "z_PT (post-transcription)"),
|
| 391 |
+
]):
|
| 392 |
+
z = adata_deep.obsm[key]
|
| 393 |
+
# Quick PCA+UMAP for visualization
|
| 394 |
+
from sklearn.decomposition import PCA
|
| 395 |
+
if z.shape[1] > 2:
|
| 396 |
+
pca = PCA(n_components=2)
|
| 397 |
+
z_2d = pca.fit_transform(z)
|
| 398 |
+
else:
|
| 399 |
+
z_2d = z
|
| 400 |
+
|
| 401 |
+
if has_ct:
|
| 402 |
+
categories = adata_deep.obs[cluster_key].astype("category")
|
| 403 |
+
codes = categories.cat.codes.values
|
| 404 |
+
cmap = plt.cm.get_cmap("tab20", len(categories.cat.categories))
|
| 405 |
+
scatter = axes[ax_idx].scatter(z_2d[:, 0], z_2d[:, 1], c=codes,
|
| 406 |
+
cmap=cmap, alpha=0.3, s=3)
|
| 407 |
+
else:
|
| 408 |
+
axes[ax_idx].scatter(z_2d[:, 0], z_2d[:, 1], alpha=0.3, s=3, c="steelblue")
|
| 409 |
+
axes[ax_idx].set_title(title)
|
| 410 |
+
axes[ax_idx].set_xlabel("PC1")
|
| 411 |
+
axes[ax_idx].set_ylabel("PC2")
|
| 412 |
+
|
| 413 |
+
# Third panel: gamma PCA
|
| 414 |
+
gamma = adata_deep.layers["gamma"]
|
| 415 |
+
from sklearn.decomposition import PCA
|
| 416 |
+
pca = PCA(n_components=2)
|
| 417 |
+
g_2d = pca.fit_transform(gamma)
|
| 418 |
+
if has_ct:
|
| 419 |
+
categories = adata_deep.obs[cluster_key].astype("category")
|
| 420 |
+
codes = categories.cat.codes.values
|
| 421 |
+
cmap = plt.cm.get_cmap("tab20", len(categories.cat.categories))
|
| 422 |
+
axes[2].scatter(g_2d[:, 0], g_2d[:, 1], c=codes, cmap=cmap, alpha=0.3, s=3)
|
| 423 |
+
else:
|
| 424 |
+
axes[2].scatter(g_2d[:, 0], g_2d[:, 1], alpha=0.3, s=3, c="steelblue")
|
| 425 |
+
axes[2].set_title("gamma (DeepPTR)")
|
| 426 |
+
axes[2].set_xlabel("PC1")
|
| 427 |
+
axes[2].set_ylabel("PC2")
|
| 428 |
+
|
| 429 |
+
if has_ct:
|
| 430 |
+
cats = categories.cat.categories.tolist()
|
| 431 |
+
if len(cats) <= 15:
|
| 432 |
+
handles = [plt.Line2D([0], [0], marker="o", color="w",
|
| 433 |
+
markerfacecolor=cmap(i), markersize=6, label=c)
|
| 434 |
+
for i, c in enumerate(cats)]
|
| 435 |
+
fig.legend(handles=handles, loc="center right", fontsize=7,
|
| 436 |
+
bbox_to_anchor=(1.15, 0.5))
|
| 437 |
+
|
| 438 |
+
fig.suptitle(f"{dataset_name}: DeepPTR Latent Spaces", y=1.02)
|
| 439 |
+
fig.tight_layout()
|
| 440 |
+
save_fig(fig, f"{dataset_name}_latent_spaces")
|
| 441 |
+
|
| 442 |
+
# Quantify: silhouette score of cell types in latent space
|
| 443 |
+
if has_ct and len(categories.cat.categories) >= 2:
|
| 444 |
+
from sklearn.metrics import silhouette_score
|
| 445 |
+
codes = categories.cat.codes.values
|
| 446 |
+
sil_T = silhouette_score(adata_deep.obsm["X_z_T"], codes, sample_size=min(2000, len(codes)))
|
| 447 |
+
sil_PT = silhouette_score(adata_deep.obsm["X_z_PT"], codes, sample_size=min(2000, len(codes)))
|
| 448 |
+
sil_gamma = silhouette_score(gamma, codes, sample_size=min(2000, len(codes)))
|
| 449 |
+
print(f" Silhouette: z_T={sil_T:.4f}, z_PT={sil_PT:.4f}, gamma={sil_gamma:.4f}")
|
| 450 |
+
return {"silhouette_z_T": sil_T, "silhouette_z_PT": sil_PT, "silhouette_gamma": sil_gamma}
|
| 451 |
+
|
| 452 |
+
return None
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ============================================================================
|
| 456 |
+
# 6. GENE RANKING COMPARISON
|
| 457 |
+
# ============================================================================
|
| 458 |
+
|
| 459 |
+
def run_gene_ranking(adata_an, adata_deep, dataset_name, n_top=50):
|
| 460 |
+
"""Compare top differentially-degraded genes between methods."""
|
| 461 |
+
print(f"\n--- Gene ranking comparison (top {n_top}) ---")
|
| 462 |
+
|
| 463 |
+
shared_genes = adata_an.var_names.intersection(adata_deep.var_names)
|
| 464 |
+
if len(shared_genes) < 20:
|
| 465 |
+
print(" [SKIP] Too few shared genes")
|
| 466 |
+
return None
|
| 467 |
+
|
| 468 |
+
gamma_an = pd.Series(
|
| 469 |
+
np.median(adata_an.layers["gamma"], axis=0), index=adata_an.var_names
|
| 470 |
+
)
|
| 471 |
+
gamma_dp = pd.Series(
|
| 472 |
+
np.median(adata_deep.layers["gamma"], axis=0), index=adata_deep.var_names
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
# Variance of gamma across cells (identifies genes with heterogeneous degradation)
|
| 476 |
+
gamma_var_an = pd.Series(
|
| 477 |
+
np.var(adata_an.layers["gamma"], axis=0), index=adata_an.var_names
|
| 478 |
+
)
|
| 479 |
+
gamma_var_dp = pd.Series(
|
| 480 |
+
np.var(adata_deep.layers["gamma"], axis=0), index=adata_deep.var_names
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
# Top genes by median gamma (shared)
|
| 484 |
+
top_an = gamma_an[shared_genes].nlargest(n_top).index.tolist()
|
| 485 |
+
top_dp = gamma_dp[shared_genes].nlargest(n_top).index.tolist()
|
| 486 |
+
overlap_median = len(set(top_an) & set(top_dp))
|
| 487 |
+
|
| 488 |
+
# Top genes by gamma variance (shared)
|
| 489 |
+
top_var_an = gamma_var_an[shared_genes].nlargest(n_top).index.tolist()
|
| 490 |
+
top_var_dp = gamma_var_dp[shared_genes].nlargest(n_top).index.tolist()
|
| 491 |
+
overlap_var = len(set(top_var_an) & set(top_var_dp))
|
| 492 |
+
|
| 493 |
+
# Rank correlation on shared genes
|
| 494 |
+
ranks_an = gamma_an[shared_genes].rank(ascending=False)
|
| 495 |
+
ranks_dp = gamma_dp[shared_genes].rank(ascending=False)
|
| 496 |
+
rank_corr, _ = stats.spearmanr(ranks_an.values, ranks_dp.values)
|
| 497 |
+
|
| 498 |
+
result = {
|
| 499 |
+
"n_shared_genes": len(shared_genes),
|
| 500 |
+
"top_median_overlap": overlap_median,
|
| 501 |
+
"top_median_overlap_frac": overlap_median / n_top,
|
| 502 |
+
"top_var_overlap": overlap_var,
|
| 503 |
+
"top_var_overlap_frac": overlap_var / n_top,
|
| 504 |
+
"rank_correlation": float(rank_corr),
|
| 505 |
+
}
|
| 506 |
+
print(f" Top-{n_top} median gamma overlap: {overlap_median}/{n_top} ({overlap_median/n_top*100:.0f}%)")
|
| 507 |
+
print(f" Top-{n_top} var gamma overlap: {overlap_var}/{n_top} ({overlap_var/n_top*100:.0f}%)")
|
| 508 |
+
print(f" Rank correlation (shared genes): {rank_corr:.4f}")
|
| 509 |
+
|
| 510 |
+
return result
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
# ============================================================================
|
| 514 |
+
# MAIN: RUN ON EACH DATASET
|
| 515 |
+
# ============================================================================
|
| 516 |
+
|
| 517 |
+
def run_dataset(name, adata_loader, cluster_key="clusters"):
|
| 518 |
+
"""Run all expanded benchmarks on one dataset."""
|
| 519 |
+
print(f"\n{'=' * 60}")
|
| 520 |
+
print(f"DATASET: {name.upper()}")
|
| 521 |
+
print("=" * 60)
|
| 522 |
+
|
| 523 |
+
# --- Analytical ---
|
| 524 |
+
print(f"\n--- Analytical pipeline ---")
|
| 525 |
+
adata_an = adata_loader()
|
| 526 |
+
run_analytical_pipeline(adata_an)
|
| 527 |
+
print(f" Analytical: {adata_an.shape}")
|
| 528 |
+
|
| 529 |
+
# --- DeepPTR ---
|
| 530 |
+
print(f"\n--- DeepPTR ---")
|
| 531 |
+
adata_deep = adata_loader()
|
| 532 |
+
scptr.pp.filter_genes(adata_deep)
|
| 533 |
+
scptr.pp.normalize_layers(adata_deep)
|
| 534 |
+
scptr.pp.neighbors(adata_deep, n_neighbors=30)
|
| 535 |
+
scptr.pp.smooth_layers(adata_deep)
|
| 536 |
+
scptr.tl.estimate_beta(adata_deep)
|
| 537 |
+
adata_deep = select_top_genes(adata_deep, n_top=300)
|
| 538 |
+
|
| 539 |
+
t0 = time.time()
|
| 540 |
+
model, history = fit_deep(adata_deep)
|
| 541 |
+
elapsed = time.time() - t0
|
| 542 |
+
print(f" DeepPTR: {len(history.train_loss)} epochs in {elapsed:.1f}s")
|
| 543 |
+
|
| 544 |
+
all_results = {"dataset": name, "n_epochs": len(history.train_loss), "time_s": elapsed}
|
| 545 |
+
|
| 546 |
+
# 1. Enrichment
|
| 547 |
+
enrich = run_enrichment_mapped(adata_an, adata_deep, name)
|
| 548 |
+
all_results["enrichment"] = enrich
|
| 549 |
+
|
| 550 |
+
# 2. Per-cell-type
|
| 551 |
+
ct_results = run_celltype_agreement(adata_an, adata_deep, name, cluster_key)
|
| 552 |
+
all_results["celltype_agreement"] = ct_results
|
| 553 |
+
|
| 554 |
+
# 3. Uncertainty
|
| 555 |
+
unc_results = run_uncertainty_analysis(adata_an, adata_deep, name)
|
| 556 |
+
all_results["uncertainty"] = unc_results
|
| 557 |
+
|
| 558 |
+
# 4. Latent space
|
| 559 |
+
lat_results = run_latent_analysis(adata_deep, name, cluster_key)
|
| 560 |
+
all_results["latent_structure"] = lat_results
|
| 561 |
+
|
| 562 |
+
# 5. Gene ranking
|
| 563 |
+
rank_results = run_gene_ranking(adata_an, adata_deep, name)
|
| 564 |
+
all_results["gene_ranking"] = rank_results
|
| 565 |
+
|
| 566 |
+
# Save
|
| 567 |
+
with open(OUTPUT_DIR / "results" / f"{name}_v2.json", "w") as f:
|
| 568 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 569 |
+
|
| 570 |
+
return all_results
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def run_cross_dataset_consistency(datasets):
|
| 574 |
+
"""Compare cross-dataset consistency for analytical vs DeepPTR."""
|
| 575 |
+
print(f"\n{'=' * 60}")
|
| 576 |
+
print("CROSS-DATASET CONSISTENCY")
|
| 577 |
+
print("=" * 60)
|
| 578 |
+
|
| 579 |
+
# Build analytical and deep adatas
|
| 580 |
+
an_dict = {}
|
| 581 |
+
dp_dict = {}
|
| 582 |
+
|
| 583 |
+
for name, loader, cluster_key in datasets:
|
| 584 |
+
print(f"\n Processing {name}...")
|
| 585 |
+
adata_an = loader()
|
| 586 |
+
run_analytical_pipeline(adata_an)
|
| 587 |
+
an_dict[name] = adata_an
|
| 588 |
+
|
| 589 |
+
adata_dp = loader()
|
| 590 |
+
scptr.pp.filter_genes(adata_dp)
|
| 591 |
+
scptr.pp.normalize_layers(adata_dp)
|
| 592 |
+
scptr.pp.neighbors(adata_dp, n_neighbors=30)
|
| 593 |
+
scptr.pp.smooth_layers(adata_dp)
|
| 594 |
+
scptr.tl.estimate_beta(adata_dp)
|
| 595 |
+
adata_dp = select_top_genes(adata_dp, n_top=300)
|
| 596 |
+
torch.set_num_threads(4)
|
| 597 |
+
scptr.deep.fit_deepptr(
|
| 598 |
+
adata_dp,
|
| 599 |
+
d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 600 |
+
batch_size=512, max_epochs=100, kl_warmup_epochs=20,
|
| 601 |
+
patience=15, n_posterior_samples=10,
|
| 602 |
+
device="cpu", seed=0, verbose=False,
|
| 603 |
+
)
|
| 604 |
+
dp_dict[name] = adata_dp
|
| 605 |
+
|
| 606 |
+
print(f"\n--- Analytical cross-dataset ---")
|
| 607 |
+
cons_an = scptr.benchmark.cross_dataset_consistency(an_dict)
|
| 608 |
+
print(cons_an.to_string(index=False))
|
| 609 |
+
|
| 610 |
+
print(f"\n--- DeepPTR cross-dataset ---")
|
| 611 |
+
cons_dp = scptr.benchmark.cross_dataset_consistency(dp_dict)
|
| 612 |
+
print(cons_dp.to_string(index=False))
|
| 613 |
+
|
| 614 |
+
result = {
|
| 615 |
+
"analytical": cons_an.to_dict(orient="records"),
|
| 616 |
+
"deepptr": cons_dp.to_dict(orient="records"),
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
with open(OUTPUT_DIR / "results" / "cross_dataset_consistency.json", "w") as f:
|
| 620 |
+
json.dump(result, f, indent=2, default=str)
|
| 621 |
+
|
| 622 |
+
return result
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def run_subsampling_all(datasets):
|
| 626 |
+
"""Run DeepPTR subsampling robustness on each dataset."""
|
| 627 |
+
print(f"\n{'=' * 60}")
|
| 628 |
+
print("DEEPPTR SUBSAMPLING ROBUSTNESS")
|
| 629 |
+
print("=" * 60)
|
| 630 |
+
|
| 631 |
+
all_results = {}
|
| 632 |
+
for name, loader, _ in datasets:
|
| 633 |
+
print(f"\n {name}:")
|
| 634 |
+
records = run_deep_subsampling(loader, name, fractions=(0.5, 0.8))
|
| 635 |
+
all_results[name] = records
|
| 636 |
+
|
| 637 |
+
with open(OUTPUT_DIR / "results" / "subsampling_robustness.json", "w") as f:
|
| 638 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 639 |
+
|
| 640 |
+
return all_results
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def print_summary(results, cross_ds, subsampling):
|
| 644 |
+
"""Print final summary table."""
|
| 645 |
+
print(f"\n{'=' * 70}")
|
| 646 |
+
print("EXPANDED BENCHMARK SUMMARY")
|
| 647 |
+
print("=" * 70)
|
| 648 |
+
|
| 649 |
+
for name, res in results.items():
|
| 650 |
+
print(f"\n {name.upper()}")
|
| 651 |
+
|
| 652 |
+
# Enrichment
|
| 653 |
+
enrich = res.get("enrichment", {})
|
| 654 |
+
for test in ("ARE", "NMD"):
|
| 655 |
+
if test in enrich:
|
| 656 |
+
p_an = enrich[test].get("analytical", {}).get("p_value", np.nan)
|
| 657 |
+
p_hy = enrich[test].get("hybrid_deepptr", {}).get("p_value", np.nan)
|
| 658 |
+
print(f" {test} enrichment: analytical p={p_an:.2e}, hybrid p={p_hy:.2e}")
|
| 659 |
+
|
| 660 |
+
# Cell-type agreement
|
| 661 |
+
ct = res.get("celltype_agreement")
|
| 662 |
+
if ct:
|
| 663 |
+
median_r = np.median([r["spearman_r"] for r in ct])
|
| 664 |
+
print(f" Cell-type agreement: median r={median_r:.4f} ({len(ct)} types)")
|
| 665 |
+
|
| 666 |
+
# Uncertainty
|
| 667 |
+
unc = res.get("uncertainty")
|
| 668 |
+
if unc:
|
| 669 |
+
print(f" Uncertainty calibration: var-error r={unc['var_error_spearman_r']:.4f}")
|
| 670 |
+
|
| 671 |
+
# Latent
|
| 672 |
+
lat = res.get("latent_structure")
|
| 673 |
+
if lat:
|
| 674 |
+
print(f" Silhouette: z_T={lat['silhouette_z_T']:.4f}, z_PT={lat['silhouette_z_PT']:.4f}, gamma={lat['silhouette_gamma']:.4f}")
|
| 675 |
+
|
| 676 |
+
# Gene ranking
|
| 677 |
+
rank = res.get("gene_ranking")
|
| 678 |
+
if rank:
|
| 679 |
+
print(f" Gene ranking: top-50 overlap={rank['top_median_overlap']}/50, rank r={rank['rank_correlation']:.4f}")
|
| 680 |
+
|
| 681 |
+
# Cross-dataset
|
| 682 |
+
if cross_ds:
|
| 683 |
+
print(f"\n CROSS-DATASET CONSISTENCY")
|
| 684 |
+
for method in ("analytical", "deepptr"):
|
| 685 |
+
entries = cross_ds.get(method, [])
|
| 686 |
+
for e in entries:
|
| 687 |
+
print(f" {method}: {e['dataset_a']} vs {e['dataset_b']}: "
|
| 688 |
+
f"r={e['spearman_r']:.4f} (n={e['n_shared_genes']})")
|
| 689 |
+
|
| 690 |
+
# Subsampling
|
| 691 |
+
if subsampling:
|
| 692 |
+
print(f"\n SUBSAMPLING ROBUSTNESS (DeepPTR)")
|
| 693 |
+
for ds_name, records in subsampling.items():
|
| 694 |
+
for r in records:
|
| 695 |
+
print(f" {ds_name} @ {r['fraction']*100:.0f}%: r={r['spearman_r']:.4f}")
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def main():
|
| 699 |
+
set_figure_style()
|
| 700 |
+
ensure_dirs()
|
| 701 |
+
|
| 702 |
+
datasets = [
|
| 703 |
+
("pancreas", scptr.datasets.pancreas, "clusters"),
|
| 704 |
+
("dentate_gyrus", scptr.datasets.dentate_gyrus, "clusters"),
|
| 705 |
+
]
|
| 706 |
+
|
| 707 |
+
# Per-dataset analysis
|
| 708 |
+
results = {}
|
| 709 |
+
for name, loader, cluster_key in datasets:
|
| 710 |
+
results[name] = run_dataset(name, loader, cluster_key)
|
| 711 |
+
|
| 712 |
+
# Cross-dataset consistency
|
| 713 |
+
cross_ds = run_cross_dataset_consistency(datasets)
|
| 714 |
+
|
| 715 |
+
# Subsampling robustness
|
| 716 |
+
subsampling = run_subsampling_all(datasets)
|
| 717 |
+
|
| 718 |
+
# Summary
|
| 719 |
+
print_summary(results, cross_ds, subsampling)
|
| 720 |
+
|
| 721 |
+
# Save combined
|
| 722 |
+
combined = {
|
| 723 |
+
"per_dataset": {k: v for k, v in results.items()},
|
| 724 |
+
"cross_dataset": cross_ds,
|
| 725 |
+
"subsampling": subsampling,
|
| 726 |
+
}
|
| 727 |
+
with open(OUTPUT_DIR / "results" / "combined_v2.json", "w") as f:
|
| 728 |
+
json.dump(combined, f, indent=2, default=str)
|
| 729 |
+
|
| 730 |
+
print(f"\nAll results saved to: {OUTPUT_DIR}")
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
if __name__ == "__main__":
|
| 734 |
+
main()
|
analyses/run_dentate_gyrus.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Run the full scPTR analysis pipeline on dentate gyrus data.
|
| 3 |
+
|
| 4 |
+
This script mirrors run_all.py but on the dentate gyrus neurogenesis dataset.
|
| 5 |
+
Results are saved to output/dentate_gyrus/ directory.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import matplotlib
|
| 15 |
+
matplotlib.use("Agg")
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
import numpy as np
|
| 18 |
+
import pandas as pd
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 21 |
+
from _common import set_figure_style
|
| 22 |
+
|
| 23 |
+
import scptr
|
| 24 |
+
|
| 25 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "dentate_gyrus"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def save_fig(fig, name, subdir="figures"):
|
| 29 |
+
"""Save a matplotlib figure to output dir."""
|
| 30 |
+
if fig is None:
|
| 31 |
+
print(f" [WARNING] {name}: plot returned None, skipping save")
|
| 32 |
+
return
|
| 33 |
+
out_dir = OUTPUT_DIR / subdir
|
| 34 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
path = out_dir / f"{name}.png"
|
| 36 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 37 |
+
plt.close(fig)
|
| 38 |
+
print(f" Saved: {path}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main():
|
| 42 |
+
set_figure_style()
|
| 43 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
# =========================================================================
|
| 46 |
+
# LOAD DATA
|
| 47 |
+
# =========================================================================
|
| 48 |
+
print("=" * 60)
|
| 49 |
+
print("LOADING DENTATE GYRUS DATASET")
|
| 50 |
+
print("=" * 60)
|
| 51 |
+
adata = scptr.datasets.dentate_gyrus()
|
| 52 |
+
print(f" Shape: {adata.shape}")
|
| 53 |
+
print(f" Layers: {list(adata.layers.keys())}")
|
| 54 |
+
print(f" Cell types: {adata.obs['clusters'].value_counts().to_dict()}")
|
| 55 |
+
|
| 56 |
+
# =========================================================================
|
| 57 |
+
# PREPROCESSING
|
| 58 |
+
# =========================================================================
|
| 59 |
+
print("\n" + "=" * 60)
|
| 60 |
+
print("PREPROCESSING")
|
| 61 |
+
print("=" * 60)
|
| 62 |
+
|
| 63 |
+
scptr.pp.filter_genes(adata)
|
| 64 |
+
print(f" After filtering: {adata.shape}")
|
| 65 |
+
|
| 66 |
+
scptr.pp.normalize_layers(adata)
|
| 67 |
+
print(" Normalized layers")
|
| 68 |
+
|
| 69 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 70 |
+
print(" Built kNN graph (k=30)")
|
| 71 |
+
|
| 72 |
+
scptr.pp.smooth_layers(adata)
|
| 73 |
+
print(" Smoothed layers (Mu, Ms)")
|
| 74 |
+
|
| 75 |
+
# =========================================================================
|
| 76 |
+
# CORE ANALYSIS
|
| 77 |
+
# =========================================================================
|
| 78 |
+
print("\n" + "=" * 60)
|
| 79 |
+
print("CORE ANALYSIS")
|
| 80 |
+
print("=" * 60)
|
| 81 |
+
|
| 82 |
+
scptr.tl.estimate_beta(adata)
|
| 83 |
+
beta = adata.var['beta'].values
|
| 84 |
+
print(f" Beta: median={np.median(beta):.4f}, max={np.max(beta):.4f}, "
|
| 85 |
+
f"nonzero={np.sum(beta > 0)}/{len(beta)}")
|
| 86 |
+
|
| 87 |
+
scptr.tl.estimate_beta(adata, groupby="clusters")
|
| 88 |
+
print(f" Beta (per-cluster): {adata.varm['beta_groups'].shape}")
|
| 89 |
+
|
| 90 |
+
scptr.tl.estimate_gamma(adata)
|
| 91 |
+
gamma_vals = adata.layers["gamma"]
|
| 92 |
+
gamma_med = np.median(gamma_vals, axis=0)
|
| 93 |
+
print(f" Gamma: shape={gamma_vals.shape}")
|
| 94 |
+
print(f" Median per-gene: median={np.median(gamma_med):.4f}, "
|
| 95 |
+
f"max={np.max(gamma_med):.4f}")
|
| 96 |
+
print(f" Global max={np.max(gamma_vals):.4f}")
|
| 97 |
+
print(f" Genes with >0 median gamma: {np.sum(gamma_med > 0)}/{len(gamma_med)}")
|
| 98 |
+
|
| 99 |
+
scptr.tl.variance_decomposition(adata)
|
| 100 |
+
tf = adata.var['tf_score'].values
|
| 101 |
+
print(f" TF score: median={np.median(tf):.4f}, mean={np.mean(tf):.4f}")
|
| 102 |
+
print(f" Genes with TF > 0.5: {np.sum(tf > 0.5)}/{len(tf)}")
|
| 103 |
+
|
| 104 |
+
scptr.tl.pt_states(adata)
|
| 105 |
+
n_states = adata.obs["pt_state"].nunique()
|
| 106 |
+
print(f" PT states found: {n_states}")
|
| 107 |
+
|
| 108 |
+
scptr.tl.pt_velocity(adata)
|
| 109 |
+
print(" PT velocity computed")
|
| 110 |
+
|
| 111 |
+
# =========================================================================
|
| 112 |
+
# BENCHMARKING
|
| 113 |
+
# =========================================================================
|
| 114 |
+
print("\n" + "=" * 60)
|
| 115 |
+
print("BENCHMARKING")
|
| 116 |
+
print("=" * 60)
|
| 117 |
+
res_dir = OUTPUT_DIR / "results"
|
| 118 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 119 |
+
|
| 120 |
+
# Half-life correlation (mouse reference — dentate gyrus is mouse data)
|
| 121 |
+
print("\n--- Half-life correlation (mouse reference) ---")
|
| 122 |
+
hl_mouse = scptr.datasets.herzog2017_halflives()
|
| 123 |
+
corr = scptr.benchmark.correlate_with_halflives(adata, hl_mouse)
|
| 124 |
+
print(f" n_genes matched: {corr['n_genes']} (unfiltered: {corr['n_genes_unfiltered']})")
|
| 125 |
+
print(f" Spearman r = {corr['spearman_r']:.4f} (p = {corr['spearman_p']:.2e})")
|
| 126 |
+
print(f" Pearson r = {corr['pearson_r']:.4f} (p = {corr['pearson_p']:.2e})")
|
| 127 |
+
|
| 128 |
+
# Also human reference
|
| 129 |
+
print("\n--- Half-life correlation (human reference) ---")
|
| 130 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 131 |
+
corr_human = scptr.benchmark.correlate_with_halflives(adata, hl_human)
|
| 132 |
+
print(f" n_genes matched: {corr_human['n_genes']} (unfiltered: {corr_human['n_genes_unfiltered']})")
|
| 133 |
+
print(f" Spearman r = {corr_human['spearman_r']:.4f} (p = {corr_human['spearman_p']:.2e})")
|
| 134 |
+
|
| 135 |
+
corr_save = {k: v for k, v in corr.items() if k != "matched_genes"}
|
| 136 |
+
corr_human_save = {k: v for k, v in corr_human.items() if k != "matched_genes"}
|
| 137 |
+
with open(res_dir / "halflife_correlation.json", "w") as f:
|
| 138 |
+
json.dump({"mouse_reference": corr_save, "human_reference": corr_human_save}, f, indent=2)
|
| 139 |
+
|
| 140 |
+
# Half-life scatter
|
| 141 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
|
| 142 |
+
gamma_med_s = pd.Series(gamma_med, index=adata.var_names)
|
| 143 |
+
hl_s = hl_mouse.set_index("gene_symbol")["half_life_hours"]
|
| 144 |
+
shared = gamma_med_s.index.intersection(hl_s.index)
|
| 145 |
+
g = gamma_med_s[shared].values
|
| 146 |
+
h = hl_s[shared].values
|
| 147 |
+
|
| 148 |
+
axes[0].scatter(h, g, alpha=0.1, s=5, c="steelblue")
|
| 149 |
+
axes[0].set_xlabel("Published half-life (hours)")
|
| 150 |
+
axes[0].set_ylabel("scPTR median gamma")
|
| 151 |
+
axes[0].set_title(f"All genes (n={len(shared)})")
|
| 152 |
+
|
| 153 |
+
mask = (g > 0) & (h > 0) & np.isfinite(g) & np.isfinite(h)
|
| 154 |
+
axes[1].scatter(h[mask], g[mask], alpha=0.15, s=8, c="steelblue")
|
| 155 |
+
axes[1].set_xscale("log")
|
| 156 |
+
axes[1].set_yscale("log")
|
| 157 |
+
axes[1].set_xlabel("Published half-life (hours)")
|
| 158 |
+
axes[1].set_ylabel("scPTR median gamma")
|
| 159 |
+
axes[1].set_title(
|
| 160 |
+
f"Filtered (Spearman r={corr['spearman_r']:.3f}, "
|
| 161 |
+
f"p={corr['spearman_p']:.1e}, n={corr['n_genes']})"
|
| 162 |
+
)
|
| 163 |
+
fig.suptitle("Dentate Gyrus: Gamma vs Published Half-lives", fontsize=13, y=1.02)
|
| 164 |
+
fig.tight_layout()
|
| 165 |
+
save_fig(fig, "halflife_scatter")
|
| 166 |
+
|
| 167 |
+
# ARE/NMD enrichment
|
| 168 |
+
print("\n--- ARE / NMD enrichment ---")
|
| 169 |
+
are_result = scptr.benchmark.are_enrichment(adata)
|
| 170 |
+
nmd_result = scptr.benchmark.nmd_enrichment(adata)
|
| 171 |
+
print(f" ARE: n_in={are_result['n_genes_in_set']}, p={are_result['p_value']:.4f}")
|
| 172 |
+
print(f" NMD: n_in={nmd_result['n_genes_in_set']}, p={nmd_result['p_value']:.4f}")
|
| 173 |
+
|
| 174 |
+
with open(res_dir / "enrichment_results.json", "w") as f:
|
| 175 |
+
json.dump({"ARE": are_result, "NMD": nmd_result}, f, indent=2)
|
| 176 |
+
|
| 177 |
+
fig = scptr.pl.enrichment_barplot([are_result, nmd_result])
|
| 178 |
+
save_fig(fig, "enrichment_barplot")
|
| 179 |
+
|
| 180 |
+
# Subsampling robustness
|
| 181 |
+
print("\n--- Subsampling robustness ---")
|
| 182 |
+
fractions = [0.2, 0.4, 0.6, 0.8, 0.9]
|
| 183 |
+
robust_df = scptr.benchmark.subsampling_robustness(
|
| 184 |
+
adata, fractions=fractions, n_repeats=5
|
| 185 |
+
)
|
| 186 |
+
robust_df.to_csv(res_dir / "subsampling_robustness.csv", index=False)
|
| 187 |
+
for frac in fractions:
|
| 188 |
+
sub = robust_df[robust_df["fraction"] == frac]
|
| 189 |
+
print(f" fraction={frac:.1f}: mean Spearman r = {sub['spearman_r'].mean():.4f}")
|
| 190 |
+
|
| 191 |
+
# =========================================================================
|
| 192 |
+
# PT STATES
|
| 193 |
+
# =========================================================================
|
| 194 |
+
print("\n" + "=" * 60)
|
| 195 |
+
print("PT STATE DISCOVERY")
|
| 196 |
+
print("=" * 60)
|
| 197 |
+
|
| 198 |
+
state_counts = adata.obs["pt_state"].value_counts()
|
| 199 |
+
state_counts.to_csv(res_dir / "pt_state_counts.csv")
|
| 200 |
+
print(f" PT states: {dict(state_counts)}")
|
| 201 |
+
|
| 202 |
+
fig = scptr.pl.pt_umap(adata, show=False)
|
| 203 |
+
save_fig(fig, "pt_umap")
|
| 204 |
+
|
| 205 |
+
fig = scptr.pl.tf_ptf_scatter(adata, show=False)
|
| 206 |
+
save_fig(fig, "tf_ptf_scatter")
|
| 207 |
+
|
| 208 |
+
ct = pd.crosstab(adata.obs["pt_state"], adata.obs["clusters"])
|
| 209 |
+
ct.to_csv(res_dir / "pt_state_vs_clusters.csv")
|
| 210 |
+
print(f"\n PT state vs expression cluster crosstab:")
|
| 211 |
+
print(ct.to_string())
|
| 212 |
+
|
| 213 |
+
rank_df = scptr.tl.rank_pt_genes(adata, n_genes=50)
|
| 214 |
+
rank_df.to_csv(res_dir / "ranked_pt_genes.csv", index=False)
|
| 215 |
+
print(f"\n Top differentially degraded genes: {len(rank_df)} entries")
|
| 216 |
+
print(f" Top 10: {rank_df.head(10)['names'].tolist()}")
|
| 217 |
+
|
| 218 |
+
fig = scptr.pl.gamma_heatmap(adata, show=False)
|
| 219 |
+
save_fig(fig, "gamma_heatmap")
|
| 220 |
+
|
| 221 |
+
# =========================================================================
|
| 222 |
+
# PT VELOCITY
|
| 223 |
+
# =========================================================================
|
| 224 |
+
print("\n" + "=" * 60)
|
| 225 |
+
print("PT VELOCITY")
|
| 226 |
+
print("=" * 60)
|
| 227 |
+
|
| 228 |
+
fig = scptr.pl.pt_velocity_embedding(adata, density=0.3, arrow_size=1.5, show=False)
|
| 229 |
+
save_fig(fig, "pt_velocity_embedding")
|
| 230 |
+
|
| 231 |
+
# =========================================================================
|
| 232 |
+
# SUMMARY
|
| 233 |
+
# =========================================================================
|
| 234 |
+
print("\n" + "=" * 60)
|
| 235 |
+
print("SUMMARY")
|
| 236 |
+
print("=" * 60)
|
| 237 |
+
print(f" Dataset: dentate_gyrus ({adata.n_obs} cells, {adata.n_vars} genes)")
|
| 238 |
+
print(f" Beta: median={np.median(adata.var['beta']):.4f}, max={np.max(adata.var['beta']):.4f}")
|
| 239 |
+
print(f" Gamma max: {np.max(adata.layers['gamma']):.4f}")
|
| 240 |
+
print(f" PT states discovered: {n_states}")
|
| 241 |
+
print(f" TF score: median={np.median(adata.var['tf_score']):.4f}")
|
| 242 |
+
print(f" Half-life Spearman r (mouse): {corr['spearman_r']:.4f} (n={corr['n_genes']})")
|
| 243 |
+
print(f" Half-life Spearman r (human): {corr_human['spearman_r']:.4f} (n={corr_human['n_genes']})")
|
| 244 |
+
print(f" Robustness (90%): {robust_df[robust_df['fraction']==0.9]['spearman_r'].mean():.4f}")
|
| 245 |
+
print(f"\nAll results saved to: {OUTPUT_DIR.resolve()}")
|
| 246 |
+
|
| 247 |
+
# Return adata for cross-dataset use
|
| 248 |
+
return adata
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
main()
|
analyses/run_final_fixes.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Final publication fixes: address critical reviewer concerns.
|
| 3 |
+
|
| 4 |
+
1. Fix Fisher's exact test bug in hub consistency (contingency table was wrong)
|
| 5 |
+
2. Pathway specificity analysis (scPTR vs unspliced-only: unique vs generic pathways)
|
| 6 |
+
3. Per-cell sci-fate: stratify by expression level to show where scPTR advantage is largest
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import matplotlib
|
| 16 |
+
matplotlib.use("Agg")
|
| 17 |
+
import matplotlib.pyplot as plt
|
| 18 |
+
import numpy as np
|
| 19 |
+
import pandas as pd
|
| 20 |
+
from scipy import stats
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 23 |
+
from _common import set_figure_style
|
| 24 |
+
|
| 25 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "final_fixes"
|
| 26 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def save_fig(fig, name, subdir="figures"):
|
| 30 |
+
if fig is None:
|
| 31 |
+
return
|
| 32 |
+
out_dir = OUTPUT_DIR / subdir
|
| 33 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
path = out_dir / f"{name}.png"
|
| 35 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 36 |
+
plt.close(fig)
|
| 37 |
+
print(f" Saved: {path}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# =========================================================================
|
| 41 |
+
# 1. Fix Fisher's exact test for hub consistency
|
| 42 |
+
# =========================================================================
|
| 43 |
+
def fix_hub_fisher():
|
| 44 |
+
"""Recompute Fisher's exact using only shared RBPs as the universe."""
|
| 45 |
+
print("\n" + "=" * 60)
|
| 46 |
+
print("1. CORRECTED FISHER'S EXACT FOR HUB CONSISTENCY")
|
| 47 |
+
print("=" * 60)
|
| 48 |
+
|
| 49 |
+
res_dir = OUTPUT_DIR / "results"
|
| 50 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
# Load hub counts
|
| 53 |
+
hub_files = {
|
| 54 |
+
"pancreas": PROJECT_ROOT / "output" / "gap_analysis" / "results" / "network" / "pancreas" / "rbp_hub_counts.csv",
|
| 55 |
+
"dentate_gyrus": PROJECT_ROOT / "output" / "gap_analysis" / "results" / "network" / "dentate_gyrus" / "rbp_hub_counts.csv",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
hub_counts = {}
|
| 59 |
+
for name, path in hub_files.items():
|
| 60 |
+
if path.exists():
|
| 61 |
+
df = pd.read_csv(path)
|
| 62 |
+
count_col = [c for c in df.columns if c != "rbp"][0]
|
| 63 |
+
hub_counts[name] = pd.Series(df[count_col].values, index=df["rbp"].values)
|
| 64 |
+
|
| 65 |
+
# NB from corrected network
|
| 66 |
+
nb_net_path = PROJECT_ROOT / "output" / "tier3" / "results" / "neuroblastoma_network_corrected.csv"
|
| 67 |
+
if nb_net_path.exists():
|
| 68 |
+
nb_net = pd.read_csv(nb_net_path)
|
| 69 |
+
hub_counts["neuroblastoma"] = nb_net.groupby("rbp").size().sort_values(ascending=False)
|
| 70 |
+
|
| 71 |
+
# Uppercase
|
| 72 |
+
hub_upper = {}
|
| 73 |
+
for name, series in hub_counts.items():
|
| 74 |
+
hub_upper[name] = pd.Series(series.values, index=[g.upper() for g in series.index])
|
| 75 |
+
|
| 76 |
+
names = sorted(hub_upper.keys())
|
| 77 |
+
results = []
|
| 78 |
+
|
| 79 |
+
print("\n Corrected Fisher's exact (universe = shared RBPs only):")
|
| 80 |
+
for i, name_a in enumerate(names):
|
| 81 |
+
for j in range(len(names)):
|
| 82 |
+
if i == j:
|
| 83 |
+
continue
|
| 84 |
+
name_b = names[j]
|
| 85 |
+
shared = set(hub_upper[name_a].index) & set(hub_upper[name_b].index)
|
| 86 |
+
n_shared = len(shared)
|
| 87 |
+
if n_shared < 5:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
# Rank RBPs WITHIN the shared set only
|
| 91 |
+
shared_a = hub_upper[name_a].reindex(list(shared)).dropna().sort_values(ascending=False)
|
| 92 |
+
shared_b = hub_upper[name_b].reindex(list(shared)).dropna().sort_values(ascending=False)
|
| 93 |
+
|
| 94 |
+
# Top-k from A (within shared), top-k from B (within shared)
|
| 95 |
+
k_a = min(5, n_shared // 3) # top third or 5
|
| 96 |
+
k_b = min(10, n_shared // 2) # top half or 10
|
| 97 |
+
|
| 98 |
+
top_a = set(shared_a.index[:k_a])
|
| 99 |
+
top_b = set(shared_b.index[:k_b])
|
| 100 |
+
|
| 101 |
+
# 2x2 contingency table (universe = shared)
|
| 102 |
+
a_and_b = len(top_a & top_b)
|
| 103 |
+
a_not_b = len(top_a - top_b)
|
| 104 |
+
b_not_a = len(top_b - top_a)
|
| 105 |
+
neither = n_shared - a_and_b - a_not_b - b_not_a
|
| 106 |
+
|
| 107 |
+
table = [[a_and_b, a_not_b], [b_not_a, neither]]
|
| 108 |
+
odds_ratio, fisher_p = stats.fisher_exact(table, alternative="greater")
|
| 109 |
+
print(f" Top-{k_a} {name_a} in top-{k_b} {name_b}: "
|
| 110 |
+
f"{a_and_b}/{k_a} overlap, OR={odds_ratio:.2f}, p={fisher_p:.4f} "
|
| 111 |
+
f"(n_shared={n_shared})")
|
| 112 |
+
|
| 113 |
+
results.append({
|
| 114 |
+
"source": name_a, "target": name_b,
|
| 115 |
+
"k_source": k_a, "k_target": k_b,
|
| 116 |
+
"overlap": a_and_b, "n_shared": n_shared,
|
| 117 |
+
"odds_ratio": float(odds_ratio), "fisher_p": float(fisher_p),
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
with open(res_dir / "corrected_hub_fisher.json", "w") as f:
|
| 121 |
+
json.dump(results, f, indent=2)
|
| 122 |
+
|
| 123 |
+
return results
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# =========================================================================
|
| 127 |
+
# 2. Pathway specificity: unique tissue pathways per method
|
| 128 |
+
# =========================================================================
|
| 129 |
+
def pathway_specificity():
|
| 130 |
+
"""Analyze whether scPTR finds different or more specific pathways
|
| 131 |
+
than unspliced-only, rather than just counting totals."""
|
| 132 |
+
print("\n" + "=" * 60)
|
| 133 |
+
print("2. PATHWAY SPECIFICITY ANALYSIS")
|
| 134 |
+
print("=" * 60)
|
| 135 |
+
|
| 136 |
+
res_dir = OUTPUT_DIR / "results"
|
| 137 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
|
| 139 |
+
# Load full coherence ablation results
|
| 140 |
+
coherence_csv = PROJECT_ROOT / "output" / "comprehensive_fixes" / "results" / "coherence_ablation.csv"
|
| 141 |
+
pathways_csv = PROJECT_ROOT / "output" / "comprehensive_fixes" / "results" / "coherence_ablation_pathways.csv"
|
| 142 |
+
|
| 143 |
+
coherence_df = pd.read_csv(coherence_csv)
|
| 144 |
+
pathways_df = pd.read_csv(pathways_csv)
|
| 145 |
+
|
| 146 |
+
# Generic housekeeping pathways (appear in every tissue, not informative)
|
| 147 |
+
generic_pathways = {
|
| 148 |
+
"ribosome", "oxidative phosphorylation", "thermogenesis",
|
| 149 |
+
"huntington disease", "alzheimer disease", "parkinson disease",
|
| 150 |
+
"non-alcoholic fatty liver disease", "cardiac muscle contraction",
|
| 151 |
+
"diabetic cardiomyopathy", "chemical carcinogenesis",
|
| 152 |
+
"metabolic pathways", "carbon metabolism",
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# Tissue-specific pathways (the ones we care about)
|
| 156 |
+
tissue_specific = {
|
| 157 |
+
"pancreas": {
|
| 158 |
+
"protein processing in endoplasmic reticulum", "autophagy",
|
| 159 |
+
"insulin secretion", "insulin signaling pathway",
|
| 160 |
+
"pancreatic secretion", "maturity onset diabetes",
|
| 161 |
+
"unfolded protein response", "protein folding",
|
| 162 |
+
},
|
| 163 |
+
"dentate_gyrus": {
|
| 164 |
+
"synaptic vesicle cycle", "long-term potentiation",
|
| 165 |
+
"glutamatergic synapse", "gabaergic synapse",
|
| 166 |
+
"axon guidance", "neurotrophin signaling pathway",
|
| 167 |
+
"dopaminergic synapse", "serotonergic synapse",
|
| 168 |
+
},
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
results = {}
|
| 172 |
+
|
| 173 |
+
for dataset in ["pancreas", "dentate_gyrus"]:
|
| 174 |
+
print(f"\n--- {dataset} ---")
|
| 175 |
+
ds_paths = pathways_df[pathways_df["dataset"] == dataset]
|
| 176 |
+
ds_coherence = coherence_df[coherence_df["dataset"] == dataset]
|
| 177 |
+
|
| 178 |
+
expected_set = tissue_specific.get(dataset, set())
|
| 179 |
+
|
| 180 |
+
for method in ["scPTR_gamma", "raw_u_s_ratio", "unspliced_only"]:
|
| 181 |
+
method_paths = ds_paths[ds_paths["method"] == method]
|
| 182 |
+
all_terms = [t.lower() for t in method_paths["pathway"].values]
|
| 183 |
+
|
| 184 |
+
n_total = len(all_terms)
|
| 185 |
+
n_generic = sum(1 for t in all_terms
|
| 186 |
+
if any(g in t for g in generic_pathways))
|
| 187 |
+
n_tissue = sum(1 for t in all_terms
|
| 188 |
+
if any(ts in t for ts in expected_set))
|
| 189 |
+
n_specific = n_total - n_generic
|
| 190 |
+
|
| 191 |
+
# Unique pathways (found by this method but not others)
|
| 192 |
+
other_methods = [m for m in ["scPTR_gamma", "raw_u_s_ratio", "unspliced_only"]
|
| 193 |
+
if m != method]
|
| 194 |
+
other_terms = set()
|
| 195 |
+
for om in other_methods:
|
| 196 |
+
om_paths = ds_paths[ds_paths["method"] == om]
|
| 197 |
+
other_terms |= set(t.lower() for t in om_paths["pathway"].values)
|
| 198 |
+
|
| 199 |
+
unique_terms = [t for t in all_terms if t not in other_terms]
|
| 200 |
+
n_unique = len(unique_terms)
|
| 201 |
+
|
| 202 |
+
# From coherence CSV: mean invisibility, mean diff genes
|
| 203 |
+
mc = ds_coherence[ds_coherence["method"] == method]
|
| 204 |
+
|
| 205 |
+
key = f"{dataset}_{method}"
|
| 206 |
+
results[key] = {
|
| 207 |
+
"dataset": dataset, "method": method,
|
| 208 |
+
"n_total_pathways": n_total,
|
| 209 |
+
"n_generic": n_generic,
|
| 210 |
+
"n_tissue_specific": n_tissue,
|
| 211 |
+
"n_non_generic": n_specific,
|
| 212 |
+
"n_unique_to_method": n_unique,
|
| 213 |
+
"generic_fraction": n_generic / max(n_total, 1),
|
| 214 |
+
"mean_invisibility": float(mc["invisibility"].mean()) if len(mc) > 0 else 0,
|
| 215 |
+
"mean_diff_genes": float(mc["n_diff_genes"].mean()) if len(mc) > 0 else 0,
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
print(f" {method:<20s}: {n_total} total, {n_generic} generic, "
|
| 219 |
+
f"{n_tissue} tissue-specific, {n_unique} unique")
|
| 220 |
+
|
| 221 |
+
# Cross-method comparison: per-cluster agreement
|
| 222 |
+
print("\n Per-cluster: do all methods find the same expected pathways?")
|
| 223 |
+
clusters_tested = coherence_df["cluster"].unique()
|
| 224 |
+
agreement_data = []
|
| 225 |
+
|
| 226 |
+
for cluster in clusters_tested:
|
| 227 |
+
cluster_data = coherence_df[coherence_df["cluster"] == cluster]
|
| 228 |
+
for _, row in cluster_data.iterrows():
|
| 229 |
+
agreement_data.append({
|
| 230 |
+
"cluster": cluster,
|
| 231 |
+
"dataset": row["dataset"],
|
| 232 |
+
"method": row["method"],
|
| 233 |
+
"n_expected": row["n_expected_pathways"],
|
| 234 |
+
"n_sig": row["n_sig_pathways"],
|
| 235 |
+
"n_diff_genes": row["n_diff_genes"],
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
agreement_df = pd.DataFrame(agreement_data)
|
| 239 |
+
|
| 240 |
+
# Key metric: per cluster, which method finds the MOST expected pathways?
|
| 241 |
+
print("\n Per-cluster winner (most expected pathways):")
|
| 242 |
+
winner_counts = {"scPTR_gamma": 0, "raw_u_s_ratio": 0, "unspliced_only": 0, "tie": 0}
|
| 243 |
+
|
| 244 |
+
for cluster in clusters_tested:
|
| 245 |
+
cl = agreement_df[agreement_df["cluster"] == cluster]
|
| 246 |
+
if len(cl) == 0:
|
| 247 |
+
continue
|
| 248 |
+
max_expected = cl["n_expected"].max()
|
| 249 |
+
winners = cl[cl["n_expected"] == max_expected]["method"].tolist()
|
| 250 |
+
if len(winners) == 1:
|
| 251 |
+
winner_counts[winners[0]] += 1
|
| 252 |
+
else:
|
| 253 |
+
winner_counts["tie"] += 1
|
| 254 |
+
|
| 255 |
+
for method, count in winner_counts.items():
|
| 256 |
+
print(f" {method}: wins {count}/{len(clusters_tested)} clusters")
|
| 257 |
+
|
| 258 |
+
results["winner_counts"] = winner_counts
|
| 259 |
+
|
| 260 |
+
with open(res_dir / "pathway_specificity.json", "w") as f:
|
| 261 |
+
json.dump(results, f, indent=2, default=str)
|
| 262 |
+
|
| 263 |
+
# Figure
|
| 264 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 265 |
+
|
| 266 |
+
# Panel 1: stacked bar of generic vs tissue-specific vs other
|
| 267 |
+
methods = ["scPTR_gamma", "raw_u_s_ratio", "unspliced_only"]
|
| 268 |
+
method_labels = ["scPTR\ngamma", "Raw u/s\nratio", "Unspliced\nonly"]
|
| 269 |
+
x = np.arange(len(methods))
|
| 270 |
+
|
| 271 |
+
for di, dataset in enumerate(["pancreas", "dentate_gyrus"]):
|
| 272 |
+
offset = di * 0.35 - 0.175
|
| 273 |
+
generics = []
|
| 274 |
+
tissues = []
|
| 275 |
+
others = []
|
| 276 |
+
for m in methods:
|
| 277 |
+
key = f"{dataset}_{m}"
|
| 278 |
+
if key in results:
|
| 279 |
+
r = results[key]
|
| 280 |
+
generics.append(r["n_generic"])
|
| 281 |
+
tissues.append(r["n_tissue_specific"])
|
| 282 |
+
others.append(r["n_non_generic"] - r["n_tissue_specific"])
|
| 283 |
+
else:
|
| 284 |
+
generics.append(0)
|
| 285 |
+
tissues.append(0)
|
| 286 |
+
others.append(0)
|
| 287 |
+
|
| 288 |
+
color_generic = "lightgray" if di == 0 else "silver"
|
| 289 |
+
color_tissue = "steelblue" if di == 0 else "darkorange"
|
| 290 |
+
color_other = "lightblue" if di == 0 else "moccasin"
|
| 291 |
+
|
| 292 |
+
axes[0].bar(x + offset, tissues, 0.3, label=f"{dataset} tissue-specific",
|
| 293 |
+
color=color_tissue, edgecolor="black", linewidth=0.3)
|
| 294 |
+
axes[0].bar(x + offset, others, 0.3, bottom=tissues,
|
| 295 |
+
label=f"{dataset} other", color=color_other,
|
| 296 |
+
edgecolor="black", linewidth=0.3)
|
| 297 |
+
axes[0].bar(x + offset, generics, 0.3,
|
| 298 |
+
bottom=[t + o for t, o in zip(tissues, others)],
|
| 299 |
+
label=f"{dataset} generic", color=color_generic,
|
| 300 |
+
edgecolor="black", linewidth=0.3)
|
| 301 |
+
|
| 302 |
+
axes[0].set_xticks(x)
|
| 303 |
+
axes[0].set_xticklabels(method_labels)
|
| 304 |
+
axes[0].set_ylabel("Number of top pathways")
|
| 305 |
+
axes[0].set_title("Pathway Composition by Method")
|
| 306 |
+
axes[0].legend(fontsize=6, ncol=2)
|
| 307 |
+
|
| 308 |
+
# Panel 2: per-cluster winner counts
|
| 309 |
+
cats = list(winner_counts.keys())
|
| 310 |
+
vals = [winner_counts[c] for c in cats]
|
| 311 |
+
colors_bar = ["steelblue", "orange", "lightblue", "gray"]
|
| 312 |
+
axes[1].bar(cats, vals, color=colors_bar, edgecolor="black", linewidth=0.5)
|
| 313 |
+
axes[1].set_ylabel("Number of clusters won")
|
| 314 |
+
axes[1].set_title("Per-Cluster: Most Expected Pathways")
|
| 315 |
+
for i, v in enumerate(vals):
|
| 316 |
+
axes[1].text(i, v + 0.2, str(v), ha="center", fontsize=10, fontweight="bold")
|
| 317 |
+
|
| 318 |
+
fig.suptitle("Pathway Specificity Analysis", fontsize=13, y=1.02)
|
| 319 |
+
fig.tight_layout()
|
| 320 |
+
save_fig(fig, "pathway_specificity")
|
| 321 |
+
|
| 322 |
+
return results
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# =========================================================================
|
| 326 |
+
# 3. Per-cell sci-fate stratified by expression level
|
| 327 |
+
# =========================================================================
|
| 328 |
+
def percell_stratified():
|
| 329 |
+
"""Show that scPTR's advantage over raw u/s increases for
|
| 330 |
+
low-expression genes, where smoothing matters most."""
|
| 331 |
+
print("\n" + "=" * 60)
|
| 332 |
+
print("3. PER-CELL SCI-FATE STRATIFIED BY EXPRESSION LEVEL")
|
| 333 |
+
print("=" * 60)
|
| 334 |
+
|
| 335 |
+
res_dir = OUTPUT_DIR / "results"
|
| 336 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 337 |
+
|
| 338 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 339 |
+
import scptr
|
| 340 |
+
|
| 341 |
+
adata_raw = load_scifate_data()
|
| 342 |
+
adata = prepare_for_scptr(adata_raw)
|
| 343 |
+
|
| 344 |
+
scptr.pp.filter_genes(adata)
|
| 345 |
+
scptr.pp.normalize_layers(adata)
|
| 346 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 347 |
+
scptr.pp.smooth_layers(adata)
|
| 348 |
+
scptr.tl.estimate_beta(adata)
|
| 349 |
+
scptr.tl.estimate_gamma(adata)
|
| 350 |
+
print(f" Pipeline complete: {adata.shape}")
|
| 351 |
+
|
| 352 |
+
gamma = adata.layers["gamma"]
|
| 353 |
+
u_layer = adata.layers.get("Mu", adata.layers.get("unspliced"))
|
| 354 |
+
s_layer = adata.layers.get("Ms", adata.layers.get("spliced"))
|
| 355 |
+
u = u_layer.toarray() if hasattr(u_layer, 'toarray') else np.asarray(u_layer)
|
| 356 |
+
s = s_layer.toarray() if hasattr(s_layer, 'toarray') else np.asarray(s_layer)
|
| 357 |
+
|
| 358 |
+
raw_ratio = np.zeros_like(gamma)
|
| 359 |
+
s_safe = np.where(s > 0.01, s, 1.0)
|
| 360 |
+
raw_ratio = u / s_safe
|
| 361 |
+
raw_ratio[s < 0.01] = 0
|
| 362 |
+
|
| 363 |
+
# Ground truth per cell
|
| 364 |
+
total_raw = np.asarray(adata_raw.X.toarray() if hasattr(adata_raw.X, 'toarray') else adata_raw.X)
|
| 365 |
+
new_raw = np.asarray(adata_raw.layers["new"].toarray() if hasattr(adata_raw.layers["new"], 'toarray') else adata_raw.layers["new"])
|
| 366 |
+
old_raw = total_raw - new_raw
|
| 367 |
+
|
| 368 |
+
raw_gene_map = {g: i for i, g in enumerate(adata_raw.var_names)}
|
| 369 |
+
filtered_in_raw = [raw_gene_map[g] for g in adata.var_names if g in raw_gene_map]
|
| 370 |
+
genes_in_both = [g for g in adata.var_names if g in raw_gene_map]
|
| 371 |
+
gene_idx_in_filtered = [list(adata.var_names).index(g) for g in genes_in_both]
|
| 372 |
+
|
| 373 |
+
gt_new = new_raw[:, filtered_in_raw]
|
| 374 |
+
gt_old = old_raw[:, filtered_in_raw]
|
| 375 |
+
gt_total = total_raw[:, filtered_in_raw]
|
| 376 |
+
gt_ratio = np.zeros_like(gt_new, dtype=float)
|
| 377 |
+
valid_gt = gt_old > 0.1
|
| 378 |
+
gt_ratio[valid_gt] = gt_new[valid_gt] / gt_old[valid_gt]
|
| 379 |
+
gt_ratio[~valid_gt] = np.nan
|
| 380 |
+
|
| 381 |
+
gamma_matched = gamma[:, gene_idx_in_filtered]
|
| 382 |
+
raw_matched = raw_ratio[:, gene_idx_in_filtered]
|
| 383 |
+
|
| 384 |
+
# Stratify genes by expression level (mean total counts)
|
| 385 |
+
gene_mean_expr = gt_total.mean(axis=0)
|
| 386 |
+
terciles = np.percentile(gene_mean_expr[gene_mean_expr > 0], [33, 67])
|
| 387 |
+
|
| 388 |
+
strata = {
|
| 389 |
+
"low": gene_mean_expr <= terciles[0],
|
| 390 |
+
"medium": (gene_mean_expr > terciles[0]) & (gene_mean_expr <= terciles[1]),
|
| 391 |
+
"high": gene_mean_expr > terciles[1],
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
n_cells = adata.n_obs
|
| 395 |
+
results = {}
|
| 396 |
+
|
| 397 |
+
for stratum_name, gene_mask in strata.items():
|
| 398 |
+
n_genes_stratum = gene_mask.sum()
|
| 399 |
+
print(f"\n --- {stratum_name} expression ({n_genes_stratum} genes) ---")
|
| 400 |
+
|
| 401 |
+
gamma_corrs = []
|
| 402 |
+
raw_corrs = []
|
| 403 |
+
|
| 404 |
+
for i in range(n_cells):
|
| 405 |
+
gt_i = gt_ratio[i, gene_mask]
|
| 406 |
+
gamma_i = gamma_matched[i, gene_mask]
|
| 407 |
+
raw_i = raw_matched[i, gene_mask]
|
| 408 |
+
|
| 409 |
+
valid = np.isfinite(gt_i) & (gt_i > 0) & (gamma_i > 0) & (raw_i > 0)
|
| 410 |
+
if valid.sum() >= 10:
|
| 411 |
+
r_g, _ = stats.spearmanr(gamma_i[valid], gt_i[valid])
|
| 412 |
+
r_r, _ = stats.spearmanr(raw_i[valid], gt_i[valid])
|
| 413 |
+
gamma_corrs.append(r_g)
|
| 414 |
+
raw_corrs.append(r_r)
|
| 415 |
+
|
| 416 |
+
gamma_corrs = np.array(gamma_corrs)
|
| 417 |
+
raw_corrs = np.array(raw_corrs)
|
| 418 |
+
|
| 419 |
+
# Filter out NaN correlations (from constant inputs)
|
| 420 |
+
finite_mask = np.isfinite(gamma_corrs) & np.isfinite(raw_corrs)
|
| 421 |
+
gamma_corrs = gamma_corrs[finite_mask]
|
| 422 |
+
raw_corrs = raw_corrs[finite_mask]
|
| 423 |
+
|
| 424 |
+
if len(gamma_corrs) < 10:
|
| 425 |
+
print(f" Skipped: only {len(gamma_corrs)} valid cells after NaN filtering")
|
| 426 |
+
continue
|
| 427 |
+
|
| 428 |
+
mean_g = np.mean(gamma_corrs)
|
| 429 |
+
mean_r = np.mean(raw_corrs)
|
| 430 |
+
gamma_wins = (gamma_corrs > raw_corrs).sum()
|
| 431 |
+
n_valid = len(gamma_corrs)
|
| 432 |
+
w_stat, w_p = stats.wilcoxon(gamma_corrs, raw_corrs, alternative="greater")
|
| 433 |
+
|
| 434 |
+
print(f" scPTR gamma: mean r = {mean_g:.4f}")
|
| 435 |
+
print(f" Raw u/s: mean r = {mean_r:.4f}")
|
| 436 |
+
print(f" Advantage: {mean_g - mean_r:.4f}")
|
| 437 |
+
print(f" gamma wins: {gamma_wins}/{n_valid} ({100*gamma_wins/n_valid:.1f}%)")
|
| 438 |
+
print(f" Wilcoxon p: {w_p:.2e}")
|
| 439 |
+
|
| 440 |
+
results[stratum_name] = {
|
| 441 |
+
"n_genes": int(n_genes_stratum),
|
| 442 |
+
"n_valid_cells": int(n_valid),
|
| 443 |
+
"mean_gamma_corr": float(mean_g),
|
| 444 |
+
"mean_raw_corr": float(mean_r),
|
| 445 |
+
"advantage": float(mean_g - mean_r),
|
| 446 |
+
"gamma_wins_frac": float(gamma_wins / n_valid),
|
| 447 |
+
"wilcoxon_p": float(w_p),
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
with open(res_dir / "percell_stratified.json", "w") as f:
|
| 451 |
+
json.dump(results, f, indent=2)
|
| 452 |
+
|
| 453 |
+
# Figure: advantage by expression stratum
|
| 454 |
+
fig, axes = plt.subplots(1, 2, figsize=(11, 5))
|
| 455 |
+
|
| 456 |
+
strata_order = ["low", "medium", "high"]
|
| 457 |
+
strata_labels = ["Low\nexpr", "Medium\nexpr", "High\nexpr"]
|
| 458 |
+
|
| 459 |
+
# Panel 1: mean correlation per stratum
|
| 460 |
+
gamma_means = [results.get(s, {}).get("mean_gamma_corr", 0) for s in strata_order]
|
| 461 |
+
raw_means = [results.get(s, {}).get("mean_raw_corr", 0) for s in strata_order]
|
| 462 |
+
x = np.arange(len(strata_order))
|
| 463 |
+
axes[0].bar(x - 0.15, gamma_means, 0.3, label="scPTR gamma",
|
| 464 |
+
color="steelblue", edgecolor="black", linewidth=0.5)
|
| 465 |
+
axes[0].bar(x + 0.15, raw_means, 0.3, label="Raw u/s",
|
| 466 |
+
color="salmon", edgecolor="black", linewidth=0.5)
|
| 467 |
+
axes[0].set_xticks(x)
|
| 468 |
+
axes[0].set_xticklabels(strata_labels)
|
| 469 |
+
axes[0].set_ylabel("Mean per-cell Spearman r")
|
| 470 |
+
axes[0].set_title("Per-Cell Correlation by Expression Level")
|
| 471 |
+
axes[0].legend()
|
| 472 |
+
|
| 473 |
+
# Panel 2: advantage (gamma - raw) by stratum
|
| 474 |
+
advantages = [results.get(s, {}).get("advantage", 0) for s in strata_order]
|
| 475 |
+
p_values = [results.get(s, {}).get("wilcoxon_p", 1) for s in strata_order]
|
| 476 |
+
colors = ["steelblue" if a > 0 else "salmon" for a in advantages]
|
| 477 |
+
axes[1].bar(strata_labels, advantages, color=colors, edgecolor="black", linewidth=0.5)
|
| 478 |
+
axes[1].set_ylabel("scPTR advantage (gamma r - raw r)")
|
| 479 |
+
axes[1].set_title("scPTR Advantage by Expression Level")
|
| 480 |
+
axes[1].axhline(0, color="gray", linestyle="--", alpha=0.3)
|
| 481 |
+
for i, (a, p) in enumerate(zip(advantages, p_values)):
|
| 482 |
+
sig = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else "ns"
|
| 483 |
+
axes[1].text(i, a + 0.001 if a > 0 else a - 0.002,
|
| 484 |
+
f"{a:.4f}\n({sig})", ha="center", fontsize=9)
|
| 485 |
+
|
| 486 |
+
fig.suptitle("scPTR Advantage Stratified by Gene Expression Level",
|
| 487 |
+
fontsize=13, y=1.02)
|
| 488 |
+
fig.tight_layout()
|
| 489 |
+
save_fig(fig, "percell_stratified")
|
| 490 |
+
|
| 491 |
+
return results
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# =========================================================================
|
| 495 |
+
# MAIN
|
| 496 |
+
# =========================================================================
|
| 497 |
+
def main():
|
| 498 |
+
set_figure_style()
|
| 499 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 500 |
+
(OUTPUT_DIR / "results").mkdir(parents=True, exist_ok=True)
|
| 501 |
+
(OUTPUT_DIR / "figures").mkdir(parents=True, exist_ok=True)
|
| 502 |
+
|
| 503 |
+
all_results = {}
|
| 504 |
+
|
| 505 |
+
# 1. Fix Fisher's exact
|
| 506 |
+
all_results["hub_fisher"] = fix_hub_fisher()
|
| 507 |
+
|
| 508 |
+
# 2. Pathway specificity
|
| 509 |
+
all_results["pathway_specificity"] = pathway_specificity()
|
| 510 |
+
|
| 511 |
+
# 3. Per-cell stratified
|
| 512 |
+
all_results["percell_stratified"] = percell_stratified()
|
| 513 |
+
|
| 514 |
+
with open(OUTPUT_DIR / "results" / "all_final_fixes.json", "w") as f:
|
| 515 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 516 |
+
|
| 517 |
+
print("\n" + "=" * 60)
|
| 518 |
+
print("ALL FINAL FIXES COMPLETE")
|
| 519 |
+
print("=" * 60)
|
| 520 |
+
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
if __name__ == "__main__":
|
| 524 |
+
main()
|
analyses/run_gaps.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Fill research plan gaps: expression-invisible states, RNA velocity comparison,
|
| 3 |
+
and network inference on real data.
|
| 4 |
+
|
| 5 |
+
Gap 1 (Aim 2): Formally demonstrate expression-invisible PT states
|
| 6 |
+
Gap 2 (Aim 3): Compare PT velocity with scvelo RNA velocity
|
| 7 |
+
Gap 3 (Aim 4): Run RBP network inference on real data
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
import scanpy as sc
|
| 22 |
+
from scipy import stats
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 25 |
+
from _common import set_figure_style
|
| 26 |
+
|
| 27 |
+
import scptr
|
| 28 |
+
|
| 29 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "gap_analysis"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def save_fig(fig, name, subdir="figures"):
|
| 33 |
+
if fig is None:
|
| 34 |
+
print(f" [WARNING] {name}: None, skipping")
|
| 35 |
+
return
|
| 36 |
+
out_dir = OUTPUT_DIR / subdir
|
| 37 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
path = out_dir / f"{name}.png"
|
| 39 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 40 |
+
plt.close(fig)
|
| 41 |
+
print(f" Saved: {path}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def process_dataset(name):
|
| 45 |
+
"""Load and run full preprocessing + core analysis on a dataset."""
|
| 46 |
+
print(f"\nLoading {name}...")
|
| 47 |
+
if name == "pancreas":
|
| 48 |
+
adata = scptr.datasets.pancreas()
|
| 49 |
+
else:
|
| 50 |
+
adata = scptr.datasets.dentate_gyrus()
|
| 51 |
+
|
| 52 |
+
scptr.pp.filter_genes(adata)
|
| 53 |
+
scptr.pp.normalize_layers(adata)
|
| 54 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 55 |
+
scptr.pp.smooth_layers(adata)
|
| 56 |
+
scptr.tl.estimate_beta(adata)
|
| 57 |
+
scptr.tl.estimate_gamma(adata)
|
| 58 |
+
scptr.tl.variance_decomposition(adata)
|
| 59 |
+
scptr.tl.pt_states(adata)
|
| 60 |
+
scptr.tl.pt_velocity(adata)
|
| 61 |
+
print(f" {name}: {adata.n_obs} cells, {adata.n_vars} genes, "
|
| 62 |
+
f"{adata.obs['pt_state'].nunique()} PT states")
|
| 63 |
+
return adata
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# =========================================================================
|
| 67 |
+
# GAP 1: Expression-invisible PT states (Aim 2 central claim)
|
| 68 |
+
# =========================================================================
|
| 69 |
+
def run_invisible_states(adata, dataset_name):
|
| 70 |
+
"""Formally demonstrate that gamma clustering reveals sub-populations
|
| 71 |
+
invisible to expression-based clustering.
|
| 72 |
+
|
| 73 |
+
Method:
|
| 74 |
+
1. For each expression cluster, extract cells
|
| 75 |
+
2. Re-cluster using gamma profiles (sub-clustering)
|
| 76 |
+
3. Test significance via silhouette score and ANOVA on gamma PCs
|
| 77 |
+
4. Characterize differentially stabilized genes in sub-clusters
|
| 78 |
+
"""
|
| 79 |
+
print("\n" + "=" * 60)
|
| 80 |
+
print(f"GAP 1: EXPRESSION-INVISIBLE STATES ({dataset_name})")
|
| 81 |
+
print("=" * 60)
|
| 82 |
+
|
| 83 |
+
res_dir = OUTPUT_DIR / "results" / "invisible_states" / dataset_name
|
| 84 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
fig_prefix = f"invisible_states/{dataset_name}"
|
| 86 |
+
|
| 87 |
+
gamma = scptr.tools._gamma # just for access to layer
|
| 88 |
+
gamma_mat = adata.layers["gamma"]
|
| 89 |
+
clusters = adata.obs["clusters"].astype(str)
|
| 90 |
+
|
| 91 |
+
results = []
|
| 92 |
+
|
| 93 |
+
for cluster_name in sorted(clusters.unique()):
|
| 94 |
+
mask = (clusters == cluster_name).values
|
| 95 |
+
n_cells = mask.sum()
|
| 96 |
+
|
| 97 |
+
if n_cells < 50: # need enough cells for sub-clustering
|
| 98 |
+
print(f" {cluster_name}: {n_cells} cells (too few, skipping)")
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
# Extract gamma for this cluster
|
| 102 |
+
gamma_sub = gamma_mat[mask]
|
| 103 |
+
|
| 104 |
+
# PCA on gamma within this cluster
|
| 105 |
+
from sklearn.decomposition import PCA
|
| 106 |
+
from sklearn.cluster import KMeans
|
| 107 |
+
from sklearn.metrics import silhouette_score
|
| 108 |
+
|
| 109 |
+
n_pcs = min(15, n_cells - 1, gamma_sub.shape[1] - 1)
|
| 110 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 111 |
+
gamma_pcs = pca.fit_transform(gamma_sub)
|
| 112 |
+
|
| 113 |
+
# Try 2-4 sub-clusters, pick best silhouette
|
| 114 |
+
best_k = 1
|
| 115 |
+
best_sil = -1
|
| 116 |
+
best_labels = np.zeros(n_cells, dtype=int)
|
| 117 |
+
|
| 118 |
+
for k in [2, 3]:
|
| 119 |
+
if n_cells < k * 10:
|
| 120 |
+
continue
|
| 121 |
+
km = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 122 |
+
labels = km.fit_predict(gamma_pcs)
|
| 123 |
+
# Only evaluate if all clusters have >= 10 cells
|
| 124 |
+
min_size = min(np.bincount(labels))
|
| 125 |
+
if min_size < 10:
|
| 126 |
+
continue
|
| 127 |
+
sil = silhouette_score(gamma_pcs, labels)
|
| 128 |
+
if sil > best_sil:
|
| 129 |
+
best_sil = sil
|
| 130 |
+
best_k = k
|
| 131 |
+
best_labels = labels
|
| 132 |
+
|
| 133 |
+
# Statistical test: MANOVA-like test using gamma PCs
|
| 134 |
+
# Use ANOVA on first few PCs as a proxy
|
| 135 |
+
if best_k > 1:
|
| 136 |
+
p_values_pcs = []
|
| 137 |
+
for pc in range(min(5, n_pcs)):
|
| 138 |
+
groups = [gamma_pcs[best_labels == j, pc] for j in range(best_k)]
|
| 139 |
+
if all(len(g) >= 2 for g in groups):
|
| 140 |
+
f_stat, p_val = stats.f_oneway(*groups)
|
| 141 |
+
p_values_pcs.append(p_val)
|
| 142 |
+
# Combine p-values (Fisher's method)
|
| 143 |
+
if p_values_pcs:
|
| 144 |
+
# Clamp p-values to avoid log(0)
|
| 145 |
+
p_clamped = [max(p, 1e-300) for p in p_values_pcs]
|
| 146 |
+
combined_stat = -2 * sum(np.log(p) for p in p_clamped)
|
| 147 |
+
from scipy.stats import chi2
|
| 148 |
+
combined_p = 1 - chi2.cdf(combined_stat, 2 * len(p_clamped))
|
| 149 |
+
else:
|
| 150 |
+
combined_p = 1.0
|
| 151 |
+
else:
|
| 152 |
+
combined_p = 1.0
|
| 153 |
+
|
| 154 |
+
# Now test if these sub-clusters are visible in expression space
|
| 155 |
+
# Use expression PCA and compute silhouette for the SAME labels
|
| 156 |
+
expr_sub = adata.X[mask] if not hasattr(adata.X, 'toarray') else adata.X[mask].toarray()
|
| 157 |
+
n_expr_pcs = min(15, n_cells - 1, expr_sub.shape[1] - 1)
|
| 158 |
+
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
|
| 159 |
+
expr_pcs = pca_expr.fit_transform(expr_sub)
|
| 160 |
+
|
| 161 |
+
if best_k > 1:
|
| 162 |
+
sil_gamma = best_sil
|
| 163 |
+
sil_expr = silhouette_score(expr_pcs, best_labels)
|
| 164 |
+
else:
|
| 165 |
+
sil_gamma = 0
|
| 166 |
+
sil_expr = 0
|
| 167 |
+
|
| 168 |
+
# Find differentially degraded genes between sub-clusters
|
| 169 |
+
top_genes = []
|
| 170 |
+
if best_k > 1:
|
| 171 |
+
median_gamma_by_sub = np.zeros((best_k, gamma_sub.shape[1]))
|
| 172 |
+
for j in range(best_k):
|
| 173 |
+
median_gamma_by_sub[j] = np.median(gamma_sub[best_labels == j], axis=0)
|
| 174 |
+
# Max fold change across sub-clusters
|
| 175 |
+
max_gamma = np.max(median_gamma_by_sub, axis=0)
|
| 176 |
+
min_gamma = np.minimum(np.min(median_gamma_by_sub, axis=0), 1e-6)
|
| 177 |
+
fold_change = max_gamma / np.clip(min_gamma, 1e-6, None)
|
| 178 |
+
# Filter to genes with nonzero gamma
|
| 179 |
+
nonzero_mask = max_gamma > 0.01
|
| 180 |
+
if nonzero_mask.sum() > 0:
|
| 181 |
+
fc_masked = fold_change.copy()
|
| 182 |
+
fc_masked[~nonzero_mask] = 0
|
| 183 |
+
top_idx = np.argsort(fc_masked)[::-1][:20]
|
| 184 |
+
top_genes = [adata.var_names[i] for i in top_idx if fc_masked[i] > 1.5]
|
| 185 |
+
|
| 186 |
+
result = {
|
| 187 |
+
"cluster": cluster_name,
|
| 188 |
+
"n_cells": int(n_cells),
|
| 189 |
+
"n_subclusters": int(best_k),
|
| 190 |
+
"silhouette_gamma": float(sil_gamma),
|
| 191 |
+
"silhouette_expr": float(sil_expr),
|
| 192 |
+
"invisibility_score": float(sil_gamma - sil_expr),
|
| 193 |
+
"combined_p": float(combined_p),
|
| 194 |
+
"top_diff_genes": top_genes[:10],
|
| 195 |
+
}
|
| 196 |
+
results.append(result)
|
| 197 |
+
|
| 198 |
+
status = "INVISIBLE" if sil_gamma > 0.1 and sil_expr < 0.1 else \
|
| 199 |
+
"PARTIALLY" if sil_gamma > sil_expr + 0.05 else "VISIBLE"
|
| 200 |
+
print(f" {cluster_name}: {n_cells} cells, k={best_k}, "
|
| 201 |
+
f"sil_gamma={sil_gamma:.3f}, sil_expr={sil_expr:.3f}, "
|
| 202 |
+
f"p={combined_p:.2e} [{status}]")
|
| 203 |
+
|
| 204 |
+
# Save results
|
| 205 |
+
results_df = pd.DataFrame(results)
|
| 206 |
+
results_df.to_csv(res_dir / "invisible_states.csv", index=False)
|
| 207 |
+
|
| 208 |
+
# Summary figure: silhouette in gamma vs expression space
|
| 209 |
+
if len(results_df) > 0:
|
| 210 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
|
| 211 |
+
|
| 212 |
+
# Left: paired bar chart
|
| 213 |
+
x = np.arange(len(results_df))
|
| 214 |
+
width = 0.35
|
| 215 |
+
axes[0].bar(x - width/2, results_df["silhouette_gamma"], width,
|
| 216 |
+
label="Gamma space", color="steelblue")
|
| 217 |
+
axes[0].bar(x + width/2, results_df["silhouette_expr"], width,
|
| 218 |
+
label="Expression space", color="salmon")
|
| 219 |
+
axes[0].set_xticks(x)
|
| 220 |
+
axes[0].set_xticklabels(results_df["cluster"], rotation=45, ha="right")
|
| 221 |
+
axes[0].set_ylabel("Silhouette score")
|
| 222 |
+
axes[0].set_title("Sub-cluster separation: Gamma vs Expression")
|
| 223 |
+
axes[0].legend()
|
| 224 |
+
axes[0].axhline(0, color="gray", linestyle="--", alpha=0.3)
|
| 225 |
+
|
| 226 |
+
# Right: invisibility score
|
| 227 |
+
colors = ["steelblue" if v > 0.05 else "gray"
|
| 228 |
+
for v in results_df["invisibility_score"]]
|
| 229 |
+
axes[1].barh(results_df["cluster"], results_df["invisibility_score"],
|
| 230 |
+
color=colors)
|
| 231 |
+
axes[1].set_xlabel("Invisibility score (sil_gamma - sil_expr)")
|
| 232 |
+
axes[1].set_title("Expression-invisible PT sub-states")
|
| 233 |
+
axes[1].axvline(0, color="gray", linestyle="--", alpha=0.3)
|
| 234 |
+
|
| 235 |
+
fig.suptitle(f"Expression-Invisible States: {dataset_name}", fontsize=13, y=1.02)
|
| 236 |
+
fig.tight_layout()
|
| 237 |
+
save_fig(fig, f"invisible_states_{dataset_name}", f"figures/invisible_states")
|
| 238 |
+
|
| 239 |
+
return results_df
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# =========================================================================
|
| 243 |
+
# GAP 2: RNA velocity comparison (Aim 3)
|
| 244 |
+
# =========================================================================
|
| 245 |
+
def run_velocity_comparison(adata, dataset_name):
|
| 246 |
+
"""Compare PT velocity with scvelo RNA velocity on the same dataset.
|
| 247 |
+
|
| 248 |
+
Shows:
|
| 249 |
+
1. Side-by-side velocity embeddings
|
| 250 |
+
2. Correlation of velocity magnitudes
|
| 251 |
+
3. Angular agreement between velocity fields
|
| 252 |
+
"""
|
| 253 |
+
print("\n" + "=" * 60)
|
| 254 |
+
print(f"GAP 2: RNA VELOCITY COMPARISON ({dataset_name})")
|
| 255 |
+
print("=" * 60)
|
| 256 |
+
|
| 257 |
+
res_dir = OUTPUT_DIR / "results" / "velocity_comparison" / dataset_name
|
| 258 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 259 |
+
|
| 260 |
+
import scvelo as scv
|
| 261 |
+
|
| 262 |
+
# Run scvelo RNA velocity
|
| 263 |
+
print(" Running scvelo RNA velocity...")
|
| 264 |
+
# scvelo needs its own preprocessing
|
| 265 |
+
adata_scv = adata.copy()
|
| 266 |
+
|
| 267 |
+
# scvelo pipeline
|
| 268 |
+
scv.pp.filter_and_normalize(adata_scv, min_shared_counts=20, n_top_genes=2000)
|
| 269 |
+
scv.pp.moments(adata_scv, n_pcs=30, n_neighbors=30)
|
| 270 |
+
scv.tl.velocity(adata_scv)
|
| 271 |
+
|
| 272 |
+
# Project scvelo velocity onto the gamma UMAP for fair comparison
|
| 273 |
+
# Use the gamma UMAP coordinates from scPTR
|
| 274 |
+
if "X_gamma_umap" in adata.obsm:
|
| 275 |
+
adata_scv.obsm["X_gamma_umap"] = adata.obsm["X_gamma_umap"]
|
| 276 |
+
|
| 277 |
+
# Compute UMAP for scvelo data
|
| 278 |
+
sc.tl.umap(adata_scv)
|
| 279 |
+
|
| 280 |
+
# Get velocity vectors
|
| 281 |
+
scv_velocity = adata_scv.layers.get("velocity")
|
| 282 |
+
pt_velocity = adata.layers.get("pt_velocity")
|
| 283 |
+
|
| 284 |
+
if scv_velocity is None:
|
| 285 |
+
print(" [WARNING] scvelo velocity not computed, skipping comparison")
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
print(f" scvelo velocity shape: {scv_velocity.shape}")
|
| 289 |
+
print(f" PT velocity shape: {pt_velocity.shape}")
|
| 290 |
+
|
| 291 |
+
# Find shared genes
|
| 292 |
+
shared_genes = adata.var_names.intersection(adata_scv.var_names)
|
| 293 |
+
print(f" Shared genes: {len(shared_genes)}")
|
| 294 |
+
|
| 295 |
+
# Compare velocity magnitudes per cell
|
| 296 |
+
# Use scvelo's gene set for fair comparison
|
| 297 |
+
scv_genes = adata_scv.var_names
|
| 298 |
+
scv_gene_idx_in_adata = [list(adata.var_names).index(g)
|
| 299 |
+
for g in scv_genes if g in adata.var_names]
|
| 300 |
+
pt_vel_shared = pt_velocity[:, scv_gene_idx_in_adata]
|
| 301 |
+
scv_vel_shared_genes = [g for g in scv_genes if g in adata.var_names]
|
| 302 |
+
scv_vel_idx = [list(adata_scv.var_names).index(g) for g in scv_vel_shared_genes]
|
| 303 |
+
scv_vel_shared = scv_velocity[:, scv_vel_idx]
|
| 304 |
+
|
| 305 |
+
# Handle NaN in scvelo
|
| 306 |
+
scv_vel_shared = np.nan_to_num(scv_vel_shared, 0)
|
| 307 |
+
|
| 308 |
+
# Per-cell velocity magnitude
|
| 309 |
+
pt_mag = np.linalg.norm(pt_vel_shared, axis=1)
|
| 310 |
+
scv_mag = np.linalg.norm(scv_vel_shared, axis=1)
|
| 311 |
+
|
| 312 |
+
# Cosine similarity per cell
|
| 313 |
+
dot_product = np.sum(pt_vel_shared * scv_vel_shared, axis=1)
|
| 314 |
+
norms = pt_mag * scv_mag
|
| 315 |
+
norms = np.clip(norms, 1e-10, None)
|
| 316 |
+
cosine_sim = dot_product / norms
|
| 317 |
+
|
| 318 |
+
# Filter to cells with nonzero velocity in both
|
| 319 |
+
valid = (pt_mag > 1e-6) & (scv_mag > 1e-6)
|
| 320 |
+
print(f" Cells with nonzero velocity in both: {valid.sum()}/{len(valid)}")
|
| 321 |
+
|
| 322 |
+
if valid.sum() > 10:
|
| 323 |
+
mag_corr, mag_p = stats.spearmanr(pt_mag[valid], scv_mag[valid])
|
| 324 |
+
mean_cosine = np.mean(cosine_sim[valid])
|
| 325 |
+
print(f" Magnitude Spearman r = {mag_corr:.4f} (p={mag_p:.2e})")
|
| 326 |
+
print(f" Mean cosine similarity = {mean_cosine:.4f}")
|
| 327 |
+
else:
|
| 328 |
+
mag_corr = np.nan
|
| 329 |
+
mean_cosine = np.nan
|
| 330 |
+
|
| 331 |
+
# Save results
|
| 332 |
+
results = {
|
| 333 |
+
"n_shared_genes": len(scv_vel_shared_genes),
|
| 334 |
+
"n_cells_both_nonzero": int(valid.sum()),
|
| 335 |
+
"magnitude_spearman_r": float(mag_corr) if not np.isnan(mag_corr) else None,
|
| 336 |
+
"mean_cosine_similarity": float(mean_cosine) if not np.isnan(mean_cosine) else None,
|
| 337 |
+
}
|
| 338 |
+
with open(res_dir / "velocity_comparison.json", "w") as f:
|
| 339 |
+
json.dump(results, f, indent=2)
|
| 340 |
+
|
| 341 |
+
# Figure: 2x2 panel
|
| 342 |
+
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
|
| 343 |
+
|
| 344 |
+
# Top-left: scvelo velocity on scvelo UMAP
|
| 345 |
+
coords_scv = adata_scv.obsm.get("X_umap")
|
| 346 |
+
if coords_scv is not None:
|
| 347 |
+
axes[0, 0].scatter(coords_scv[:, 0], coords_scv[:, 1],
|
| 348 |
+
c=scv_mag, cmap="YlOrRd", s=3, alpha=0.5,
|
| 349 |
+
vmax=np.percentile(scv_mag, 95))
|
| 350 |
+
axes[0, 0].set_title("RNA Velocity magnitude (scvelo UMAP)")
|
| 351 |
+
axes[0, 0].set_xlabel("UMAP 1")
|
| 352 |
+
axes[0, 0].set_ylabel("UMAP 2")
|
| 353 |
+
|
| 354 |
+
# Top-right: PT velocity on gamma UMAP
|
| 355 |
+
coords_gamma = adata.obsm.get("X_gamma_umap")
|
| 356 |
+
if coords_gamma is not None:
|
| 357 |
+
axes[0, 1].scatter(coords_gamma[:, 0], coords_gamma[:, 1],
|
| 358 |
+
c=pt_mag, cmap="YlOrRd", s=3, alpha=0.5,
|
| 359 |
+
vmax=np.percentile(pt_mag, 95))
|
| 360 |
+
axes[0, 1].set_title("PT Velocity magnitude (gamma UMAP)")
|
| 361 |
+
axes[0, 1].set_xlabel("UMAP 1")
|
| 362 |
+
axes[0, 1].set_ylabel("UMAP 2")
|
| 363 |
+
|
| 364 |
+
# Bottom-left: magnitude correlation
|
| 365 |
+
if valid.sum() > 10:
|
| 366 |
+
axes[1, 0].scatter(scv_mag[valid], pt_mag[valid], alpha=0.1, s=3, c="steelblue")
|
| 367 |
+
axes[1, 0].set_xlabel("RNA velocity magnitude")
|
| 368 |
+
axes[1, 0].set_ylabel("PT velocity magnitude")
|
| 369 |
+
axes[1, 0].set_title(f"Magnitude correlation (r={mag_corr:.3f})")
|
| 370 |
+
|
| 371 |
+
# Bottom-right: cosine similarity distribution
|
| 372 |
+
if valid.sum() > 10:
|
| 373 |
+
axes[1, 1].hist(cosine_sim[valid], bins=50, color="steelblue",
|
| 374 |
+
alpha=0.8, edgecolor="white")
|
| 375 |
+
axes[1, 1].axvline(mean_cosine, color="red", linestyle="--",
|
| 376 |
+
label=f"Mean={mean_cosine:.3f}")
|
| 377 |
+
axes[1, 1].set_xlabel("Cosine similarity (PT vel vs RNA vel)")
|
| 378 |
+
axes[1, 1].set_ylabel("Number of cells")
|
| 379 |
+
axes[1, 1].set_title("Directional agreement")
|
| 380 |
+
axes[1, 1].legend()
|
| 381 |
+
|
| 382 |
+
fig.suptitle(f"PT Velocity vs RNA Velocity: {dataset_name}", fontsize=13, y=1.02)
|
| 383 |
+
fig.tight_layout()
|
| 384 |
+
save_fig(fig, f"velocity_comparison_{dataset_name}", "figures/velocity_comparison")
|
| 385 |
+
|
| 386 |
+
return results
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# =========================================================================
|
| 390 |
+
# GAP 3: Network inference on real data (Aim 4)
|
| 391 |
+
# =========================================================================
|
| 392 |
+
def run_network_inference(adata, dataset_name):
|
| 393 |
+
"""Run RBP-target network inference on real data.
|
| 394 |
+
|
| 395 |
+
Identifies RBPs whose expression correlates with target gene gamma shifts.
|
| 396 |
+
"""
|
| 397 |
+
print("\n" + "=" * 60)
|
| 398 |
+
print(f"GAP 3: NETWORK INFERENCE ({dataset_name})")
|
| 399 |
+
print("=" * 60)
|
| 400 |
+
|
| 401 |
+
res_dir = OUTPUT_DIR / "results" / "network" / dataset_name
|
| 402 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 403 |
+
|
| 404 |
+
# Get known RBPs that are expressed in this dataset
|
| 405 |
+
known_rbps = scptr.tl.list_known_rbps(organism="mouse")
|
| 406 |
+
rbp_genes = [g for g in known_rbps if g in adata.var_names]
|
| 407 |
+
print(f" Known RBPs in dataset: {len(rbp_genes)}/{len(known_rbps)}")
|
| 408 |
+
|
| 409 |
+
if len(rbp_genes) < 5:
|
| 410 |
+
print(" Too few RBPs, skipping network inference")
|
| 411 |
+
return
|
| 412 |
+
|
| 413 |
+
# Get top differentially degraded genes as targets
|
| 414 |
+
gamma = adata.layers["gamma"]
|
| 415 |
+
gamma_var = np.var(gamma, axis=0)
|
| 416 |
+
# Use top 500 most variable gamma genes as targets
|
| 417 |
+
top_targets_idx = np.argsort(gamma_var)[::-1][:500]
|
| 418 |
+
target_genes = [adata.var_names[i] for i in top_targets_idx
|
| 419 |
+
if gamma_var[i] > 0 and adata.var_names[i] not in rbp_genes]
|
| 420 |
+
target_genes = target_genes[:200]
|
| 421 |
+
print(f" Target genes (top variable gamma): {len(target_genes)}")
|
| 422 |
+
|
| 423 |
+
# For each cell type, compute correlation between RBP expression and
|
| 424 |
+
# target gene gamma
|
| 425 |
+
clusters = adata.obs["clusters"].astype(str)
|
| 426 |
+
all_edges = []
|
| 427 |
+
|
| 428 |
+
for cluster_name in sorted(clusters.unique()):
|
| 429 |
+
mask = (clusters == cluster_name).values
|
| 430 |
+
n_cells = mask.sum()
|
| 431 |
+
if n_cells < 30:
|
| 432 |
+
continue
|
| 433 |
+
|
| 434 |
+
# Get expression of RBPs in this cluster
|
| 435 |
+
rbp_idx = [list(adata.var_names).index(g) for g in rbp_genes]
|
| 436 |
+
if hasattr(adata.X, 'toarray'):
|
| 437 |
+
rbp_expr = adata.X[mask][:, rbp_idx].toarray()
|
| 438 |
+
else:
|
| 439 |
+
rbp_expr = adata.X[mask][:, rbp_idx]
|
| 440 |
+
|
| 441 |
+
# Get gamma of target genes
|
| 442 |
+
target_idx = [list(adata.var_names).index(g) for g in target_genes]
|
| 443 |
+
target_gamma = gamma[mask][:, target_idx]
|
| 444 |
+
|
| 445 |
+
# Correlation: RBP expression vs target gamma
|
| 446 |
+
for ri, rbp in enumerate(rbp_genes):
|
| 447 |
+
rbp_x = rbp_expr[:, ri]
|
| 448 |
+
if np.std(rbp_x) < 1e-6:
|
| 449 |
+
continue
|
| 450 |
+
|
| 451 |
+
for ti, target in enumerate(target_genes):
|
| 452 |
+
target_g = target_gamma[:, ti]
|
| 453 |
+
if np.std(target_g) < 1e-6:
|
| 454 |
+
continue
|
| 455 |
+
|
| 456 |
+
r, p = stats.spearmanr(rbp_x, target_g)
|
| 457 |
+
if abs(r) > 0.2 and p < 0.01:
|
| 458 |
+
all_edges.append({
|
| 459 |
+
"cluster": cluster_name,
|
| 460 |
+
"rbp": rbp,
|
| 461 |
+
"target": target,
|
| 462 |
+
"spearman_r": float(r),
|
| 463 |
+
"p_value": float(p),
|
| 464 |
+
"direction": "stabilizing" if r < 0 else "destabilizing",
|
| 465 |
+
})
|
| 466 |
+
|
| 467 |
+
edges_df = pd.DataFrame(all_edges)
|
| 468 |
+
if len(edges_df) > 0:
|
| 469 |
+
# Multiple testing correction (Benjamini-Hochberg)
|
| 470 |
+
from statsmodels.stats.multitest import multipletests
|
| 471 |
+
_, edges_df["fdr"], _, _ = multipletests(edges_df["p_value"], method="fdr_bh")
|
| 472 |
+
edges_df = edges_df[edges_df["fdr"] < 0.05].copy()
|
| 473 |
+
|
| 474 |
+
edges_df.to_csv(res_dir / "network_edges.csv", index=False)
|
| 475 |
+
print(f" Significant edges (FDR<0.05): {len(edges_df)}")
|
| 476 |
+
|
| 477 |
+
if len(edges_df) > 0:
|
| 478 |
+
# Top RBP hubs
|
| 479 |
+
hub_counts = edges_df.groupby("rbp").size().sort_values(ascending=False)
|
| 480 |
+
print(f"\n Top RBP hubs:")
|
| 481 |
+
for rbp, count in hub_counts.head(15).items():
|
| 482 |
+
n_stab = len(edges_df[(edges_df["rbp"] == rbp) & (edges_df["direction"] == "stabilizing")])
|
| 483 |
+
n_dest = len(edges_df[(edges_df["rbp"] == rbp) & (edges_df["direction"] == "destabilizing")])
|
| 484 |
+
print(f" {rbp}: {count} targets ({n_stab} stabilizing, {n_dest} destabilizing)")
|
| 485 |
+
|
| 486 |
+
hub_counts.head(30).to_csv(res_dir / "rbp_hub_counts.csv")
|
| 487 |
+
|
| 488 |
+
# Network summary figure
|
| 489 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
|
| 490 |
+
|
| 491 |
+
# Left: top RBP hubs
|
| 492 |
+
top_hubs = hub_counts.head(20)
|
| 493 |
+
colors = ["steelblue" if h > hub_counts.median() else "lightblue"
|
| 494 |
+
for h in top_hubs.values]
|
| 495 |
+
axes[0].barh(range(len(top_hubs)), top_hubs.values, color=colors)
|
| 496 |
+
axes[0].set_yticks(range(len(top_hubs)))
|
| 497 |
+
axes[0].set_yticklabels(top_hubs.index)
|
| 498 |
+
axes[0].set_xlabel("Number of target genes")
|
| 499 |
+
axes[0].set_title("Top RBP Regulators")
|
| 500 |
+
axes[0].invert_yaxis()
|
| 501 |
+
|
| 502 |
+
# Right: effect size distribution
|
| 503 |
+
axes[1].hist(edges_df["spearman_r"], bins=40, color="steelblue",
|
| 504 |
+
alpha=0.8, edgecolor="white")
|
| 505 |
+
axes[1].axvline(0, color="red", linestyle="--", alpha=0.5)
|
| 506 |
+
n_stab = (edges_df["direction"] == "stabilizing").sum()
|
| 507 |
+
n_dest = (edges_df["direction"] == "destabilizing").sum()
|
| 508 |
+
axes[1].set_xlabel("Spearman correlation (RBP expr vs target gamma)")
|
| 509 |
+
axes[1].set_ylabel("Number of edges")
|
| 510 |
+
axes[1].set_title(f"Edge effects: {n_stab} stabilizing, {n_dest} destabilizing")
|
| 511 |
+
|
| 512 |
+
fig.suptitle(f"RBP-Target Network: {dataset_name}", fontsize=13, y=1.02)
|
| 513 |
+
fig.tight_layout()
|
| 514 |
+
save_fig(fig, f"network_{dataset_name}", "figures/network")
|
| 515 |
+
|
| 516 |
+
return edges_df
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# =========================================================================
|
| 520 |
+
# MAIN
|
| 521 |
+
# =========================================================================
|
| 522 |
+
def main():
|
| 523 |
+
set_figure_style()
|
| 524 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 525 |
+
|
| 526 |
+
# Process both datasets
|
| 527 |
+
panc = process_dataset("pancreas")
|
| 528 |
+
dg = process_dataset("dentate_gyrus")
|
| 529 |
+
|
| 530 |
+
# GAP 1: Expression-invisible states
|
| 531 |
+
invis_panc = run_invisible_states(panc, "pancreas")
|
| 532 |
+
invis_dg = run_invisible_states(dg, "dentate_gyrus")
|
| 533 |
+
|
| 534 |
+
# GAP 2: RNA velocity comparison
|
| 535 |
+
vel_panc = run_velocity_comparison(panc, "pancreas")
|
| 536 |
+
vel_dg = run_velocity_comparison(dg, "dentate_gyrus")
|
| 537 |
+
|
| 538 |
+
# GAP 3: Network inference
|
| 539 |
+
net_panc = run_network_inference(panc, "pancreas")
|
| 540 |
+
net_dg = run_network_inference(dg, "dentate_gyrus")
|
| 541 |
+
|
| 542 |
+
# Summary
|
| 543 |
+
print("\n" + "=" * 60)
|
| 544 |
+
print("GAP ANALYSIS COMPLETE")
|
| 545 |
+
print("=" * 60)
|
| 546 |
+
print(f"\nAll results saved to: {OUTPUT_DIR.resolve()}")
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
if __name__ == "__main__":
|
| 550 |
+
main()
|
analyses/run_halflife_ablation.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Half-life ablation: compare scPTR gamma vs naive methods for biological accuracy.
|
| 3 |
+
|
| 4 |
+
For each dataset, compute per-gene median values using four methods:
|
| 5 |
+
1. scPTR gamma (full kinetic model)
|
| 6 |
+
2. Raw u/s ratio (no beta normalization)
|
| 7 |
+
3. Unspliced only (raw unspliced counts)
|
| 8 |
+
4. Expression (spliced counts, negative control)
|
| 9 |
+
|
| 10 |
+
Then correlate each with published mRNA half-lives. scPTR gamma should produce
|
| 11 |
+
the strongest negative correlation because the kinetic model (beta normalization,
|
| 12 |
+
smoothing, clipping) produces biologically meaningful degradation rates.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import matplotlib
|
| 22 |
+
matplotlib.use("Agg")
|
| 23 |
+
import matplotlib.pyplot as plt
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
from scipy import stats
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 29 |
+
from _common import set_figure_style
|
| 30 |
+
|
| 31 |
+
import scptr
|
| 32 |
+
|
| 33 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "halflife_ablation"
|
| 34 |
+
DATASETS_DIR = Path(__file__).parent.parent / "src" / "scptr" / "datasets" / "data"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_fig(fig, name, subdir="figures"):
|
| 38 |
+
out_dir = OUTPUT_DIR / subdir
|
| 39 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
path = out_dir / f"{name}.png"
|
| 41 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 42 |
+
plt.close(fig)
|
| 43 |
+
print(f" Saved: {path}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_pipeline(adata, name):
|
| 47 |
+
print(f"\n--- Pipeline: {name} ---")
|
| 48 |
+
scptr.pp.filter_genes(adata)
|
| 49 |
+
scptr.pp.normalize_layers(adata)
|
| 50 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 51 |
+
scptr.pp.smooth_layers(adata)
|
| 52 |
+
scptr.tl.estimate_beta(adata)
|
| 53 |
+
scptr.tl.estimate_gamma(adata)
|
| 54 |
+
print(f" Done: {adata.shape}")
|
| 55 |
+
return adata
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def halflife_ablation(adata, name):
|
| 59 |
+
"""Compare half-life correlations across methods."""
|
| 60 |
+
print(f"\n{'='*60}")
|
| 61 |
+
print(f"HALF-LIFE ABLATION: {name}")
|
| 62 |
+
print(f"{'='*60}")
|
| 63 |
+
|
| 64 |
+
gamma = adata.layers["gamma"]
|
| 65 |
+
u_layer = adata.layers.get("Mu", adata.layers.get("unspliced"))
|
| 66 |
+
s_layer = adata.layers.get("Ms", adata.layers.get("spliced"))
|
| 67 |
+
u = u_layer.toarray() if hasattr(u_layer, 'toarray') else np.asarray(u_layer)
|
| 68 |
+
s = s_layer.toarray() if hasattr(s_layer, 'toarray') else np.asarray(s_layer)
|
| 69 |
+
expr = adata.X.toarray() if hasattr(adata.X, 'toarray') else np.asarray(adata.X)
|
| 70 |
+
|
| 71 |
+
# Raw u/s ratio
|
| 72 |
+
s_safe = np.where(s > 0.01, s, 1.0)
|
| 73 |
+
raw_ratio = u / s_safe
|
| 74 |
+
raw_ratio[s < 0.01] = 0
|
| 75 |
+
|
| 76 |
+
# Per-gene medians for each method
|
| 77 |
+
methods = {
|
| 78 |
+
"scPTR gamma": np.median(gamma, axis=0),
|
| 79 |
+
"Raw u/s ratio": np.median(raw_ratio, axis=0),
|
| 80 |
+
"Unspliced only": np.median(u, axis=0),
|
| 81 |
+
"Expression": np.median(expr, axis=0),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Filter to gamma-informative genes
|
| 85 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 86 |
+
informative = nonzero_frac >= 0.1
|
| 87 |
+
|
| 88 |
+
# Load half-life references
|
| 89 |
+
hl_files = [
|
| 90 |
+
("Mouse (Herzog)", DATASETS_DIR / "herzog2017_halflives.csv"),
|
| 91 |
+
("Human (Schofield)", DATASETS_DIR / "schofield2018_halflives.csv"),
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
results = []
|
| 95 |
+
|
| 96 |
+
for hl_label, hl_path in hl_files:
|
| 97 |
+
if not hl_path.exists():
|
| 98 |
+
continue
|
| 99 |
+
|
| 100 |
+
hl_df = pd.read_csv(hl_path)
|
| 101 |
+
hl_df = hl_df[["gene_symbol", "half_life_hours"]].dropna()
|
| 102 |
+
hl_dict = dict(zip(hl_df["gene_symbol"].str.upper(), hl_df["half_life_hours"]))
|
| 103 |
+
|
| 104 |
+
print(f"\n Reference: {hl_label}")
|
| 105 |
+
|
| 106 |
+
for method_name, medians in methods.items():
|
| 107 |
+
matched_vals = []
|
| 108 |
+
matched_hl = []
|
| 109 |
+
for i, gene in enumerate(adata.var_names):
|
| 110 |
+
g_upper = gene.upper()
|
| 111 |
+
if g_upper in hl_dict and informative[i]:
|
| 112 |
+
matched_vals.append(medians[i])
|
| 113 |
+
matched_hl.append(hl_dict[g_upper])
|
| 114 |
+
|
| 115 |
+
if len(matched_vals) < 50:
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
r, p = stats.spearmanr(matched_vals, matched_hl)
|
| 119 |
+
print(f" {method_name:<20s}: r = {r:.4f} (p = {p:.2e}, n = {len(matched_vals)})")
|
| 120 |
+
|
| 121 |
+
results.append({
|
| 122 |
+
"dataset": name,
|
| 123 |
+
"reference": hl_label,
|
| 124 |
+
"method": method_name,
|
| 125 |
+
"spearman_r": float(r),
|
| 126 |
+
"p_value": float(p),
|
| 127 |
+
"n_genes": len(matched_vals),
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
return results
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main():
|
| 134 |
+
set_figure_style()
|
| 135 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 136 |
+
res_dir = OUTPUT_DIR / "results"
|
| 137 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
|
| 139 |
+
# Load datasets
|
| 140 |
+
all_results = []
|
| 141 |
+
|
| 142 |
+
print("=" * 60)
|
| 143 |
+
print("LOADING DATASETS")
|
| 144 |
+
print("=" * 60)
|
| 145 |
+
|
| 146 |
+
adata_pan = scptr.datasets.pancreas()
|
| 147 |
+
adata_pan = run_pipeline(adata_pan, "pancreas")
|
| 148 |
+
all_results.extend(halflife_ablation(adata_pan, "pancreas"))
|
| 149 |
+
|
| 150 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 151 |
+
adata_dg = run_pipeline(adata_dg, "dentate_gyrus")
|
| 152 |
+
all_results.extend(halflife_ablation(adata_dg, "dentate_gyrus"))
|
| 153 |
+
|
| 154 |
+
# sci-fate
|
| 155 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 156 |
+
adata_sf_raw = load_scifate_data()
|
| 157 |
+
adata_sf = prepare_for_scptr(adata_sf_raw)
|
| 158 |
+
adata_sf = run_pipeline(adata_sf, "scifate")
|
| 159 |
+
all_results.extend(halflife_ablation(adata_sf, "scifate"))
|
| 160 |
+
|
| 161 |
+
# Save results
|
| 162 |
+
results_df = pd.DataFrame(all_results)
|
| 163 |
+
results_df.to_csv(res_dir / "halflife_ablation.csv", index=False)
|
| 164 |
+
|
| 165 |
+
# Summary
|
| 166 |
+
print(f"\n{'='*60}")
|
| 167 |
+
print("SUMMARY")
|
| 168 |
+
print(f"{'='*60}")
|
| 169 |
+
|
| 170 |
+
# Use Human (Schofield) as primary reference
|
| 171 |
+
human_results = results_df[results_df["reference"] == "Human (Schofield)"]
|
| 172 |
+
if len(human_results) > 0:
|
| 173 |
+
pivot = human_results.pivot_table(
|
| 174 |
+
index="method", columns="dataset", values="spearman_r", aggfunc="first"
|
| 175 |
+
)
|
| 176 |
+
print("\n Spearman r with Human (Schofield) half-lives:")
|
| 177 |
+
print(pivot.to_string())
|
| 178 |
+
|
| 179 |
+
# Figure: grouped bar chart
|
| 180 |
+
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 181 |
+
|
| 182 |
+
for ax_idx, (hl_label, hl_sub) in enumerate(results_df.groupby("reference")):
|
| 183 |
+
ax = axes[ax_idx]
|
| 184 |
+
datasets = hl_sub["dataset"].unique()
|
| 185 |
+
methods_order = ["scPTR gamma", "Raw u/s ratio", "Unspliced only", "Expression"]
|
| 186 |
+
colors = ["steelblue", "orange", "lightblue", "gray"]
|
| 187 |
+
x = np.arange(len(datasets))
|
| 188 |
+
width = 0.18
|
| 189 |
+
|
| 190 |
+
for mi, (method, color) in enumerate(zip(methods_order, colors)):
|
| 191 |
+
vals = []
|
| 192 |
+
for ds in datasets:
|
| 193 |
+
sub = hl_sub[(hl_sub["method"] == method) & (hl_sub["dataset"] == ds)]
|
| 194 |
+
vals.append(sub["spearman_r"].values[0] if len(sub) > 0 else 0)
|
| 195 |
+
bars = ax.bar(x + mi * width, vals, width, label=method, color=color,
|
| 196 |
+
edgecolor="black", linewidth=0.5)
|
| 197 |
+
for bi, v in enumerate(vals):
|
| 198 |
+
ax.text(x[bi] + mi * width, v - 0.02, f"{v:.3f}",
|
| 199 |
+
ha="center", va="top", fontsize=7, rotation=90)
|
| 200 |
+
|
| 201 |
+
ax.set_xticks(x + 1.5 * width)
|
| 202 |
+
ax.set_xticklabels(datasets, fontsize=9)
|
| 203 |
+
ax.set_ylabel("Spearman r with half-life")
|
| 204 |
+
ax.set_title(f"Half-life Correlation: {hl_label}")
|
| 205 |
+
ax.legend(fontsize=7, loc="lower left")
|
| 206 |
+
ax.axhline(y=0, color="black", linewidth=0.5)
|
| 207 |
+
|
| 208 |
+
fig.suptitle("Ablation: Which Method Best Predicts mRNA Half-Life?", fontsize=13)
|
| 209 |
+
fig.tight_layout()
|
| 210 |
+
save_fig(fig, "halflife_ablation")
|
| 211 |
+
|
| 212 |
+
print(f"\nResults saved to: {OUTPUT_DIR.resolve()}")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
main()
|
analyses/run_perturbation_validation.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""RBP perturbation validation: compare scPTR network predictions with
|
| 3 |
+
Replogle 2022 CRISPRi Perturb-seq data (via Harmonizome API).
|
| 4 |
+
|
| 5 |
+
For each RBP hub identified by scPTR (via Spearman correlation between
|
| 6 |
+
RBP expression and target gamma), we test whether its predicted targets
|
| 7 |
+
are enriched among genes differentially expressed upon RBP knockdown.
|
| 8 |
+
|
| 9 |
+
This validates the causal direction: if scPTR correctly identifies that RBP X
|
| 10 |
+
regulates gene Y's degradation, then knocking down RBP X should change Y's
|
| 11 |
+
expression level.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import matplotlib
|
| 21 |
+
matplotlib.use("Agg")
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
from scipy import stats
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 28 |
+
from _common import set_figure_style
|
| 29 |
+
|
| 30 |
+
import scptr
|
| 31 |
+
|
| 32 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "perturbation_validation"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def save_fig(fig, name, subdir="figures"):
|
| 36 |
+
out_dir = OUTPUT_DIR / subdir
|
| 37 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
path = out_dir / f"{name}.png"
|
| 39 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 40 |
+
plt.close(fig)
|
| 41 |
+
print(f" Saved: {path}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_perturb_seq_de(rbp: str) -> tuple[list[str], list[str]] | None:
|
| 45 |
+
"""Load differentially expressed genes from Replogle 2022 CRISPRi Perturb-seq
|
| 46 |
+
via Harmonizome API.
|
| 47 |
+
|
| 48 |
+
Returns (up_genes, down_genes): genes whose expression increases/decreases
|
| 49 |
+
when the RBP is knocked down.
|
| 50 |
+
"""
|
| 51 |
+
import requests
|
| 52 |
+
|
| 53 |
+
rbp_ids = {
|
| 54 |
+
"HNRNPA1": "3857_HNRNPA1_P1P2",
|
| 55 |
+
"YBX1": "9921_YBX1_P1P2",
|
| 56 |
+
"ELAVL1": "2583_ELAVL1_P1P2",
|
| 57 |
+
"SRSF3": "8433_SRSF3_P1P2",
|
| 58 |
+
"RBFOX2": "7148_RBFOX2_P1",
|
| 59 |
+
"FUS": "3224_FUS_P1P2",
|
| 60 |
+
"HNRNPC": "3861_HNRNPC_P1P2",
|
| 61 |
+
"DDX5": "2134_DDX5_P1P2",
|
| 62 |
+
"MBNL1": "4881_MBNL1_P1P2",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
gene_set_id = rbp_ids.get(rbp)
|
| 66 |
+
if gene_set_id is None:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
dataset_name = ("Replogle+et+al.,+Cell,+2022+K562+Genome-wide+"
|
| 70 |
+
"Perturb-seq+Gene+Perturbation+Signatures")
|
| 71 |
+
url = (f"https://maayanlab.cloud/Harmonizome/api/1.0/gene_set/"
|
| 72 |
+
f"{gene_set_id}/{dataset_name}")
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
r = requests.get(url, timeout=30)
|
| 76 |
+
r.raise_for_status()
|
| 77 |
+
data = r.json()
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f" Harmonizome API error for {rbp}: {e}")
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
associations = data.get("associations", [])
|
| 83 |
+
if not associations:
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
up_genes = []
|
| 87 |
+
down_genes = []
|
| 88 |
+
for assoc in associations:
|
| 89 |
+
gene_name = assoc.get("gene", {}).get("symbol", "")
|
| 90 |
+
value = assoc.get("standardizedValue", 0)
|
| 91 |
+
if value > 0:
|
| 92 |
+
up_genes.append(gene_name)
|
| 93 |
+
else:
|
| 94 |
+
down_genes.append(gene_name)
|
| 95 |
+
|
| 96 |
+
return up_genes, down_genes
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def infer_spearman_network(adata, rbp_list, n_top_targets=200):
|
| 100 |
+
"""Infer RBP-target network using vectorized Spearman partial correlation
|
| 101 |
+
(library-size corrected) between RBP expression and target gene gamma.
|
| 102 |
+
|
| 103 |
+
Vectorized approach: rank all columns once, residualize against library size
|
| 104 |
+
ranks using matrix operations, then compute correlations via dot products.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
gamma = np.array(scptr._utils.get_layer(adata, "gamma"))
|
| 108 |
+
expression = np.array(scptr._utils.get_layer(adata, "Ms"))
|
| 109 |
+
|
| 110 |
+
gene_names = [g.upper() for g in adata.var_names]
|
| 111 |
+
gene_name_to_idx = {g: i for i, g in enumerate(gene_names)}
|
| 112 |
+
|
| 113 |
+
n_cells, n_genes = gamma.shape
|
| 114 |
+
|
| 115 |
+
# Library size ranks (once)
|
| 116 |
+
lib_size = expression.sum(axis=1)
|
| 117 |
+
lib_rank = stats.rankdata(lib_size)
|
| 118 |
+
lib_rank_centered = lib_rank - lib_rank.mean()
|
| 119 |
+
lib_ss = np.dot(lib_rank_centered, lib_rank_centered)
|
| 120 |
+
|
| 121 |
+
# Rank all gamma columns (vectorized)
|
| 122 |
+
gamma_ranks = np.zeros_like(gamma)
|
| 123 |
+
gamma_valid = np.zeros(n_genes, dtype=bool)
|
| 124 |
+
for j in range(n_genes):
|
| 125 |
+
col = gamma[:, j]
|
| 126 |
+
if np.std(col) < 1e-8:
|
| 127 |
+
continue
|
| 128 |
+
gamma_ranks[:, j] = stats.rankdata(col)
|
| 129 |
+
gamma_valid[j] = True
|
| 130 |
+
|
| 131 |
+
# Residualize gamma ranks against library size (vectorized)
|
| 132 |
+
# slope_j = dot(lib_rank_centered, gamma_rank_j_centered) / dot(lib_rank_centered, lib_rank_centered)
|
| 133 |
+
gamma_ranks_centered = gamma_ranks - gamma_ranks.mean(axis=0, keepdims=True)
|
| 134 |
+
slopes_gamma = np.dot(lib_rank_centered, gamma_ranks_centered) / lib_ss
|
| 135 |
+
gamma_resid = gamma_ranks - np.outer(lib_rank, slopes_gamma)
|
| 136 |
+
gamma_resid_centered = gamma_resid - gamma_resid.mean(axis=0, keepdims=True)
|
| 137 |
+
gamma_resid_std = np.sqrt((gamma_resid_centered ** 2).sum(axis=0))
|
| 138 |
+
gamma_resid_std[gamma_resid_std < 1e-8] = 1.0 # avoid division by zero
|
| 139 |
+
|
| 140 |
+
edges = []
|
| 141 |
+
seen_rbps = set()
|
| 142 |
+
|
| 143 |
+
for rbp in rbp_list:
|
| 144 |
+
rbp_upper = rbp.upper()
|
| 145 |
+
if rbp_upper in seen_rbps:
|
| 146 |
+
continue
|
| 147 |
+
if rbp_upper not in gene_name_to_idx:
|
| 148 |
+
continue
|
| 149 |
+
seen_rbps.add(rbp_upper)
|
| 150 |
+
|
| 151 |
+
rbp_idx = gene_name_to_idx[rbp_upper]
|
| 152 |
+
rbp_expr = expression[:, rbp_idx]
|
| 153 |
+
|
| 154 |
+
if np.std(rbp_expr) < 1e-8:
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
# Rank and residualize RBP expression
|
| 158 |
+
rbp_rank = stats.rankdata(rbp_expr)
|
| 159 |
+
rbp_rank_centered = rbp_rank - rbp_rank.mean()
|
| 160 |
+
slope_rbp = np.dot(lib_rank_centered, rbp_rank_centered) / lib_ss
|
| 161 |
+
rbp_resid = rbp_rank - slope_rbp * lib_rank
|
| 162 |
+
rbp_resid_centered = rbp_resid - rbp_resid.mean()
|
| 163 |
+
rbp_resid_std = np.sqrt(np.dot(rbp_resid_centered, rbp_resid_centered))
|
| 164 |
+
|
| 165 |
+
if rbp_resid_std < 1e-8:
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
# Vectorized correlation: r = dot(rbp_resid, gamma_resid) / (std_rbp * std_gamma)
|
| 169 |
+
r_vals = np.dot(rbp_resid_centered, gamma_resid_centered) / (rbp_resid_std * gamma_resid_std)
|
| 170 |
+
r_vals = np.clip(r_vals, -1.0, 1.0)
|
| 171 |
+
|
| 172 |
+
# Compute p-values from t-distribution
|
| 173 |
+
df = n_cells - 3 # partial correlation df
|
| 174 |
+
t_vals = r_vals * np.sqrt(df / (1 - r_vals ** 2 + 1e-12))
|
| 175 |
+
p_vals = 2 * stats.t.sf(np.abs(t_vals), df)
|
| 176 |
+
|
| 177 |
+
# Filter to valid targets (not self, valid gamma)
|
| 178 |
+
valid_mask = gamma_valid.copy()
|
| 179 |
+
valid_mask[rbp_idx] = False
|
| 180 |
+
valid_indices = np.where(valid_mask)[0]
|
| 181 |
+
|
| 182 |
+
if len(valid_indices) == 0:
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
valid_r = r_vals[valid_indices]
|
| 186 |
+
valid_p = p_vals[valid_indices]
|
| 187 |
+
|
| 188 |
+
# Select top N targets by absolute correlation strength
|
| 189 |
+
abs_r = np.abs(valid_r)
|
| 190 |
+
top_k = min(n_top_targets, len(abs_r))
|
| 191 |
+
top_indices = np.argsort(abs_r)[::-1][:top_k]
|
| 192 |
+
|
| 193 |
+
for idx_in_valid in top_indices:
|
| 194 |
+
gene_idx = valid_indices[idx_in_valid]
|
| 195 |
+
edges.append({
|
| 196 |
+
"regulator": rbp_upper,
|
| 197 |
+
"target": gene_names[gene_idx],
|
| 198 |
+
"weight": float(valid_r[idx_in_valid]),
|
| 199 |
+
"p_value": float(valid_p[idx_in_valid]),
|
| 200 |
+
"direction": "destabilizing" if valid_r[idx_in_valid] > 0 else "stabilizing",
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
result = pd.DataFrame(edges)
|
| 204 |
+
if len(result) > 0:
|
| 205 |
+
result = result.sort_values("weight", key=abs, ascending=False).reset_index(drop=True)
|
| 206 |
+
|
| 207 |
+
return result
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def validate_rbp_targets(adata, dataset_name, network_df):
|
| 211 |
+
"""For each hub RBP, test enrichment of its predicted targets among
|
| 212 |
+
perturbation-responsive genes."""
|
| 213 |
+
print(f"\n{'='*60}")
|
| 214 |
+
print(f"PERTURBATION VALIDATION: {dataset_name}")
|
| 215 |
+
print(f"{'='*60}")
|
| 216 |
+
|
| 217 |
+
rbp_counts = network_df.groupby("regulator").size().sort_values(ascending=False)
|
| 218 |
+
top_rbps = rbp_counts.head(15).index.tolist()
|
| 219 |
+
print(f" Top RBP hubs: {top_rbps[:10]}")
|
| 220 |
+
|
| 221 |
+
results = []
|
| 222 |
+
|
| 223 |
+
for rbp in top_rbps:
|
| 224 |
+
rbp_upper = rbp.upper()
|
| 225 |
+
|
| 226 |
+
rbp_edges = network_df[network_df["regulator"] == rbp_upper]
|
| 227 |
+
predicted_targets = set(rbp_edges["target"].str.upper())
|
| 228 |
+
predicted_destab = set(
|
| 229 |
+
rbp_edges[rbp_edges["direction"] == "destabilizing"]["target"].str.upper()
|
| 230 |
+
)
|
| 231 |
+
predicted_stab = set(
|
| 232 |
+
rbp_edges[rbp_edges["direction"] == "stabilizing"]["target"].str.upper()
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
n_targets = len(predicted_targets)
|
| 236 |
+
if n_targets < 5:
|
| 237 |
+
continue
|
| 238 |
+
|
| 239 |
+
perturb_result = load_perturb_seq_de(rbp_upper)
|
| 240 |
+
if perturb_result is not None:
|
| 241 |
+
up_genes, down_genes = perturb_result
|
| 242 |
+
up_set = set(g.upper() for g in up_genes)
|
| 243 |
+
down_set = set(g.upper() for g in down_genes)
|
| 244 |
+
|
| 245 |
+
all_genes = set(g.upper() for g in adata.var_names)
|
| 246 |
+
|
| 247 |
+
# Destabilizing targets should be upregulated upon RBP knockdown
|
| 248 |
+
if len(predicted_destab) > 0 and len(up_set) > 0:
|
| 249 |
+
overlap_destab_up = len(predicted_destab & up_set)
|
| 250 |
+
destab_not_up = len(predicted_destab - up_set)
|
| 251 |
+
up_not_destab = len(up_set - predicted_destab)
|
| 252 |
+
neither = len(all_genes - predicted_destab - up_set)
|
| 253 |
+
|
| 254 |
+
table = [[overlap_destab_up, destab_not_up],
|
| 255 |
+
[up_not_destab, neither]]
|
| 256 |
+
odds_ratio, fisher_p = stats.fisher_exact(table,
|
| 257 |
+
alternative="greater")
|
| 258 |
+
|
| 259 |
+
print(f"\n {rbp_upper} (Perturb-seq CRISPRi):")
|
| 260 |
+
print(f" Predicted destab targets: {len(predicted_destab)}")
|
| 261 |
+
print(f" Genes up upon KD: {len(up_set)}")
|
| 262 |
+
print(f" Overlap: {overlap_destab_up}")
|
| 263 |
+
print(f" Fisher OR={odds_ratio:.2f}, p={fisher_p:.3e}")
|
| 264 |
+
|
| 265 |
+
results.append({
|
| 266 |
+
"rbp": rbp_upper,
|
| 267 |
+
"dataset": dataset_name,
|
| 268 |
+
"validation": "Perturb-seq_CRISPRi",
|
| 269 |
+
"n_predicted_targets": n_targets,
|
| 270 |
+
"n_predicted_destab": len(predicted_destab),
|
| 271 |
+
"n_predicted_stab": len(predicted_stab),
|
| 272 |
+
"n_perturbation_up": len(up_set),
|
| 273 |
+
"n_perturbation_down": len(down_set),
|
| 274 |
+
"overlap_destab_up": overlap_destab_up,
|
| 275 |
+
"fisher_or": float(odds_ratio),
|
| 276 |
+
"fisher_p": float(fisher_p),
|
| 277 |
+
})
|
| 278 |
+
|
| 279 |
+
# Stabilizing targets should be downregulated upon RBP knockdown
|
| 280 |
+
if len(predicted_stab) > 0 and len(down_set) > 0:
|
| 281 |
+
overlap_stab_down = len(predicted_stab & down_set)
|
| 282 |
+
stab_not_down = len(predicted_stab - down_set)
|
| 283 |
+
down_not_stab = len(down_set - predicted_stab)
|
| 284 |
+
neither2 = len(all_genes - predicted_stab - down_set)
|
| 285 |
+
|
| 286 |
+
table2 = [[overlap_stab_down, stab_not_down],
|
| 287 |
+
[down_not_stab, neither2]]
|
| 288 |
+
or2, p2 = stats.fisher_exact(table2, alternative="greater")
|
| 289 |
+
|
| 290 |
+
print(f" Stabilizing->down: overlap={overlap_stab_down}, "
|
| 291 |
+
f"OR={or2:.2f}, p={p2:.3e}")
|
| 292 |
+
else:
|
| 293 |
+
print(f"\n {rbp_upper}: No Perturb-seq data available")
|
| 294 |
+
|
| 295 |
+
return results
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def run_network_and_validate(adata, dataset_name):
|
| 299 |
+
"""Run scPTR pipeline, infer correlation-based network, validate."""
|
| 300 |
+
import copy
|
| 301 |
+
adata = copy.deepcopy(adata)
|
| 302 |
+
scptr.pp.filter_genes(adata)
|
| 303 |
+
scptr.pp.normalize_layers(adata)
|
| 304 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 305 |
+
scptr.pp.smooth_layers(adata)
|
| 306 |
+
scptr.tl.estimate_beta(adata)
|
| 307 |
+
scptr.tl.estimate_gamma(adata)
|
| 308 |
+
|
| 309 |
+
# RBPs to test (those with Perturb-seq data + known RBP hubs)
|
| 310 |
+
rbp_list = [
|
| 311 |
+
"HNRNPA1", "YBX1", "ELAVL1", "SRSF3", "RBFOX2",
|
| 312 |
+
"FUS", "HNRNPC", "DDX5", "MBNL1",
|
| 313 |
+
# Additional common RBP hubs
|
| 314 |
+
"HNRNPD", "TRA2B", "ZFP36L1", "RBFOX1", "RBFOX3",
|
| 315 |
+
"CELF2", "ELAVL3", "MATR3", "MBNL2", "PTBP1",
|
| 316 |
+
# Mouse gene name variants
|
| 317 |
+
"Hnrnpa1", "Ybx1", "Elavl1", "Srsf3", "Rbfox2",
|
| 318 |
+
"Fus", "Hnrnpc", "Ddx5", "Mbnl1", "Hnrnpd",
|
| 319 |
+
"Tra2b", "Zfp36l1", "Rbfox1", "Rbfox3", "Celf2",
|
| 320 |
+
"Elavl3", "Matr3", "Mbnl2", "Ptbp1",
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
print(f" Inferring correlation network for {len(rbp_list)} candidate RBPs...")
|
| 324 |
+
net_df = infer_spearman_network(adata, rbp_list, n_top_targets=200)
|
| 325 |
+
|
| 326 |
+
if len(net_df) == 0:
|
| 327 |
+
print(f" No network edges for {dataset_name}")
|
| 328 |
+
return []
|
| 329 |
+
|
| 330 |
+
print(f" Network: {len(net_df)} edges, "
|
| 331 |
+
f"{net_df['regulator'].nunique()} regulators, "
|
| 332 |
+
f"{net_df['target'].nunique()} targets")
|
| 333 |
+
|
| 334 |
+
destab_frac = (net_df["direction"] == "destabilizing").mean()
|
| 335 |
+
print(f" Destabilizing fraction: {destab_frac:.1%}")
|
| 336 |
+
|
| 337 |
+
return validate_rbp_targets(adata, dataset_name, net_df)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def main():
|
| 341 |
+
set_figure_style()
|
| 342 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 343 |
+
|
| 344 |
+
print("=" * 60)
|
| 345 |
+
print("LOADING DATASETS")
|
| 346 |
+
print("=" * 60)
|
| 347 |
+
|
| 348 |
+
adata_pan = scptr.datasets.pancreas()
|
| 349 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 350 |
+
|
| 351 |
+
all_results = []
|
| 352 |
+
|
| 353 |
+
print("\n" + "#" * 60)
|
| 354 |
+
print("# PANCREAS")
|
| 355 |
+
print("#" * 60)
|
| 356 |
+
results_pan = run_network_and_validate(adata_pan, "pancreas")
|
| 357 |
+
all_results.extend(results_pan)
|
| 358 |
+
|
| 359 |
+
print("\n" + "#" * 60)
|
| 360 |
+
print("# DENTATE GYRUS")
|
| 361 |
+
print("#" * 60)
|
| 362 |
+
results_dg = run_network_and_validate(adata_dg, "dentate_gyrus")
|
| 363 |
+
all_results.extend(results_dg)
|
| 364 |
+
|
| 365 |
+
# Save results
|
| 366 |
+
res_dir = OUTPUT_DIR / "results"
|
| 367 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 368 |
+
|
| 369 |
+
if all_results:
|
| 370 |
+
results_df = pd.DataFrame(all_results)
|
| 371 |
+
results_df.to_csv(res_dir / "perturbation_validation.csv", index=False)
|
| 372 |
+
|
| 373 |
+
# FDR correction across all tests
|
| 374 |
+
from statsmodels.stats.multitest import multipletests
|
| 375 |
+
_, fdr, _, _ = multipletests(results_df["fisher_p"], method="fdr_bh")
|
| 376 |
+
results_df["fdr"] = fdr
|
| 377 |
+
|
| 378 |
+
# Summary
|
| 379 |
+
print(f"\n{'='*60}")
|
| 380 |
+
print("PERTURBATION VALIDATION SUMMARY")
|
| 381 |
+
print(f"{'='*60}")
|
| 382 |
+
print(f" Total tests: {len(results_df)}")
|
| 383 |
+
print(f" Significant (p<0.05): {(results_df['fisher_p'] < 0.05).sum()}")
|
| 384 |
+
print(f" Significant (FDR<0.10): {(results_df['fdr'] < 0.10).sum()}")
|
| 385 |
+
print(f" Mean odds ratio: {results_df['fisher_or'].mean():.2f}")
|
| 386 |
+
print(f" Median odds ratio: {results_df['fisher_or'].median():.2f}")
|
| 387 |
+
|
| 388 |
+
print(f"\n Per-RBP results:")
|
| 389 |
+
for _, row in results_df.sort_values("fisher_p").iterrows():
|
| 390 |
+
sig = ("***" if row["fisher_p"] < 0.001 else
|
| 391 |
+
"**" if row["fisher_p"] < 0.01 else
|
| 392 |
+
"*" if row["fisher_p"] < 0.05 else "")
|
| 393 |
+
print(f" {row['rbp']:>10s} ({row['dataset']:>12s}): "
|
| 394 |
+
f"OR={row['fisher_or']:6.2f} p={row['fisher_p']:.3e} "
|
| 395 |
+
f"overlap={row['overlap_destab_up']:3d}/{row['n_predicted_destab']:3d} {sig}")
|
| 396 |
+
|
| 397 |
+
# Figure
|
| 398 |
+
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 399 |
+
|
| 400 |
+
rbps = results_df["rbp"].values
|
| 401 |
+
ors = results_df["fisher_or"].values
|
| 402 |
+
ps = results_df["fisher_p"].values
|
| 403 |
+
colors = ["red" if p < 0.05 else "gray" for p in ps]
|
| 404 |
+
|
| 405 |
+
y_pos = np.arange(len(rbps))
|
| 406 |
+
axes[0].barh(y_pos, np.log2(ors + 0.01), color=colors, edgecolor="black",
|
| 407 |
+
linewidth=0.5)
|
| 408 |
+
axes[0].set_yticks(y_pos)
|
| 409 |
+
axes[0].set_yticklabels([f"{r} ({d[:3]})" for r, d in
|
| 410 |
+
zip(rbps, results_df["dataset"])], fontsize=8)
|
| 411 |
+
axes[0].axvline(x=0, color="black", linestyle="-", linewidth=0.5)
|
| 412 |
+
axes[0].set_xlabel("log2(Odds Ratio)")
|
| 413 |
+
axes[0].set_title("scPTR Target Enrichment in\nPerturb-seq DE Genes")
|
| 414 |
+
|
| 415 |
+
axes[1].barh(y_pos, -np.log10(ps), color=colors, edgecolor="black",
|
| 416 |
+
linewidth=0.5)
|
| 417 |
+
axes[1].axvline(x=-np.log10(0.05), color="blue", linestyle="--",
|
| 418 |
+
alpha=0.5, label="p=0.05")
|
| 419 |
+
axes[1].set_yticks(y_pos)
|
| 420 |
+
axes[1].set_yticklabels([f"{r} ({d[:3]})" for r, d in
|
| 421 |
+
zip(rbps, results_df["dataset"])], fontsize=8)
|
| 422 |
+
axes[1].set_xlabel("-log10(p)")
|
| 423 |
+
axes[1].set_title("Significance of Enrichment")
|
| 424 |
+
axes[1].legend()
|
| 425 |
+
|
| 426 |
+
fig.tight_layout()
|
| 427 |
+
save_fig(fig, "perturbation_validation")
|
| 428 |
+
|
| 429 |
+
results_df.to_csv(res_dir / "perturbation_validation.csv", index=False)
|
| 430 |
+
|
| 431 |
+
summary = {
|
| 432 |
+
"n_tests": len(results_df),
|
| 433 |
+
"n_sig_005": int((results_df["fisher_p"] < 0.05).sum()),
|
| 434 |
+
"n_sig_fdr_010": int((results_df["fdr"] < 0.10).sum()),
|
| 435 |
+
"mean_or": float(results_df["fisher_or"].mean()),
|
| 436 |
+
"median_or": float(results_df["fisher_or"].median()),
|
| 437 |
+
}
|
| 438 |
+
with open(res_dir / "perturbation_summary.json", "w") as f:
|
| 439 |
+
json.dump(summary, f, indent=2)
|
| 440 |
+
else:
|
| 441 |
+
print(" No perturbation validation results obtained.")
|
| 442 |
+
|
| 443 |
+
print(f"\nResults saved to: {OUTPUT_DIR.resolve()}")
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
if __name__ == "__main__":
|
| 447 |
+
main()
|
analyses/run_scifate.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Validate scPTR gamma estimates against sci-fate metabolic labeling ground truth.
|
| 3 |
+
|
| 4 |
+
sci-fate (Cao et al. 2020, Nature Biotechnology) provides both total and newly
|
| 5 |
+
synthesized mRNA counts per cell via 4sU metabolic labeling. This allows us to
|
| 6 |
+
compute ground-truth degradation rates and compare them against scPTR's gamma
|
| 7 |
+
estimates from splicing kinetics alone.
|
| 8 |
+
|
| 9 |
+
Key idea:
|
| 10 |
+
- old RNA = total - new (pre-existing mRNA)
|
| 11 |
+
- degradation_rate ~ new / old (high ratio = fast turnover)
|
| 12 |
+
- We expect: genes with high scPTR gamma should have high new/old ratio
|
| 13 |
+
|
| 14 |
+
Data: A549 cells treated with dexamethasone (0-10h), GEO GSE131351.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import gzip
|
| 20 |
+
import json
|
| 21 |
+
import sys
|
| 22 |
+
from io import BytesIO
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import matplotlib
|
| 26 |
+
matplotlib.use("Agg")
|
| 27 |
+
import matplotlib.pyplot as plt
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pandas as pd
|
| 30 |
+
import scanpy as sc
|
| 31 |
+
from scipy import stats
|
| 32 |
+
from scipy.io import mmread
|
| 33 |
+
from scipy.sparse import csc_matrix
|
| 34 |
+
|
| 35 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 36 |
+
from _common import set_figure_style
|
| 37 |
+
|
| 38 |
+
import scptr
|
| 39 |
+
|
| 40 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "scifate_validation"
|
| 41 |
+
CACHE_DIR = Path.home() / ".cache" / "scptr" / "scifate"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def save_fig(fig, name, subdir="figures"):
|
| 45 |
+
"""Save a matplotlib figure to output dir."""
|
| 46 |
+
if fig is None:
|
| 47 |
+
print(f" [WARNING] {name}: plot returned None, skipping save")
|
| 48 |
+
return
|
| 49 |
+
out_dir = OUTPUT_DIR / subdir
|
| 50 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
path = out_dir / f"{name}.png"
|
| 52 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 53 |
+
plt.close(fig)
|
| 54 |
+
print(f" Saved: {path}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_scifate_data():
|
| 58 |
+
"""Load sci-fate data from GEO-downloaded files.
|
| 59 |
+
|
| 60 |
+
Returns AnnData with:
|
| 61 |
+
- X: total gene counts (sparse)
|
| 62 |
+
- layers['new']: newly synthesized counts (sparse)
|
| 63 |
+
- obs: cell annotations (treatment_time, etc.)
|
| 64 |
+
- var: gene annotations (gene_id, gene_short_name)
|
| 65 |
+
"""
|
| 66 |
+
print("Loading sci-fate data from GEO files...")
|
| 67 |
+
|
| 68 |
+
# Load cell annotations
|
| 69 |
+
cell_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_cell_annotate.txt.gz",
|
| 70 |
+
compression="gzip")
|
| 71 |
+
print(f" Cells: {len(cell_ann)}")
|
| 72 |
+
|
| 73 |
+
# Load gene annotations
|
| 74 |
+
gene_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_gene_annotate.txt.gz",
|
| 75 |
+
compression="gzip")
|
| 76 |
+
print(f" Genes: {len(gene_ann)}")
|
| 77 |
+
|
| 78 |
+
# Load total count matrix (MatrixMarket format, gzipped)
|
| 79 |
+
print(" Loading total count matrix...")
|
| 80 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count.txt.gz", 'rb') as f:
|
| 81 |
+
total_mat = mmread(f) # genes x cells
|
| 82 |
+
total_mat = csc_matrix(total_mat).T # -> cells x genes
|
| 83 |
+
|
| 84 |
+
# Load newly synthesized count matrix
|
| 85 |
+
print(" Loading newly synthesized count matrix...")
|
| 86 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count_newly_synthesised.txt.gz", 'rb') as f:
|
| 87 |
+
new_mat = mmread(f) # genes x cells
|
| 88 |
+
new_mat = csc_matrix(new_mat).T # -> cells x genes
|
| 89 |
+
|
| 90 |
+
print(f" Total matrix: {total_mat.shape}")
|
| 91 |
+
print(f" New matrix: {new_mat.shape}")
|
| 92 |
+
|
| 93 |
+
# Build AnnData
|
| 94 |
+
import anndata as ad
|
| 95 |
+
adata = ad.AnnData(
|
| 96 |
+
X=total_mat,
|
| 97 |
+
obs=cell_ann.set_index("sample"),
|
| 98 |
+
var=gene_ann.set_index("gene_id"),
|
| 99 |
+
)
|
| 100 |
+
adata.layers["new"] = new_mat
|
| 101 |
+
adata.var_names_make_unique()
|
| 102 |
+
|
| 103 |
+
# Use gene short names
|
| 104 |
+
adata.var["gene_id_full"] = adata.var_names.tolist()
|
| 105 |
+
adata.var_names = adata.var["gene_short_name"].values
|
| 106 |
+
adata.var_names_make_unique()
|
| 107 |
+
|
| 108 |
+
print(f" AnnData shape: {adata.shape}")
|
| 109 |
+
print(f" Treatment times: {adata.obs['treatment_time'].value_counts().to_dict()}")
|
| 110 |
+
|
| 111 |
+
return adata
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def compute_ground_truth_degradation(adata):
|
| 115 |
+
"""Compute per-gene ground-truth degradation rate from labeled/unlabeled RNA.
|
| 116 |
+
|
| 117 |
+
Ground truth: degradation_rate_proxy = mean(new) / mean(old)
|
| 118 |
+
where old = total - new.
|
| 119 |
+
|
| 120 |
+
Genes with high turnover have high new/old ratio.
|
| 121 |
+
"""
|
| 122 |
+
total = np.asarray(adata.X.toarray() if hasattr(adata.X, 'toarray') else adata.X)
|
| 123 |
+
new = np.asarray(adata.layers["new"].toarray() if hasattr(adata.layers["new"], 'toarray') else adata.layers["new"])
|
| 124 |
+
old = total - new
|
| 125 |
+
|
| 126 |
+
# Per-gene: mean across cells
|
| 127 |
+
mean_new = new.mean(axis=0)
|
| 128 |
+
mean_old = old.mean(axis=0)
|
| 129 |
+
mean_total = total.mean(axis=0)
|
| 130 |
+
|
| 131 |
+
# Degradation rate proxy: new/old ratio (high = fast turnover)
|
| 132 |
+
# Only for genes with sufficient expression
|
| 133 |
+
min_expr = 0.5 # minimum mean total expression
|
| 134 |
+
reliable = (mean_total >= min_expr) & (mean_old > 0.1)
|
| 135 |
+
|
| 136 |
+
deg_rate = np.full(adata.n_vars, np.nan)
|
| 137 |
+
deg_rate[reliable] = mean_new[reliable] / mean_old[reliable]
|
| 138 |
+
|
| 139 |
+
# Also compute fraction-new (new/total), another degradation proxy
|
| 140 |
+
frac_new = np.full(adata.n_vars, np.nan)
|
| 141 |
+
frac_new[reliable] = mean_new[reliable] / mean_total[reliable]
|
| 142 |
+
|
| 143 |
+
result = pd.DataFrame({
|
| 144 |
+
"gene": adata.var_names,
|
| 145 |
+
"mean_total": mean_total,
|
| 146 |
+
"mean_new": mean_new,
|
| 147 |
+
"mean_old": mean_old,
|
| 148 |
+
"new_old_ratio": deg_rate,
|
| 149 |
+
"frac_new": frac_new,
|
| 150 |
+
})
|
| 151 |
+
return result
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def prepare_for_scptr(adata_scifate):
|
| 155 |
+
"""Prepare sci-fate data for scPTR pipeline.
|
| 156 |
+
|
| 157 |
+
sci-fate doesn't have unspliced/spliced layers from velocity-style
|
| 158 |
+
preprocessing. Instead, we use:
|
| 159 |
+
- spliced = old RNA (pre-existing, ~steady-state pool)
|
| 160 |
+
- unspliced = new RNA (recently transcribed, proxy for nascent)
|
| 161 |
+
|
| 162 |
+
This mapping makes biological sense: newly synthesized RNA is analogous
|
| 163 |
+
to the unspliced pool (recently produced), while old RNA represents the
|
| 164 |
+
mature steady-state pool (analogous to spliced).
|
| 165 |
+
"""
|
| 166 |
+
import anndata as ad
|
| 167 |
+
|
| 168 |
+
total = adata_scifate.X.toarray() if hasattr(adata_scifate.X, 'toarray') else np.asarray(adata_scifate.X)
|
| 169 |
+
new = adata_scifate.layers["new"].toarray() if hasattr(adata_scifate.layers["new"], 'toarray') else np.asarray(adata_scifate.layers["new"])
|
| 170 |
+
old = total - new
|
| 171 |
+
|
| 172 |
+
# Filter to protein-coding genes with sufficient expression
|
| 173 |
+
mean_total = total.mean(axis=0)
|
| 174 |
+
keep = mean_total >= 0.5 # min mean expression
|
| 175 |
+
if "gene_type" in adata_scifate.var.columns:
|
| 176 |
+
is_pc = adata_scifate.var["gene_type"] == "protein_coding"
|
| 177 |
+
keep = keep & is_pc.values
|
| 178 |
+
|
| 179 |
+
adata = ad.AnnData(
|
| 180 |
+
X=total[:, keep].astype(np.float32),
|
| 181 |
+
obs=adata_scifate.obs.copy(),
|
| 182 |
+
var=adata_scifate.var.iloc[keep].copy(),
|
| 183 |
+
)
|
| 184 |
+
# Map: unspliced=new, spliced=old
|
| 185 |
+
adata.layers["unspliced"] = new[:, keep].astype(np.float32)
|
| 186 |
+
adata.layers["spliced"] = old[:, keep].astype(np.float32)
|
| 187 |
+
|
| 188 |
+
print(f" Prepared AnnData: {adata.shape}")
|
| 189 |
+
print(f" Protein-coding genes with mean expr >= 0.5: {keep.sum()}")
|
| 190 |
+
return adata
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def main():
|
| 194 |
+
set_figure_style()
|
| 195 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 196 |
+
res_dir = OUTPUT_DIR / "results"
|
| 197 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 198 |
+
|
| 199 |
+
# =========================================================================
|
| 200 |
+
# LOAD SCI-FATE DATA
|
| 201 |
+
# =========================================================================
|
| 202 |
+
print("=" * 60)
|
| 203 |
+
print("LOADING SCI-FATE DATA")
|
| 204 |
+
print("=" * 60)
|
| 205 |
+
adata_raw = load_scifate_data()
|
| 206 |
+
|
| 207 |
+
# =========================================================================
|
| 208 |
+
# GROUND TRUTH DEGRADATION RATES
|
| 209 |
+
# =========================================================================
|
| 210 |
+
print("\n" + "=" * 60)
|
| 211 |
+
print("COMPUTING GROUND TRUTH DEGRADATION RATES")
|
| 212 |
+
print("=" * 60)
|
| 213 |
+
|
| 214 |
+
gt = compute_ground_truth_degradation(adata_raw)
|
| 215 |
+
n_reliable = gt["new_old_ratio"].notna().sum()
|
| 216 |
+
print(f" Reliable genes: {n_reliable} / {len(gt)}")
|
| 217 |
+
print(f" New/old ratio: median={gt['new_old_ratio'].median():.4f}, "
|
| 218 |
+
f"mean={gt['new_old_ratio'].mean():.4f}")
|
| 219 |
+
print(f" Frac new: median={gt['frac_new'].median():.4f}")
|
| 220 |
+
|
| 221 |
+
gt.to_csv(res_dir / "ground_truth_degradation.csv", index=False)
|
| 222 |
+
|
| 223 |
+
# =========================================================================
|
| 224 |
+
# PER-TIMEPOINT ANALYSIS
|
| 225 |
+
# =========================================================================
|
| 226 |
+
print("\n" + "=" * 60)
|
| 227 |
+
print("PER-TIMEPOINT GROUND TRUTH")
|
| 228 |
+
print("=" * 60)
|
| 229 |
+
|
| 230 |
+
timepoints = sorted(adata_raw.obs["treatment_time"].unique())
|
| 231 |
+
gt_by_time = {}
|
| 232 |
+
for tp in timepoints:
|
| 233 |
+
mask = adata_raw.obs["treatment_time"] == tp
|
| 234 |
+
sub = adata_raw[mask].copy()
|
| 235 |
+
gt_tp = compute_ground_truth_degradation(sub)
|
| 236 |
+
gt_by_time[tp] = gt_tp
|
| 237 |
+
n_rel = gt_tp["new_old_ratio"].notna().sum()
|
| 238 |
+
med_ratio = gt_tp["new_old_ratio"].median()
|
| 239 |
+
print(f" {tp}: {mask.sum()} cells, {n_rel} reliable genes, "
|
| 240 |
+
f"median new/old ratio = {med_ratio:.4f}")
|
| 241 |
+
|
| 242 |
+
# Check consistency across timepoints
|
| 243 |
+
print("\n--- Cross-timepoint consistency ---")
|
| 244 |
+
tp_list = list(gt_by_time.keys())
|
| 245 |
+
for i in range(len(tp_list)):
|
| 246 |
+
for j in range(i + 1, len(tp_list)):
|
| 247 |
+
a = gt_by_time[tp_list[i]].set_index("gene")
|
| 248 |
+
b = gt_by_time[tp_list[j]].set_index("gene")
|
| 249 |
+
shared = a.index.intersection(b.index)
|
| 250 |
+
va = a.loc[shared, "new_old_ratio"].values
|
| 251 |
+
vb = b.loc[shared, "new_old_ratio"].values
|
| 252 |
+
valid = np.isfinite(va) & np.isfinite(vb)
|
| 253 |
+
if valid.sum() > 10:
|
| 254 |
+
r, p = stats.spearmanr(va[valid], vb[valid])
|
| 255 |
+
print(f" {tp_list[i]} vs {tp_list[j]}: Spearman r = {r:.4f} (n={valid.sum()})")
|
| 256 |
+
|
| 257 |
+
# =========================================================================
|
| 258 |
+
# RUN SCPTR PIPELINE
|
| 259 |
+
# =========================================================================
|
| 260 |
+
print("\n" + "=" * 60)
|
| 261 |
+
print("RUNNING SCPTR PIPELINE ON SCI-FATE DATA")
|
| 262 |
+
print("=" * 60)
|
| 263 |
+
|
| 264 |
+
adata = prepare_for_scptr(adata_raw)
|
| 265 |
+
|
| 266 |
+
# Preprocessing
|
| 267 |
+
scptr.pp.filter_genes(adata)
|
| 268 |
+
print(f" After gene filtering: {adata.shape}")
|
| 269 |
+
|
| 270 |
+
scptr.pp.normalize_layers(adata)
|
| 271 |
+
print(" Normalized layers")
|
| 272 |
+
|
| 273 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 274 |
+
print(" Built kNN graph")
|
| 275 |
+
|
| 276 |
+
scptr.pp.smooth_layers(adata)
|
| 277 |
+
print(" Smoothed layers")
|
| 278 |
+
|
| 279 |
+
# Core analysis
|
| 280 |
+
scptr.tl.estimate_beta(adata)
|
| 281 |
+
beta = adata.var["beta"].values
|
| 282 |
+
print(f" Beta: median={np.median(beta):.4f}, max={np.max(beta):.4f}")
|
| 283 |
+
|
| 284 |
+
scptr.tl.estimate_gamma(adata)
|
| 285 |
+
gamma = adata.layers["gamma"]
|
| 286 |
+
gamma_med = np.median(gamma, axis=0)
|
| 287 |
+
print(f" Gamma: shape={gamma.shape}, median of medians={np.median(gamma_med):.4f}")
|
| 288 |
+
print(f" Gamma max: {np.max(gamma):.4f}")
|
| 289 |
+
|
| 290 |
+
# =========================================================================
|
| 291 |
+
# CORRELATION: SCPTR GAMMA vs GROUND TRUTH
|
| 292 |
+
# =========================================================================
|
| 293 |
+
print("\n" + "=" * 60)
|
| 294 |
+
print("SCPTR GAMMA vs GROUND TRUTH DEGRADATION RATES")
|
| 295 |
+
print("=" * 60)
|
| 296 |
+
print(" NOTE: Since gamma = beta * unspliced/spliced and we map")
|
| 297 |
+
print(" new→unspliced, old→spliced, the gamma-vs-new/old correlation")
|
| 298 |
+
print(" is partially tautological. The independent validation is the")
|
| 299 |
+
print(" correlation with published half-lives (Schofield 2018).")
|
| 300 |
+
|
| 301 |
+
# Build gene-level comparison
|
| 302 |
+
gamma_series = pd.Series(gamma_med, index=adata.var_names)
|
| 303 |
+
gt_indexed = gt.set_index("gene")
|
| 304 |
+
|
| 305 |
+
shared = gamma_series.index.intersection(gt_indexed.index)
|
| 306 |
+
print(f" Shared genes: {len(shared)}")
|
| 307 |
+
|
| 308 |
+
g = gamma_series[shared].values.astype(float)
|
| 309 |
+
gt_ratio = gt_indexed.loc[shared, "new_old_ratio"].values.astype(float)
|
| 310 |
+
gt_frac = gt_indexed.loc[shared, "frac_new"].values.astype(float)
|
| 311 |
+
|
| 312 |
+
# Filter: need both values finite and positive
|
| 313 |
+
valid_ratio = np.isfinite(g) & np.isfinite(gt_ratio) & (g > 0) & (gt_ratio > 0)
|
| 314 |
+
valid_frac = np.isfinite(g) & np.isfinite(gt_frac) & (g > 0) & (gt_frac > 0)
|
| 315 |
+
|
| 316 |
+
results = {}
|
| 317 |
+
|
| 318 |
+
# Correlation with new/old ratio
|
| 319 |
+
if valid_ratio.sum() > 10:
|
| 320 |
+
g_r = g[valid_ratio]
|
| 321 |
+
gt_r = gt_ratio[valid_ratio]
|
| 322 |
+
sp_r, sp_p = stats.spearmanr(g_r, gt_r)
|
| 323 |
+
pe_r, pe_p = stats.pearsonr(np.log1p(g_r), np.log1p(gt_r))
|
| 324 |
+
print(f"\n vs new/old ratio (n={valid_ratio.sum()}):")
|
| 325 |
+
print(f" Spearman r = {sp_r:.4f} (p = {sp_p:.2e})")
|
| 326 |
+
print(f" Pearson r = {pe_r:.4f} (p = {pe_p:.2e}) [log-space]")
|
| 327 |
+
results["new_old_ratio"] = {
|
| 328 |
+
"spearman_r": float(sp_r), "spearman_p": float(sp_p),
|
| 329 |
+
"pearson_r": float(pe_r), "pearson_p": float(pe_p),
|
| 330 |
+
"n_genes": int(valid_ratio.sum()),
|
| 331 |
+
}
|
| 332 |
+
else:
|
| 333 |
+
print(" Not enough shared genes for new/old ratio correlation.")
|
| 334 |
+
results["new_old_ratio"] = {"n_genes": int(valid_ratio.sum())}
|
| 335 |
+
|
| 336 |
+
# Correlation with fraction new
|
| 337 |
+
if valid_frac.sum() > 10:
|
| 338 |
+
g_f = g[valid_frac]
|
| 339 |
+
gt_f = gt_frac[valid_frac]
|
| 340 |
+
sp_r, sp_p = stats.spearmanr(g_f, gt_f)
|
| 341 |
+
pe_r, pe_p = stats.pearsonr(np.log1p(g_f), np.log1p(gt_f))
|
| 342 |
+
print(f"\n vs fraction new (n={valid_frac.sum()}):")
|
| 343 |
+
print(f" Spearman r = {sp_r:.4f} (p = {sp_p:.2e})")
|
| 344 |
+
print(f" Pearson r = {pe_r:.4f} (p = {pe_p:.2e}) [log-space]")
|
| 345 |
+
results["frac_new"] = {
|
| 346 |
+
"spearman_r": float(sp_r), "spearman_p": float(sp_p),
|
| 347 |
+
"pearson_r": float(pe_r), "pearson_p": float(pe_p),
|
| 348 |
+
"n_genes": int(valid_frac.sum()),
|
| 349 |
+
}
|
| 350 |
+
else:
|
| 351 |
+
print(" Not enough shared genes for fraction new correlation.")
|
| 352 |
+
results["frac_new"] = {"n_genes": int(valid_frac.sum())}
|
| 353 |
+
|
| 354 |
+
# =========================================================================
|
| 355 |
+
# INDEPENDENT VALIDATION: PUBLISHED HALF-LIVES (not tautological)
|
| 356 |
+
# =========================================================================
|
| 357 |
+
print("\n--- Independent validation: published half-life correlations ---")
|
| 358 |
+
print(" (This is the key result — fully independent ground truth)")
|
| 359 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 360 |
+
corr_human = scptr.benchmark.correlate_with_halflives(adata, hl_human)
|
| 361 |
+
print(f" Human half-lives (Schofield 2018): Spearman r = {corr_human['spearman_r']:.4f} "
|
| 362 |
+
f"(p={corr_human['spearman_p']:.2e}, n={corr_human['n_genes']})")
|
| 363 |
+
results["halflife_human"] = {
|
| 364 |
+
k: v for k, v in corr_human.items() if k != "matched_genes"
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
hl_mouse = scptr.datasets.herzog2017_halflives()
|
| 368 |
+
corr_mouse = scptr.benchmark.correlate_with_halflives(adata, hl_mouse)
|
| 369 |
+
print(f" Mouse half-lives (Herzog 2017): Spearman r = {corr_mouse['spearman_r']:.4f} "
|
| 370 |
+
f"(p={corr_mouse['spearman_p']:.2e}, n={corr_mouse['n_genes']})")
|
| 371 |
+
results["halflife_mouse"] = {
|
| 372 |
+
k: v for k, v in corr_mouse.items() if k != "matched_genes"
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
with open(res_dir / "scifate_validation.json", "w") as f:
|
| 376 |
+
json.dump(results, f, indent=2)
|
| 377 |
+
|
| 378 |
+
# =========================================================================
|
| 379 |
+
# SCATTER PLOTS
|
| 380 |
+
# =========================================================================
|
| 381 |
+
print("\n" + "=" * 60)
|
| 382 |
+
print("GENERATING FIGURES")
|
| 383 |
+
print("=" * 60)
|
| 384 |
+
|
| 385 |
+
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
|
| 386 |
+
|
| 387 |
+
# Panel 1: gamma vs new/old ratio
|
| 388 |
+
if valid_ratio.sum() > 10:
|
| 389 |
+
g_r = g[valid_ratio]
|
| 390 |
+
gt_r = gt_ratio[valid_ratio]
|
| 391 |
+
axes[0].scatter(gt_r, g_r, alpha=0.15, s=8, c="steelblue")
|
| 392 |
+
axes[0].set_xscale("log")
|
| 393 |
+
axes[0].set_yscale("log")
|
| 394 |
+
axes[0].set_xlabel("Ground truth: new/old RNA ratio")
|
| 395 |
+
axes[0].set_ylabel("scPTR median gamma")
|
| 396 |
+
sp_r = results["new_old_ratio"]["spearman_r"]
|
| 397 |
+
sp_p = results["new_old_ratio"]["spearman_p"]
|
| 398 |
+
axes[0].set_title(f"vs New/Old ratio\n(Spearman r={sp_r:.3f}, p={sp_p:.1e})")
|
| 399 |
+
|
| 400 |
+
# Panel 2: gamma vs fraction new
|
| 401 |
+
if valid_frac.sum() > 10:
|
| 402 |
+
g_f = g[valid_frac]
|
| 403 |
+
gt_f = gt_frac[valid_frac]
|
| 404 |
+
axes[1].scatter(gt_f, g_f, alpha=0.15, s=8, c="darkorange")
|
| 405 |
+
axes[1].set_xscale("log")
|
| 406 |
+
axes[1].set_yscale("log")
|
| 407 |
+
axes[1].set_xlabel("Ground truth: fraction new RNA")
|
| 408 |
+
axes[1].set_ylabel("scPTR median gamma")
|
| 409 |
+
sp_r = results["frac_new"]["spearman_r"]
|
| 410 |
+
sp_p = results["frac_new"]["spearman_p"]
|
| 411 |
+
axes[1].set_title(f"vs Fraction new\n(Spearman r={sp_r:.3f}, p={sp_p:.1e})")
|
| 412 |
+
|
| 413 |
+
# Panel 3: Distribution comparison
|
| 414 |
+
ax3 = axes[2]
|
| 415 |
+
# Log-transform and z-score both, show rank correlation
|
| 416 |
+
if valid_ratio.sum() > 10:
|
| 417 |
+
g_log = np.log1p(g[valid_ratio])
|
| 418 |
+
gt_log = np.log1p(gt_ratio[valid_ratio])
|
| 419 |
+
# Rank both
|
| 420 |
+
g_rank = stats.rankdata(g_log)
|
| 421 |
+
gt_rank = stats.rankdata(gt_log)
|
| 422 |
+
ax3.scatter(gt_rank / len(gt_rank), g_rank / len(g_rank),
|
| 423 |
+
alpha=0.1, s=5, c="purple")
|
| 424 |
+
ax3.plot([0, 1], [0, 1], "k--", alpha=0.3, lw=1)
|
| 425 |
+
ax3.set_xlabel("Ground truth rank (fractional)")
|
| 426 |
+
ax3.set_ylabel("scPTR gamma rank (fractional)")
|
| 427 |
+
ax3.set_title("Rank-rank plot")
|
| 428 |
+
|
| 429 |
+
fig.suptitle("sci-fate Validation: scPTR Gamma vs Ground Truth Degradation",
|
| 430 |
+
fontsize=13, y=1.02)
|
| 431 |
+
fig.tight_layout()
|
| 432 |
+
save_fig(fig, "scifate_gamma_vs_ground_truth")
|
| 433 |
+
|
| 434 |
+
# =========================================================================
|
| 435 |
+
# PER-TIMEPOINT VALIDATION
|
| 436 |
+
# =========================================================================
|
| 437 |
+
print("\n" + "=" * 60)
|
| 438 |
+
print("PER-TIMEPOINT VALIDATION")
|
| 439 |
+
print("=" * 60)
|
| 440 |
+
|
| 441 |
+
tp_results = {}
|
| 442 |
+
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
|
| 443 |
+
axes = axes.flatten()
|
| 444 |
+
|
| 445 |
+
for idx, tp in enumerate(timepoints):
|
| 446 |
+
gt_tp = gt_by_time[tp].set_index("gene")
|
| 447 |
+
shared_tp = gamma_series.index.intersection(gt_tp.index)
|
| 448 |
+
g_tp = gamma_series[shared_tp].values.astype(float)
|
| 449 |
+
gt_tp_ratio = gt_tp.loc[shared_tp, "new_old_ratio"].values.astype(float)
|
| 450 |
+
valid = np.isfinite(g_tp) & np.isfinite(gt_tp_ratio) & (g_tp > 0) & (gt_tp_ratio > 0)
|
| 451 |
+
|
| 452 |
+
if valid.sum() > 10:
|
| 453 |
+
sp_r, sp_p = stats.spearmanr(g_tp[valid], gt_tp_ratio[valid])
|
| 454 |
+
print(f" {tp}: Spearman r = {sp_r:.4f} (n={valid.sum()})")
|
| 455 |
+
tp_results[tp] = {"spearman_r": float(sp_r), "spearman_p": float(sp_p),
|
| 456 |
+
"n_genes": int(valid.sum())}
|
| 457 |
+
|
| 458 |
+
if idx < len(axes):
|
| 459 |
+
axes[idx].scatter(gt_tp_ratio[valid], g_tp[valid],
|
| 460 |
+
alpha=0.1, s=5, c="steelblue")
|
| 461 |
+
axes[idx].set_xscale("log")
|
| 462 |
+
axes[idx].set_yscale("log")
|
| 463 |
+
axes[idx].set_xlabel("New/old ratio")
|
| 464 |
+
axes[idx].set_ylabel("scPTR gamma")
|
| 465 |
+
axes[idx].set_title(f"DEX {tp} (r={sp_r:.3f}, n={valid.sum()})")
|
| 466 |
+
else:
|
| 467 |
+
print(f" {tp}: Not enough genes ({valid.sum()})")
|
| 468 |
+
|
| 469 |
+
# Remove unused axes
|
| 470 |
+
for idx in range(len(timepoints), len(axes)):
|
| 471 |
+
axes[idx].set_visible(False)
|
| 472 |
+
|
| 473 |
+
fig.suptitle("sci-fate: Per-timepoint scPTR gamma vs ground truth",
|
| 474 |
+
fontsize=13, y=1.02)
|
| 475 |
+
fig.tight_layout()
|
| 476 |
+
save_fig(fig, "scifate_per_timepoint")
|
| 477 |
+
|
| 478 |
+
with open(res_dir / "scifate_per_timepoint.json", "w") as f:
|
| 479 |
+
json.dump(tp_results, f, indent=2)
|
| 480 |
+
|
| 481 |
+
# =========================================================================
|
| 482 |
+
# TOP/BOTTOM GENE ANALYSIS
|
| 483 |
+
# =========================================================================
|
| 484 |
+
print("\n" + "=" * 60)
|
| 485 |
+
print("TOP/BOTTOM GENE ANALYSIS")
|
| 486 |
+
print("=" * 60)
|
| 487 |
+
|
| 488 |
+
if valid_ratio.sum() > 100:
|
| 489 |
+
# Compare top/bottom gamma genes with ground truth ranking
|
| 490 |
+
gene_df = pd.DataFrame({
|
| 491 |
+
"gene": shared[valid_ratio],
|
| 492 |
+
"gamma": g[valid_ratio],
|
| 493 |
+
"new_old_ratio": gt_ratio[valid_ratio],
|
| 494 |
+
})
|
| 495 |
+
gene_df = gene_df.sort_values("gamma", ascending=False)
|
| 496 |
+
|
| 497 |
+
# Top 10% gamma genes
|
| 498 |
+
n10 = max(10, len(gene_df) // 10)
|
| 499 |
+
top_gamma = gene_df.head(n10)
|
| 500 |
+
bot_gamma = gene_df.tail(n10)
|
| 501 |
+
|
| 502 |
+
top_gt_med = top_gamma["new_old_ratio"].median()
|
| 503 |
+
bot_gt_med = bot_gamma["new_old_ratio"].median()
|
| 504 |
+
|
| 505 |
+
print(f"\n Top {n10} gamma genes: median new/old ratio = {top_gt_med:.4f}")
|
| 506 |
+
print(f" Bottom {n10} gamma genes: median new/old ratio = {bot_gt_med:.4f}")
|
| 507 |
+
print(f" Fold difference: {top_gt_med / bot_gt_med:.2f}x")
|
| 508 |
+
|
| 509 |
+
# Mann-Whitney test
|
| 510 |
+
u_stat, mw_p = stats.mannwhitneyu(
|
| 511 |
+
top_gamma["new_old_ratio"].values,
|
| 512 |
+
bot_gamma["new_old_ratio"].values,
|
| 513 |
+
alternative="greater"
|
| 514 |
+
)
|
| 515 |
+
print(f" Mann-Whitney p-value (top > bottom): {mw_p:.2e}")
|
| 516 |
+
|
| 517 |
+
results["top_bottom_analysis"] = {
|
| 518 |
+
"n_per_group": n10,
|
| 519 |
+
"top_gamma_median_gt": float(top_gt_med),
|
| 520 |
+
"bottom_gamma_median_gt": float(bot_gt_med),
|
| 521 |
+
"fold_difference": float(top_gt_med / bot_gt_med),
|
| 522 |
+
"mann_whitney_p": float(mw_p),
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
# Save updated results
|
| 526 |
+
with open(res_dir / "scifate_validation.json", "w") as f:
|
| 527 |
+
json.dump(results, f, indent=2)
|
| 528 |
+
|
| 529 |
+
# Boxplot
|
| 530 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 531 |
+
positions = [1, 2]
|
| 532 |
+
bp = ax.boxplot(
|
| 533 |
+
[top_gamma["new_old_ratio"].values, bot_gamma["new_old_ratio"].values],
|
| 534 |
+
positions=positions,
|
| 535 |
+
widths=0.6,
|
| 536 |
+
patch_artist=True,
|
| 537 |
+
)
|
| 538 |
+
bp["boxes"][0].set_facecolor("salmon")
|
| 539 |
+
bp["boxes"][1].set_facecolor("lightblue")
|
| 540 |
+
ax.set_xticks(positions)
|
| 541 |
+
ax.set_xticklabels([f"Top {n10}\n(high gamma)", f"Bottom {n10}\n(low gamma)"])
|
| 542 |
+
ax.set_ylabel("Ground truth: new/old RNA ratio")
|
| 543 |
+
ax.set_title(f"High-gamma genes have higher turnover\n"
|
| 544 |
+
f"(fold={top_gt_med/bot_gt_med:.1f}x, p={mw_p:.1e})")
|
| 545 |
+
fig.tight_layout()
|
| 546 |
+
save_fig(fig, "scifate_top_bottom_boxplot")
|
| 547 |
+
|
| 548 |
+
# =========================================================================
|
| 549 |
+
# SUMMARY
|
| 550 |
+
# =========================================================================
|
| 551 |
+
print("\n" + "=" * 60)
|
| 552 |
+
print("SUMMARY")
|
| 553 |
+
print("=" * 60)
|
| 554 |
+
print(f" Dataset: sci-fate A549 ({adata_raw.n_obs} cells, {adata_raw.n_vars} genes)")
|
| 555 |
+
print(f" scPTR pipeline: {adata.n_obs} cells, {adata.n_vars} genes")
|
| 556 |
+
if "new_old_ratio" in results and "spearman_r" in results["new_old_ratio"]:
|
| 557 |
+
print(f" Gamma vs new/old ratio: Spearman r = {results['new_old_ratio']['spearman_r']:.4f}")
|
| 558 |
+
if "frac_new" in results and "spearman_r" in results["frac_new"]:
|
| 559 |
+
print(f" Gamma vs frac new: Spearman r = {results['frac_new']['spearman_r']:.4f}")
|
| 560 |
+
if "halflife_human" in results:
|
| 561 |
+
print(f" Human half-life (INDEPENDENT): Spearman r = {results['halflife_human']['spearman_r']:.4f}")
|
| 562 |
+
if "halflife_mouse" in results and "spearman_r" in results["halflife_mouse"]:
|
| 563 |
+
print(f" Mouse half-life (INDEPENDENT): Spearman r = {results['halflife_mouse']['spearman_r']:.4f}")
|
| 564 |
+
if "top_bottom_analysis" in results:
|
| 565 |
+
tb = results["top_bottom_analysis"]
|
| 566 |
+
print(f" Top vs bottom gamma: {tb['fold_difference']:.1f}x fold diff (p={tb['mann_whitney_p']:.1e})")
|
| 567 |
+
print(f"\nAll results saved to: {OUTPUT_DIR.resolve()}")
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
if __name__ == "__main__":
|
| 571 |
+
main()
|
analyses/run_summary.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Cross-dataset validation summary: consolidate results from all datasets.
|
| 3 |
+
|
| 4 |
+
Runs the full scPTR pipeline on all 3 datasets and produces:
|
| 5 |
+
1. Cross-dataset consistency (pairwise gamma correlation)
|
| 6 |
+
2. Half-life validation across all datasets
|
| 7 |
+
3. ARE/NMD enrichment across datasets
|
| 8 |
+
4. Subsampling robustness across datasets
|
| 9 |
+
5. Summary table and comparison figures
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use("Agg")
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from scipy import stats
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 26 |
+
from _common import set_figure_style
|
| 27 |
+
|
| 28 |
+
import scptr
|
| 29 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 30 |
+
|
| 31 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "summary"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def save_fig(fig, name, subdir="figures"):
|
| 35 |
+
if fig is None:
|
| 36 |
+
return
|
| 37 |
+
out_dir = OUTPUT_DIR / subdir
|
| 38 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
path = out_dir / f"{name}.png"
|
| 40 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 41 |
+
plt.close(fig)
|
| 42 |
+
print(f" Saved: {path}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run_pipeline(adata, name, groupby=None):
|
| 46 |
+
"""Run standard scPTR pipeline on a dataset."""
|
| 47 |
+
print(f"\n--- Running pipeline on {name} ---")
|
| 48 |
+
print(f" Input: {adata.shape}")
|
| 49 |
+
|
| 50 |
+
scptr.pp.filter_genes(adata)
|
| 51 |
+
scptr.pp.normalize_layers(adata)
|
| 52 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 53 |
+
scptr.pp.smooth_layers(adata)
|
| 54 |
+
|
| 55 |
+
scptr.tl.estimate_beta(adata)
|
| 56 |
+
if groupby:
|
| 57 |
+
scptr.tl.estimate_beta(adata, groupby=groupby)
|
| 58 |
+
scptr.tl.estimate_gamma(adata)
|
| 59 |
+
scptr.tl.variance_decomposition(adata)
|
| 60 |
+
scptr.tl.pt_states(adata)
|
| 61 |
+
scptr.tl.pt_velocity(adata)
|
| 62 |
+
|
| 63 |
+
gamma = adata.layers["gamma"]
|
| 64 |
+
gamma_med = np.median(gamma, axis=0)
|
| 65 |
+
n_states = adata.obs["pt_state"].nunique()
|
| 66 |
+
print(f" After pipeline: {adata.shape}")
|
| 67 |
+
print(f" Gamma: median={np.median(gamma_med):.4f}, max={np.max(gamma):.2f}")
|
| 68 |
+
print(f" PT states: {n_states}")
|
| 69 |
+
return adata
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
set_figure_style()
|
| 74 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
res_dir = OUTPUT_DIR / "results"
|
| 76 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
# =========================================================================
|
| 79 |
+
# LOAD ALL DATASETS
|
| 80 |
+
# =========================================================================
|
| 81 |
+
print("=" * 60)
|
| 82 |
+
print("LOADING DATASETS")
|
| 83 |
+
print("=" * 60)
|
| 84 |
+
|
| 85 |
+
print("\n--- Pancreas ---")
|
| 86 |
+
adata_pan = scptr.datasets.pancreas()
|
| 87 |
+
print(f" Shape: {adata_pan.shape}")
|
| 88 |
+
|
| 89 |
+
print("\n--- Dentate Gyrus ---")
|
| 90 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 91 |
+
print(f" Shape: {adata_dg.shape}")
|
| 92 |
+
|
| 93 |
+
print("\n--- sci-fate A549 ---")
|
| 94 |
+
adata_sf_raw = load_scifate_data()
|
| 95 |
+
adata_sf = prepare_for_scptr(adata_sf_raw)
|
| 96 |
+
print(f" Shape: {adata_sf.shape}")
|
| 97 |
+
|
| 98 |
+
# =========================================================================
|
| 99 |
+
# RUN PIPELINES
|
| 100 |
+
# =========================================================================
|
| 101 |
+
print("\n" + "=" * 60)
|
| 102 |
+
print("RUNNING PIPELINES")
|
| 103 |
+
print("=" * 60)
|
| 104 |
+
|
| 105 |
+
adata_pan = run_pipeline(adata_pan, "pancreas", groupby="clusters")
|
| 106 |
+
adata_dg = run_pipeline(adata_dg, "dentate_gyrus", groupby="clusters")
|
| 107 |
+
adata_sf = run_pipeline(adata_sf, "scifate")
|
| 108 |
+
|
| 109 |
+
datasets = {
|
| 110 |
+
"pancreas": adata_pan,
|
| 111 |
+
"dentate_gyrus": adata_dg,
|
| 112 |
+
"scifate": adata_sf,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
# =========================================================================
|
| 116 |
+
# 1. CROSS-DATASET CONSISTENCY
|
| 117 |
+
# =========================================================================
|
| 118 |
+
print("\n" + "=" * 60)
|
| 119 |
+
print("1. CROSS-DATASET CONSISTENCY")
|
| 120 |
+
print("=" * 60)
|
| 121 |
+
|
| 122 |
+
consistency = scptr.benchmark.cross_dataset_consistency(datasets)
|
| 123 |
+
consistency.to_csv(res_dir / "cross_dataset_consistency.csv", index=False)
|
| 124 |
+
print(consistency.to_string(index=False))
|
| 125 |
+
|
| 126 |
+
# =========================================================================
|
| 127 |
+
# 2. HALF-LIFE VALIDATION
|
| 128 |
+
# =========================================================================
|
| 129 |
+
print("\n" + "=" * 60)
|
| 130 |
+
print("2. HALF-LIFE VALIDATION")
|
| 131 |
+
print("=" * 60)
|
| 132 |
+
|
| 133 |
+
hl_mouse = scptr.datasets.herzog2017_halflives()
|
| 134 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 135 |
+
|
| 136 |
+
hl_results = []
|
| 137 |
+
for name, adata in datasets.items():
|
| 138 |
+
for hl_name, hl_df in [("mouse_Herzog2017", hl_mouse),
|
| 139 |
+
("human_Schofield2018", hl_human)]:
|
| 140 |
+
corr = scptr.benchmark.correlate_with_halflives(adata, hl_df)
|
| 141 |
+
hl_results.append({
|
| 142 |
+
"dataset": name,
|
| 143 |
+
"reference": hl_name,
|
| 144 |
+
"spearman_r": corr["spearman_r"],
|
| 145 |
+
"spearman_p": corr["spearman_p"],
|
| 146 |
+
"pearson_r": corr["pearson_r"],
|
| 147 |
+
"n_genes": corr["n_genes"],
|
| 148 |
+
})
|
| 149 |
+
print(f" {name} vs {hl_name}: Spearman r = {corr['spearman_r']:.4f} "
|
| 150 |
+
f"(n={corr['n_genes']})")
|
| 151 |
+
|
| 152 |
+
hl_df_out = pd.DataFrame(hl_results)
|
| 153 |
+
hl_df_out.to_csv(res_dir / "halflife_correlations.csv", index=False)
|
| 154 |
+
|
| 155 |
+
# =========================================================================
|
| 156 |
+
# 3. ARE/NMD ENRICHMENT
|
| 157 |
+
# =========================================================================
|
| 158 |
+
print("\n" + "=" * 60)
|
| 159 |
+
print("3. ARE/NMD ENRICHMENT")
|
| 160 |
+
print("=" * 60)
|
| 161 |
+
|
| 162 |
+
enrichment_results = []
|
| 163 |
+
for name, adata in datasets.items():
|
| 164 |
+
are = scptr.benchmark.are_enrichment(adata)
|
| 165 |
+
nmd = scptr.benchmark.nmd_enrichment(adata)
|
| 166 |
+
enrichment_results.append({
|
| 167 |
+
"dataset": name,
|
| 168 |
+
"test": "ARE",
|
| 169 |
+
"n_in_set": are["n_genes_in_set"],
|
| 170 |
+
"U_statistic": are["U_statistic"],
|
| 171 |
+
"p_value": are["p_value"],
|
| 172 |
+
})
|
| 173 |
+
enrichment_results.append({
|
| 174 |
+
"dataset": name,
|
| 175 |
+
"test": "NMD",
|
| 176 |
+
"n_in_set": nmd["n_genes_in_set"],
|
| 177 |
+
"U_statistic": nmd["U_statistic"],
|
| 178 |
+
"p_value": nmd["p_value"],
|
| 179 |
+
})
|
| 180 |
+
print(f" {name}: ARE p={are['p_value']:.4f} (n={are['n_genes_in_set']}), "
|
| 181 |
+
f"NMD p={nmd['p_value']:.4f} (n={nmd['n_genes_in_set']})")
|
| 182 |
+
|
| 183 |
+
enr_df = pd.DataFrame(enrichment_results)
|
| 184 |
+
enr_df.to_csv(res_dir / "enrichment_results.csv", index=False)
|
| 185 |
+
|
| 186 |
+
# =========================================================================
|
| 187 |
+
# 4. SUBSAMPLING ROBUSTNESS
|
| 188 |
+
# =========================================================================
|
| 189 |
+
print("\n" + "=" * 60)
|
| 190 |
+
print("4. SUBSAMPLING ROBUSTNESS")
|
| 191 |
+
print("=" * 60)
|
| 192 |
+
|
| 193 |
+
fractions = [0.2, 0.4, 0.6, 0.8, 0.9]
|
| 194 |
+
robustness_results = []
|
| 195 |
+
for name, adata in datasets.items():
|
| 196 |
+
print(f"\n {name}:")
|
| 197 |
+
robust = scptr.benchmark.subsampling_robustness(
|
| 198 |
+
adata, fractions=fractions, n_repeats=3
|
| 199 |
+
)
|
| 200 |
+
robust["dataset"] = name
|
| 201 |
+
robustness_results.append(robust)
|
| 202 |
+
for frac in fractions:
|
| 203 |
+
sub = robust[robust["fraction"] == frac]
|
| 204 |
+
print(f" {frac:.0%}: mean Spearman r = {sub['spearman_r'].mean():.4f}")
|
| 205 |
+
|
| 206 |
+
robust_all = pd.concat(robustness_results, ignore_index=True)
|
| 207 |
+
robust_all.to_csv(res_dir / "subsampling_robustness.csv", index=False)
|
| 208 |
+
|
| 209 |
+
# =========================================================================
|
| 210 |
+
# 5. DATASET STATISTICS
|
| 211 |
+
# =========================================================================
|
| 212 |
+
print("\n" + "=" * 60)
|
| 213 |
+
print("5. DATASET STATISTICS")
|
| 214 |
+
print("=" * 60)
|
| 215 |
+
|
| 216 |
+
dataset_stats = []
|
| 217 |
+
for name, adata in datasets.items():
|
| 218 |
+
gamma = adata.layers["gamma"]
|
| 219 |
+
gamma_med = np.median(gamma, axis=0)
|
| 220 |
+
n_states = adata.obs["pt_state"].nunique()
|
| 221 |
+
tf_scores = adata.var["tf_score"].values
|
| 222 |
+
|
| 223 |
+
dataset_stats.append({
|
| 224 |
+
"dataset": name,
|
| 225 |
+
"n_cells": adata.n_obs,
|
| 226 |
+
"n_genes": adata.n_vars,
|
| 227 |
+
"beta_median": float(np.median(adata.var["beta"])),
|
| 228 |
+
"gamma_median_of_medians": float(np.median(gamma_med)),
|
| 229 |
+
"gamma_max": float(np.max(gamma)),
|
| 230 |
+
"n_pt_states": n_states,
|
| 231 |
+
"tf_score_median": float(np.median(tf_scores)),
|
| 232 |
+
"tf_score_gt_0.5": int(np.sum(tf_scores > 0.5)),
|
| 233 |
+
})
|
| 234 |
+
print(f" {name}: {adata.n_obs} cells, {adata.n_vars} genes, "
|
| 235 |
+
f"{n_states} PT states")
|
| 236 |
+
|
| 237 |
+
stats_df = pd.DataFrame(dataset_stats)
|
| 238 |
+
stats_df.to_csv(res_dir / "dataset_statistics.csv", index=False)
|
| 239 |
+
|
| 240 |
+
# =========================================================================
|
| 241 |
+
# FIGURES
|
| 242 |
+
# =========================================================================
|
| 243 |
+
print("\n" + "=" * 60)
|
| 244 |
+
print("GENERATING SUMMARY FIGURES")
|
| 245 |
+
print("=" * 60)
|
| 246 |
+
|
| 247 |
+
# Figure 1: Half-life correlation comparison bar chart
|
| 248 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 249 |
+
hl_pivot = hl_df_out.pivot(index="dataset", columns="reference",
|
| 250 |
+
values="spearman_r")
|
| 251 |
+
x = np.arange(len(hl_pivot))
|
| 252 |
+
width = 0.35
|
| 253 |
+
bars1 = ax.bar(x - width/2, hl_pivot["mouse_Herzog2017"].values,
|
| 254 |
+
width, label="Mouse (Herzog 2017)", color="steelblue")
|
| 255 |
+
bars2 = ax.bar(x + width/2, hl_pivot["human_Schofield2018"].values,
|
| 256 |
+
width, label="Human (Schofield 2018)", color="darkorange")
|
| 257 |
+
ax.set_xlabel("Dataset")
|
| 258 |
+
ax.set_ylabel("Spearman correlation with half-lives")
|
| 259 |
+
ax.set_title("Half-life Validation Across Datasets")
|
| 260 |
+
ax.set_xticks(x)
|
| 261 |
+
ax.set_xticklabels(hl_pivot.index)
|
| 262 |
+
ax.legend()
|
| 263 |
+
ax.axhline(y=0, color="gray", linewidth=0.5)
|
| 264 |
+
# Add value labels
|
| 265 |
+
for bars in [bars1, bars2]:
|
| 266 |
+
for bar in bars:
|
| 267 |
+
h = bar.get_height()
|
| 268 |
+
ax.text(bar.get_x() + bar.get_width()/2, h,
|
| 269 |
+
f"{h:.3f}", ha="center", va="bottom" if h > 0 else "top",
|
| 270 |
+
fontsize=8)
|
| 271 |
+
fig.tight_layout()
|
| 272 |
+
save_fig(fig, "halflife_comparison")
|
| 273 |
+
|
| 274 |
+
# Figure 2: Robustness curves
|
| 275 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 276 |
+
colors = {"pancreas": "steelblue", "dentate_gyrus": "darkorange",
|
| 277 |
+
"scifate": "forestgreen"}
|
| 278 |
+
for name in datasets:
|
| 279 |
+
sub = robust_all[robust_all["dataset"] == name]
|
| 280 |
+
means = sub.groupby("fraction")["spearman_r"].mean()
|
| 281 |
+
stds = sub.groupby("fraction")["spearman_r"].std()
|
| 282 |
+
ax.errorbar(means.index, means.values, yerr=stds.values,
|
| 283 |
+
marker="o", label=name, color=colors.get(name, "gray"),
|
| 284 |
+
capsize=3)
|
| 285 |
+
ax.set_xlabel("Subsampling fraction")
|
| 286 |
+
ax.set_ylabel("Spearman r with full-data gamma")
|
| 287 |
+
ax.set_title("Subsampling Robustness Across Datasets")
|
| 288 |
+
ax.legend()
|
| 289 |
+
ax.set_ylim(0, 1.05)
|
| 290 |
+
fig.tight_layout()
|
| 291 |
+
save_fig(fig, "robustness_curves")
|
| 292 |
+
|
| 293 |
+
# Figure 3: Cross-dataset consistency heatmap
|
| 294 |
+
ds_names = sorted(datasets.keys())
|
| 295 |
+
mat = np.eye(len(ds_names))
|
| 296 |
+
for _, row in consistency.iterrows():
|
| 297 |
+
i = ds_names.index(row["dataset_a"])
|
| 298 |
+
j = ds_names.index(row["dataset_b"])
|
| 299 |
+
mat[i, j] = mat[j, i] = row["spearman_r"]
|
| 300 |
+
|
| 301 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 302 |
+
im = ax.imshow(mat, cmap="RdYlBu_r", vmin=-0.2, vmax=1.0)
|
| 303 |
+
ax.set_xticks(range(len(ds_names)))
|
| 304 |
+
ax.set_yticks(range(len(ds_names)))
|
| 305 |
+
ax.set_xticklabels(ds_names, rotation=45, ha="right")
|
| 306 |
+
ax.set_yticklabels(ds_names)
|
| 307 |
+
for i in range(len(ds_names)):
|
| 308 |
+
for j in range(len(ds_names)):
|
| 309 |
+
ax.text(j, i, f"{mat[i,j]:.3f}", ha="center", va="center",
|
| 310 |
+
fontsize=10, fontweight="bold" if i != j else "normal")
|
| 311 |
+
plt.colorbar(im, ax=ax, label="Spearman r")
|
| 312 |
+
ax.set_title("Cross-Dataset Gamma Consistency")
|
| 313 |
+
fig.tight_layout()
|
| 314 |
+
save_fig(fig, "cross_dataset_heatmap")
|
| 315 |
+
|
| 316 |
+
# Figure 4: Enrichment comparison
|
| 317 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 318 |
+
for idx, test in enumerate(["ARE", "NMD"]):
|
| 319 |
+
sub = enr_df[enr_df["test"] == test]
|
| 320 |
+
x = np.arange(len(sub))
|
| 321 |
+
pvals = sub["p_value"].values
|
| 322 |
+
neg_log_p = [-np.log10(max(p, 1e-300)) for p in pvals]
|
| 323 |
+
bars = axes[idx].bar(x, neg_log_p,
|
| 324 |
+
color=["steelblue", "darkorange", "forestgreen"])
|
| 325 |
+
axes[idx].set_xticks(x)
|
| 326 |
+
axes[idx].set_xticklabels(sub["dataset"].values, rotation=45, ha="right")
|
| 327 |
+
axes[idx].set_ylabel("-log10(p-value)")
|
| 328 |
+
axes[idx].set_title(f"{test} Enrichment")
|
| 329 |
+
axes[idx].axhline(y=-np.log10(0.05), color="red", linestyle="--",
|
| 330 |
+
alpha=0.5, label="p=0.05")
|
| 331 |
+
axes[idx].legend()
|
| 332 |
+
fig.suptitle("ARE/NMD Enrichment Across Datasets", fontsize=13)
|
| 333 |
+
fig.tight_layout()
|
| 334 |
+
save_fig(fig, "enrichment_comparison")
|
| 335 |
+
|
| 336 |
+
# =========================================================================
|
| 337 |
+
# SUMMARY TABLE
|
| 338 |
+
# =========================================================================
|
| 339 |
+
print("\n" + "=" * 60)
|
| 340 |
+
print("COMPREHENSIVE SUMMARY")
|
| 341 |
+
print("=" * 60)
|
| 342 |
+
|
| 343 |
+
summary = {}
|
| 344 |
+
for name in datasets:
|
| 345 |
+
s = stats_df[stats_df["dataset"] == name].iloc[0]
|
| 346 |
+
hl_sub = hl_df_out[hl_df_out["dataset"] == name]
|
| 347 |
+
rob_90 = robust_all[(robust_all["dataset"] == name) &
|
| 348 |
+
(robust_all["fraction"] == 0.9)]
|
| 349 |
+
are_sub = enr_df[(enr_df["dataset"] == name) & (enr_df["test"] == "ARE")]
|
| 350 |
+
nmd_sub = enr_df[(enr_df["dataset"] == name) & (enr_df["test"] == "NMD")]
|
| 351 |
+
|
| 352 |
+
summary[name] = {
|
| 353 |
+
"cells": int(s["n_cells"]),
|
| 354 |
+
"genes": int(s["n_genes"]),
|
| 355 |
+
"pt_states": int(s["n_pt_states"]),
|
| 356 |
+
"hl_mouse_r": float(hl_sub[hl_sub["reference"] == "mouse_Herzog2017"]["spearman_r"].values[0]),
|
| 357 |
+
"hl_human_r": float(hl_sub[hl_sub["reference"] == "human_Schofield2018"]["spearman_r"].values[0]),
|
| 358 |
+
"robustness_90pct": float(rob_90["spearman_r"].mean()),
|
| 359 |
+
"are_p": float(are_sub["p_value"].values[0]),
|
| 360 |
+
"nmd_p": float(nmd_sub["p_value"].values[0]),
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
with open(res_dir / "comprehensive_summary.json", "w") as f:
|
| 364 |
+
json.dump(summary, f, indent=2)
|
| 365 |
+
|
| 366 |
+
# Print formatted summary
|
| 367 |
+
print(f"\n{'Dataset':<15} {'Cells':>6} {'Genes':>6} {'States':>6} "
|
| 368 |
+
f"{'HL(m)':>8} {'HL(h)':>8} {'Rob90':>7} {'ARE_p':>8} {'NMD_p':>8}")
|
| 369 |
+
print("-" * 85)
|
| 370 |
+
for name, s in summary.items():
|
| 371 |
+
print(f"{name:<15} {s['cells']:>6} {s['genes']:>6} {s['pt_states']:>6} "
|
| 372 |
+
f"{s['hl_mouse_r']:>8.4f} {s['hl_human_r']:>8.4f} "
|
| 373 |
+
f"{s['robustness_90pct']:>7.4f} "
|
| 374 |
+
f"{s['are_p']:>8.4f} {s['nmd_p']:>8.4f}")
|
| 375 |
+
|
| 376 |
+
print(f"\nAll results saved to: {OUTPUT_DIR.resolve()}")
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
if __name__ == "__main__":
|
| 380 |
+
main()
|
analyses/run_tier2_validation.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Tier 2 validation: sequence-feature correlations and eCLIP validation.
|
| 3 |
+
|
| 4 |
+
T2-4: Correlate gamma with 3' UTR length and AU content
|
| 5 |
+
(sequence-feature-based validation, replacing curated gene lists)
|
| 6 |
+
T2-5: Validate RBP-target network predictions against ENCODE eCLIP data
|
| 7 |
+
(Fisher's exact test for overlap enrichment)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from scipy import stats
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 24 |
+
from _common import set_figure_style
|
| 25 |
+
|
| 26 |
+
import scptr
|
| 27 |
+
|
| 28 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "tier2_validation"
|
| 29 |
+
DATA_DIR = Path(__file__).parent.parent / "src" / "scptr" / "benchmark" / "data"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def save_fig(fig, name, subdir="figures"):
|
| 33 |
+
out_dir = OUTPUT_DIR / subdir
|
| 34 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
path = out_dir / f"{name}.png"
|
| 36 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 37 |
+
plt.close(fig)
|
| 38 |
+
print(f" Saved: {path}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def run_pipeline(adata, name):
|
| 42 |
+
"""Run standard scPTR pipeline."""
|
| 43 |
+
print(f"\n--- Pipeline: {name} ---")
|
| 44 |
+
scptr.pp.filter_genes(adata)
|
| 45 |
+
scptr.pp.normalize_layers(adata)
|
| 46 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 47 |
+
scptr.pp.smooth_layers(adata)
|
| 48 |
+
scptr.tl.estimate_beta(adata)
|
| 49 |
+
scptr.tl.estimate_gamma(adata)
|
| 50 |
+
scptr.tl.variance_decomposition(adata)
|
| 51 |
+
scptr.tl.pt_states(adata)
|
| 52 |
+
scptr.tl.pt_velocity(adata)
|
| 53 |
+
print(f" Done: {adata.shape}")
|
| 54 |
+
return adata
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# =========================================================================
|
| 58 |
+
# T2-4: Sequence-feature validation
|
| 59 |
+
# =========================================================================
|
| 60 |
+
def sequence_feature_validation(adata, name, species):
|
| 61 |
+
"""Correlate per-gene gamma with 3' UTR length and AU content.
|
| 62 |
+
|
| 63 |
+
Hypothesis:
|
| 64 |
+
- Longer 3' UTRs → more regulatory elements → higher gamma (positive corr)
|
| 65 |
+
- Higher AU content → ARE-mediated decay → higher gamma (positive corr)
|
| 66 |
+
"""
|
| 67 |
+
print(f"\n{'='*60}")
|
| 68 |
+
print(f"T2-4: SEQUENCE FEATURE VALIDATION ({name})")
|
| 69 |
+
print(f"{'='*60}")
|
| 70 |
+
|
| 71 |
+
res_dir = OUTPUT_DIR / "results"
|
| 72 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
# Load UTR features
|
| 75 |
+
utr_file = DATA_DIR / f"{species}_utr_features.csv"
|
| 76 |
+
if not utr_file.exists():
|
| 77 |
+
print(f" ERROR: {utr_file} not found. Run download_utr_features.py first.")
|
| 78 |
+
return None
|
| 79 |
+
utr_df = pd.read_csv(utr_file)
|
| 80 |
+
print(f" Loaded {len(utr_df)} {species} genes with UTR features")
|
| 81 |
+
|
| 82 |
+
# Per-gene median gamma
|
| 83 |
+
gamma = adata.layers["gamma"]
|
| 84 |
+
gene_names = adata.var_names.tolist()
|
| 85 |
+
median_gamma = np.median(gamma, axis=0)
|
| 86 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 87 |
+
|
| 88 |
+
# Build gene-level DataFrame
|
| 89 |
+
gamma_df = pd.DataFrame({
|
| 90 |
+
"gene": gene_names,
|
| 91 |
+
"median_gamma": median_gamma,
|
| 92 |
+
"nonzero_frac": nonzero_frac,
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
# Filter to gamma-informative genes
|
| 96 |
+
gamma_df = gamma_df[gamma_df["nonzero_frac"] >= 0.1].copy()
|
| 97 |
+
print(f" Gamma-informative genes: {len(gamma_df)}")
|
| 98 |
+
|
| 99 |
+
# Case-insensitive merge
|
| 100 |
+
gamma_df["gene_upper"] = gamma_df["gene"].str.upper()
|
| 101 |
+
utr_df["gene_upper"] = utr_df["gene"].str.upper()
|
| 102 |
+
|
| 103 |
+
merged = gamma_df.merge(utr_df[["gene_upper", "utr_length", "au_content"]],
|
| 104 |
+
on="gene_upper", how="inner")
|
| 105 |
+
print(f" Merged with UTR features: {len(merged)} genes")
|
| 106 |
+
|
| 107 |
+
if len(merged) < 50:
|
| 108 |
+
print(" Too few genes for analysis")
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
# Filter extreme outliers
|
| 112 |
+
merged = merged[merged["utr_length"] > 0].copy()
|
| 113 |
+
merged["log_utr_length"] = np.log10(merged["utr_length"])
|
| 114 |
+
merged["log_gamma"] = np.log1p(merged["median_gamma"])
|
| 115 |
+
|
| 116 |
+
results = {}
|
| 117 |
+
|
| 118 |
+
# 1. Gamma vs UTR length
|
| 119 |
+
r_len, p_len = stats.spearmanr(merged["log_utr_length"], merged["median_gamma"])
|
| 120 |
+
print(f"\n Gamma vs log10(UTR length):")
|
| 121 |
+
print(f" Spearman r = {r_len:.4f}, p = {p_len:.2e}")
|
| 122 |
+
print(f" n = {len(merged)} genes")
|
| 123 |
+
results["utr_length_spearman_r"] = float(r_len)
|
| 124 |
+
results["utr_length_p"] = float(p_len)
|
| 125 |
+
|
| 126 |
+
# 2. Gamma vs AU content
|
| 127 |
+
r_au, p_au = stats.spearmanr(merged["au_content"], merged["median_gamma"])
|
| 128 |
+
print(f"\n Gamma vs AU content:")
|
| 129 |
+
print(f" Spearman r = {r_au:.4f}, p = {p_au:.2e}")
|
| 130 |
+
results["au_content_spearman_r"] = float(r_au)
|
| 131 |
+
results["au_content_p"] = float(p_au)
|
| 132 |
+
|
| 133 |
+
# 3. Quartile analysis: genes in top vs bottom UTR length quartile
|
| 134 |
+
q1 = merged["log_utr_length"].quantile(0.25)
|
| 135 |
+
q4 = merged["log_utr_length"].quantile(0.75)
|
| 136 |
+
short_utr = merged[merged["log_utr_length"] <= q1]
|
| 137 |
+
long_utr = merged[merged["log_utr_length"] >= q4]
|
| 138 |
+
|
| 139 |
+
median_gamma_short = short_utr["median_gamma"].median()
|
| 140 |
+
median_gamma_long = long_utr["median_gamma"].median()
|
| 141 |
+
u_stat, u_p = stats.mannwhitneyu(long_utr["median_gamma"],
|
| 142 |
+
short_utr["median_gamma"],
|
| 143 |
+
alternative="greater")
|
| 144 |
+
print(f"\n Quartile analysis (UTR length):")
|
| 145 |
+
print(f" Short UTR (Q1) median gamma: {median_gamma_short:.4f} (n={len(short_utr)})")
|
| 146 |
+
print(f" Long UTR (Q4) median gamma: {median_gamma_long:.4f} (n={len(long_utr)})")
|
| 147 |
+
print(f" Mann-Whitney (long > short): p = {u_p:.2e}")
|
| 148 |
+
results["long_vs_short_utr_mw_p"] = float(u_p)
|
| 149 |
+
results["median_gamma_short_utr"] = float(median_gamma_short)
|
| 150 |
+
results["median_gamma_long_utr"] = float(median_gamma_long)
|
| 151 |
+
|
| 152 |
+
# 4. AU content quartile
|
| 153 |
+
au_q1 = merged["au_content"].quantile(0.25)
|
| 154 |
+
au_q4 = merged["au_content"].quantile(0.75)
|
| 155 |
+
low_au = merged[merged["au_content"] <= au_q1]
|
| 156 |
+
high_au = merged[merged["au_content"] >= au_q4]
|
| 157 |
+
|
| 158 |
+
median_gamma_low_au = low_au["median_gamma"].median()
|
| 159 |
+
median_gamma_high_au = high_au["median_gamma"].median()
|
| 160 |
+
au_u_stat, au_u_p = stats.mannwhitneyu(high_au["median_gamma"],
|
| 161 |
+
low_au["median_gamma"],
|
| 162 |
+
alternative="greater")
|
| 163 |
+
print(f"\n Quartile analysis (AU content):")
|
| 164 |
+
print(f" Low AU (Q1) median gamma: {median_gamma_low_au:.4f} (n={len(low_au)})")
|
| 165 |
+
print(f" High AU (Q4) median gamma: {median_gamma_high_au:.4f} (n={len(high_au)})")
|
| 166 |
+
print(f" Mann-Whitney (high AU > low AU): p = {au_u_p:.2e}")
|
| 167 |
+
results["high_vs_low_au_mw_p"] = float(au_u_p)
|
| 168 |
+
|
| 169 |
+
results["n_genes"] = len(merged)
|
| 170 |
+
|
| 171 |
+
# Figure: 2x2 scatter + quartile boxplots
|
| 172 |
+
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
|
| 173 |
+
|
| 174 |
+
# Scatter: gamma vs UTR length
|
| 175 |
+
axes[0, 0].scatter(merged["log_utr_length"], merged["log_gamma"],
|
| 176 |
+
s=2, alpha=0.3, color="steelblue")
|
| 177 |
+
axes[0, 0].set_xlabel("log10(3' UTR length)")
|
| 178 |
+
axes[0, 0].set_ylabel("log1p(median gamma)")
|
| 179 |
+
axes[0, 0].set_title(f"Gamma vs 3' UTR Length ({name})\nr={r_len:.3f}, p={p_len:.1e}")
|
| 180 |
+
|
| 181 |
+
# Scatter: gamma vs AU content
|
| 182 |
+
axes[0, 1].scatter(merged["au_content"], merged["log_gamma"],
|
| 183 |
+
s=2, alpha=0.3, color="darkorange")
|
| 184 |
+
axes[0, 1].set_xlabel("3' UTR AU content")
|
| 185 |
+
axes[0, 1].set_ylabel("log1p(median gamma)")
|
| 186 |
+
axes[0, 1].set_title(f"Gamma vs AU Content ({name})\nr={r_au:.3f}, p={p_au:.1e}")
|
| 187 |
+
|
| 188 |
+
# Boxplot: UTR length quartiles
|
| 189 |
+
quartile_data = []
|
| 190 |
+
quartile_labels = []
|
| 191 |
+
for qi, (lo, hi, label) in enumerate([
|
| 192 |
+
(0, 0.25, "Q1\n(short)"), (0.25, 0.5, "Q2"), (0.5, 0.75, "Q3"),
|
| 193 |
+
(0.75, 1.0, "Q4\n(long)")
|
| 194 |
+
]):
|
| 195 |
+
qlo = merged["log_utr_length"].quantile(lo)
|
| 196 |
+
qhi = merged["log_utr_length"].quantile(hi)
|
| 197 |
+
mask = (merged["log_utr_length"] >= qlo) & (merged["log_utr_length"] <= qhi)
|
| 198 |
+
quartile_data.append(merged.loc[mask, "median_gamma"].values)
|
| 199 |
+
quartile_labels.append(label)
|
| 200 |
+
bp = axes[1, 0].boxplot(quartile_data, labels=quartile_labels, patch_artist=True,
|
| 201 |
+
showfliers=False)
|
| 202 |
+
colors = ["#2196F3", "#64B5F6", "#FFA726", "#E65100"]
|
| 203 |
+
for patch, color in zip(bp["boxes"], colors):
|
| 204 |
+
patch.set_facecolor(color)
|
| 205 |
+
axes[1, 0].set_ylabel("Median gamma")
|
| 206 |
+
axes[1, 0].set_xlabel("3' UTR Length Quartile")
|
| 207 |
+
axes[1, 0].set_title(f"Gamma by UTR Length Quartile\np={u_p:.1e}")
|
| 208 |
+
|
| 209 |
+
# Boxplot: AU content quartiles
|
| 210 |
+
au_data = []
|
| 211 |
+
au_labels = []
|
| 212 |
+
for qi, (lo, hi, label) in enumerate([
|
| 213 |
+
(0, 0.25, "Q1\n(low AU)"), (0.25, 0.5, "Q2"), (0.5, 0.75, "Q3"),
|
| 214 |
+
(0.75, 1.0, "Q4\n(high AU)")
|
| 215 |
+
]):
|
| 216 |
+
qlo = merged["au_content"].quantile(lo)
|
| 217 |
+
qhi = merged["au_content"].quantile(hi)
|
| 218 |
+
mask = (merged["au_content"] >= qlo) & (merged["au_content"] <= qhi)
|
| 219 |
+
au_data.append(merged.loc[mask, "median_gamma"].values)
|
| 220 |
+
au_labels.append(label)
|
| 221 |
+
bp2 = axes[1, 1].boxplot(au_data, labels=au_labels, patch_artist=True,
|
| 222 |
+
showfliers=False)
|
| 223 |
+
colors2 = ["#4CAF50", "#81C784", "#FFB74D", "#FF5722"]
|
| 224 |
+
for patch, color in zip(bp2["boxes"], colors2):
|
| 225 |
+
patch.set_facecolor(color)
|
| 226 |
+
axes[1, 1].set_ylabel("Median gamma")
|
| 227 |
+
axes[1, 1].set_xlabel("3' UTR AU Content Quartile")
|
| 228 |
+
axes[1, 1].set_title(f"Gamma by AU Content Quartile\np={au_u_p:.1e}")
|
| 229 |
+
|
| 230 |
+
fig.suptitle(f"Sequence Feature Validation: {name}", fontsize=14, y=1.02)
|
| 231 |
+
fig.tight_layout()
|
| 232 |
+
save_fig(fig, f"seq_features_{name}")
|
| 233 |
+
|
| 234 |
+
return results
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# =========================================================================
|
| 238 |
+
# T2-5: eCLIP validation of RBP-target networks
|
| 239 |
+
# =========================================================================
|
| 240 |
+
def eclip_validation(adata, name):
|
| 241 |
+
"""Validate scPTR-predicted RBP-target edges against ENCODE eCLIP data.
|
| 242 |
+
|
| 243 |
+
For each RBP with both scPTR predictions and eCLIP data:
|
| 244 |
+
- Fisher's exact test: are predicted targets enriched for eCLIP-confirmed targets?
|
| 245 |
+
- Report odds ratio and p-value
|
| 246 |
+
"""
|
| 247 |
+
print(f"\n{'='*60}")
|
| 248 |
+
print(f"T2-5: eCLIP VALIDATION ({name})")
|
| 249 |
+
print(f"{'='*60}")
|
| 250 |
+
|
| 251 |
+
res_dir = OUTPUT_DIR / "results"
|
| 252 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 253 |
+
|
| 254 |
+
# Load eCLIP targets
|
| 255 |
+
eclip_file = DATA_DIR / "eclip_targets.csv"
|
| 256 |
+
if not eclip_file.exists():
|
| 257 |
+
print(f" ERROR: {eclip_file} not found. Run download_eclip.py first.")
|
| 258 |
+
return None
|
| 259 |
+
eclip_df = pd.read_csv(eclip_file)
|
| 260 |
+
print(f" Loaded {len(eclip_df)} eCLIP RBP-target pairs")
|
| 261 |
+
|
| 262 |
+
# Build eCLIP target sets per RBP (uppercase for matching)
|
| 263 |
+
eclip_targets = {}
|
| 264 |
+
for rbp, grp in eclip_df.groupby("rbp"):
|
| 265 |
+
eclip_targets[rbp.upper()] = set(g.upper() for g in grp["target_gene"])
|
| 266 |
+
|
| 267 |
+
# Get scPTR network edges
|
| 268 |
+
gamma = adata.layers["gamma"]
|
| 269 |
+
gene_names = adata.var_names.tolist()
|
| 270 |
+
gene_upper = [g.upper() for g in gene_names]
|
| 271 |
+
|
| 272 |
+
# Load RBP list
|
| 273 |
+
rbp_path = Path(__file__).parent.parent / "src" / "scptr" / "tools" / "data" / "known_rbps.csv"
|
| 274 |
+
rbps = pd.read_csv(rbp_path)["gene_symbol"].tolist()
|
| 275 |
+
|
| 276 |
+
# Find RBPs in dataset
|
| 277 |
+
adata_gene_map = {g.upper(): i for i, g in enumerate(gene_names)}
|
| 278 |
+
rbp_in_data = {}
|
| 279 |
+
for r in rbps:
|
| 280 |
+
if r.upper() in adata_gene_map:
|
| 281 |
+
rbp_in_data[r.upper()] = adata_gene_map[r.upper()]
|
| 282 |
+
|
| 283 |
+
# Get expression matrix
|
| 284 |
+
if hasattr(adata.X, 'toarray'):
|
| 285 |
+
expr = adata.X.toarray()
|
| 286 |
+
else:
|
| 287 |
+
expr = np.asarray(adata.X)
|
| 288 |
+
|
| 289 |
+
# Select target genes: top variable gamma (filtered to informative)
|
| 290 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 291 |
+
informative = nonzero_frac >= 0.1
|
| 292 |
+
gamma_var = np.var(gamma[:, informative], axis=0)
|
| 293 |
+
n_targets = min(200, informative.sum())
|
| 294 |
+
top_var_idx = np.argsort(gamma_var)[-n_targets:]
|
| 295 |
+
info_indices = np.where(informative)[0]
|
| 296 |
+
target_indices = info_indices[top_var_idx]
|
| 297 |
+
target_genes_upper = set(gene_upper[i] for i in target_indices)
|
| 298 |
+
|
| 299 |
+
# Compute scPTR network edges via Spearman correlation
|
| 300 |
+
print(" Computing scPTR network edges...")
|
| 301 |
+
scptr_edges = {} # rbp_upper -> set of target_gene_upper
|
| 302 |
+
|
| 303 |
+
for rbp_upper, rbp_idx in rbp_in_data.items():
|
| 304 |
+
rbp_expr = expr[:, rbp_idx]
|
| 305 |
+
if np.std(rbp_expr) < 1e-6:
|
| 306 |
+
continue
|
| 307 |
+
|
| 308 |
+
targets = set()
|
| 309 |
+
for ti in target_indices:
|
| 310 |
+
target_gamma = gamma[:, ti]
|
| 311 |
+
valid = target_gamma > 0
|
| 312 |
+
if valid.sum() < 50:
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
r, p = stats.spearmanr(rbp_expr[valid], target_gamma[valid])
|
| 316 |
+
# Bonferroni correction
|
| 317 |
+
if p < 0.05 / (len(rbp_in_data) * n_targets):
|
| 318 |
+
targets.add(gene_upper[ti])
|
| 319 |
+
|
| 320 |
+
if targets:
|
| 321 |
+
scptr_edges[rbp_upper] = targets
|
| 322 |
+
|
| 323 |
+
print(f" scPTR edges: {sum(len(t) for t in scptr_edges.values())} total")
|
| 324 |
+
print(f" RBPs with edges: {len(scptr_edges)}")
|
| 325 |
+
|
| 326 |
+
# All genes in dataset (uppercase) as universe
|
| 327 |
+
all_genes_upper = set(gene_upper)
|
| 328 |
+
|
| 329 |
+
# Fisher's exact test for each RBP with both scPTR and eCLIP data
|
| 330 |
+
results = []
|
| 331 |
+
|
| 332 |
+
for rbp_upper in sorted(set(scptr_edges.keys()) & set(eclip_targets.keys())):
|
| 333 |
+
predicted = scptr_edges[rbp_upper]
|
| 334 |
+
eclip = eclip_targets[rbp_upper]
|
| 335 |
+
|
| 336 |
+
# Restrict eCLIP targets to genes in our dataset
|
| 337 |
+
eclip_in_data = eclip & all_genes_upper
|
| 338 |
+
if len(eclip_in_data) < 10:
|
| 339 |
+
continue
|
| 340 |
+
|
| 341 |
+
# 2x2 contingency table
|
| 342 |
+
# predicted & eCLIP | predicted & ~eCLIP
|
| 343 |
+
# ~predicted & eCLIP | ~predicted & ~eCLIP
|
| 344 |
+
a = len(predicted & eclip_in_data)
|
| 345 |
+
b = len(predicted - eclip_in_data)
|
| 346 |
+
c = len(eclip_in_data - predicted)
|
| 347 |
+
d = len(all_genes_upper - predicted - eclip_in_data)
|
| 348 |
+
|
| 349 |
+
odds_ratio, p_val = stats.fisher_exact([[a, b], [c, d]], alternative="greater")
|
| 350 |
+
|
| 351 |
+
# Also compute simple overlap statistics
|
| 352 |
+
overlap_frac = a / max(len(predicted), 1)
|
| 353 |
+
expected_frac = len(eclip_in_data) / max(len(all_genes_upper), 1)
|
| 354 |
+
enrichment = overlap_frac / max(expected_frac, 1e-6)
|
| 355 |
+
|
| 356 |
+
print(f"\n {rbp_upper}:")
|
| 357 |
+
print(f" scPTR predicted targets: {len(predicted)}")
|
| 358 |
+
print(f" eCLIP confirmed targets: {len(eclip_in_data)}")
|
| 359 |
+
print(f" Overlap: {a}")
|
| 360 |
+
print(f" Enrichment fold: {enrichment:.2f}x")
|
| 361 |
+
print(f" Fisher's exact: OR={odds_ratio:.2f}, p={p_val:.4f}")
|
| 362 |
+
|
| 363 |
+
results.append({
|
| 364 |
+
"rbp": rbp_upper,
|
| 365 |
+
"n_predicted": len(predicted),
|
| 366 |
+
"n_eclip": len(eclip_in_data),
|
| 367 |
+
"n_overlap": a,
|
| 368 |
+
"odds_ratio": float(odds_ratio),
|
| 369 |
+
"p_value": float(p_val),
|
| 370 |
+
"enrichment_fold": float(enrichment),
|
| 371 |
+
})
|
| 372 |
+
|
| 373 |
+
if not results:
|
| 374 |
+
print(" No RBPs with both scPTR and eCLIP data found")
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
results_df = pd.DataFrame(results)
|
| 378 |
+
results_df.to_csv(res_dir / f"eclip_validation_{name}.csv", index=False)
|
| 379 |
+
|
| 380 |
+
# Summary
|
| 381 |
+
n_sig = (results_df["p_value"] < 0.05).sum()
|
| 382 |
+
print(f"\n Summary: {n_sig}/{len(results_df)} RBPs have significant eCLIP overlap (p<0.05)")
|
| 383 |
+
print(f" Mean enrichment fold: {results_df['enrichment_fold'].mean():.2f}x")
|
| 384 |
+
print(f" Mean odds ratio: {results_df['odds_ratio'].mean():.2f}")
|
| 385 |
+
|
| 386 |
+
# Figure: enrichment barplot
|
| 387 |
+
if len(results_df) > 0:
|
| 388 |
+
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 389 |
+
|
| 390 |
+
# Enrichment fold
|
| 391 |
+
rbps = results_df["rbp"].values
|
| 392 |
+
enrichments = results_df["enrichment_fold"].values
|
| 393 |
+
pvals = results_df["p_value"].values
|
| 394 |
+
colors = ["steelblue" if p < 0.05 else "lightgray" for p in pvals]
|
| 395 |
+
|
| 396 |
+
bars = axes[0].bar(range(len(rbps)), enrichments, color=colors, edgecolor="black",
|
| 397 |
+
linewidth=0.5)
|
| 398 |
+
axes[0].axhline(y=1, color="red", linestyle="--", alpha=0.5, label="Expected (random)")
|
| 399 |
+
axes[0].set_xticks(range(len(rbps)))
|
| 400 |
+
axes[0].set_xticklabels(rbps, rotation=45, ha="right", fontsize=9)
|
| 401 |
+
axes[0].set_ylabel("Enrichment fold (observed/expected)")
|
| 402 |
+
axes[0].set_title(f"eCLIP Validation: Target Enrichment ({name})")
|
| 403 |
+
axes[0].legend()
|
| 404 |
+
for i, (e, p) in enumerate(zip(enrichments, pvals)):
|
| 405 |
+
sig = "*" if p < 0.05 else ""
|
| 406 |
+
axes[0].text(i, e + 0.05, f"{e:.1f}x{sig}", ha="center", fontsize=8)
|
| 407 |
+
|
| 408 |
+
# Overlap counts
|
| 409 |
+
overlap_data = np.array([
|
| 410 |
+
results_df["n_overlap"].values,
|
| 411 |
+
results_df["n_predicted"].values - results_df["n_overlap"].values,
|
| 412 |
+
])
|
| 413 |
+
axes[1].bar(range(len(rbps)), results_df["n_overlap"].values,
|
| 414 |
+
color="steelblue", label="eCLIP confirmed", edgecolor="black", linewidth=0.5)
|
| 415 |
+
axes[1].bar(range(len(rbps)),
|
| 416 |
+
results_df["n_predicted"].values - results_df["n_overlap"].values,
|
| 417 |
+
bottom=results_df["n_overlap"].values,
|
| 418 |
+
color="lightgray", label="Not confirmed", edgecolor="black", linewidth=0.5)
|
| 419 |
+
axes[1].set_xticks(range(len(rbps)))
|
| 420 |
+
axes[1].set_xticklabels(rbps, rotation=45, ha="right", fontsize=9)
|
| 421 |
+
axes[1].set_ylabel("Number of predicted targets")
|
| 422 |
+
axes[1].set_title(f"Predicted Target Overlap with eCLIP ({name})")
|
| 423 |
+
axes[1].legend()
|
| 424 |
+
|
| 425 |
+
fig.tight_layout()
|
| 426 |
+
save_fig(fig, f"eclip_validation_{name}")
|
| 427 |
+
|
| 428 |
+
return results_df
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# =========================================================================
|
| 432 |
+
# MAIN
|
| 433 |
+
# =========================================================================
|
| 434 |
+
def main():
|
| 435 |
+
set_figure_style()
|
| 436 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 437 |
+
|
| 438 |
+
# Load and process datasets
|
| 439 |
+
print("=" * 60)
|
| 440 |
+
print("LOADING DATASETS")
|
| 441 |
+
print("=" * 60)
|
| 442 |
+
|
| 443 |
+
adata_pan = scptr.datasets.pancreas()
|
| 444 |
+
adata_pan = run_pipeline(adata_pan, "pancreas")
|
| 445 |
+
|
| 446 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 447 |
+
adata_dg = run_pipeline(adata_dg, "dentate_gyrus")
|
| 448 |
+
|
| 449 |
+
# Also load sci-fate
|
| 450 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 451 |
+
from run_scifate import load_scifate_data, prepare_for_scptr
|
| 452 |
+
adata_sf_raw = load_scifate_data()
|
| 453 |
+
adata_sf = prepare_for_scptr(adata_sf_raw)
|
| 454 |
+
adata_sf = run_pipeline(adata_sf, "scifate")
|
| 455 |
+
|
| 456 |
+
datasets = {
|
| 457 |
+
"pancreas": (adata_pan, "mouse"),
|
| 458 |
+
"dentate_gyrus": (adata_dg, "mouse"),
|
| 459 |
+
"scifate": (adata_sf, "human"),
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
# T2-4: Sequence feature validation
|
| 463 |
+
print("\n" + "=" * 60)
|
| 464 |
+
print("T2-4: SEQUENCE FEATURE VALIDATION")
|
| 465 |
+
print("=" * 60)
|
| 466 |
+
|
| 467 |
+
seq_results = {}
|
| 468 |
+
for name, (adata, species) in datasets.items():
|
| 469 |
+
res = sequence_feature_validation(adata, name, species)
|
| 470 |
+
if res:
|
| 471 |
+
seq_results[name] = res
|
| 472 |
+
|
| 473 |
+
res_dir = OUTPUT_DIR / "results"
|
| 474 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 475 |
+
with open(res_dir / "sequence_features.json", "w") as f:
|
| 476 |
+
json.dump(seq_results, f, indent=2)
|
| 477 |
+
|
| 478 |
+
# T2-5: eCLIP validation (only for datasets with significant networks)
|
| 479 |
+
print("\n" + "=" * 60)
|
| 480 |
+
print("T2-5: eCLIP VALIDATION")
|
| 481 |
+
print("=" * 60)
|
| 482 |
+
|
| 483 |
+
for name, (adata, species) in datasets.items():
|
| 484 |
+
eclip_validation(adata, name)
|
| 485 |
+
|
| 486 |
+
print(f"\n{'='*60}")
|
| 487 |
+
print("ALL TIER 2 VALIDATION COMPLETE")
|
| 488 |
+
print(f"{'='*60}")
|
| 489 |
+
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
if __name__ == "__main__":
|
| 493 |
+
main()
|
analyses/run_tier3.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Tier 3 analyses: disease dataset, TIL case study, DepMap validation.
|
| 3 |
+
|
| 4 |
+
T3-1: Apply scPTR to a cancer dataset (neuroblastoma, GSE137804)
|
| 5 |
+
- Run full pipeline, identify PT states, compare tumor vs normal
|
| 6 |
+
T3-2: DepMap/CRISPR validation of RBP hub predictions
|
| 7 |
+
- Test whether scPTR-predicted RBP hubs are more essential (lower CRISPR scores)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
import scanpy as sc
|
| 22 |
+
from scipy import stats
|
| 23 |
+
from sklearn.decomposition import PCA
|
| 24 |
+
from sklearn.cluster import KMeans
|
| 25 |
+
from sklearn.metrics import silhouette_score
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 28 |
+
from _common import set_figure_style
|
| 29 |
+
|
| 30 |
+
import scptr
|
| 31 |
+
|
| 32 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "tier3"
|
| 33 |
+
CACHE_DIR = Path(__file__).parent.parent / ".cache"
|
| 34 |
+
DATA_DIR = Path(__file__).parent.parent / "src" / "scptr" / "benchmark" / "data"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_fig(fig, name, subdir="figures"):
|
| 38 |
+
out_dir = OUTPUT_DIR / subdir
|
| 39 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
path = out_dir / f"{name}.png"
|
| 41 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 42 |
+
plt.close(fig)
|
| 43 |
+
print(f" Saved: {path}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# =========================================================================
|
| 47 |
+
# T3-1: Disease dataset — Neuroblastoma (GSE137804)
|
| 48 |
+
# =========================================================================
|
| 49 |
+
def load_neuroblastoma():
|
| 50 |
+
"""Load neuroblastoma dataset with spliced/unspliced layers."""
|
| 51 |
+
h5ad_path = CACHE_DIR / "neuroblastoma.h5ad"
|
| 52 |
+
if not h5ad_path.exists():
|
| 53 |
+
raise FileNotFoundError(
|
| 54 |
+
f"{h5ad_path} not found. Download from: "
|
| 55 |
+
"https://cdn.bioturing.com/colab/data/GSE137804-kallisto.symbol.h5ad"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
print("Loading neuroblastoma dataset...")
|
| 59 |
+
adata = sc.read_h5ad(str(h5ad_path))
|
| 60 |
+
print(f" Raw: {adata.shape}")
|
| 61 |
+
print(f" Layers: {list(adata.layers.keys())}")
|
| 62 |
+
print(f" Cell types: {adata.obs['celltype'].value_counts().to_dict()}")
|
| 63 |
+
|
| 64 |
+
# Basic preprocessing
|
| 65 |
+
# Filter genes: require minimum expression
|
| 66 |
+
sc.pp.filter_genes(adata, min_cells=50)
|
| 67 |
+
print(f" After gene filter: {adata.shape}")
|
| 68 |
+
|
| 69 |
+
# Store raw counts before normalizing
|
| 70 |
+
adata.layers["raw_spliced"] = adata.layers["spliced"].copy()
|
| 71 |
+
adata.layers["raw_unspliced"] = adata.layers["unspliced"].copy()
|
| 72 |
+
|
| 73 |
+
return adata
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def run_neuroblastoma_pipeline(adata):
|
| 77 |
+
"""Run scPTR pipeline on neuroblastoma data."""
|
| 78 |
+
print("\n--- Running scPTR pipeline on neuroblastoma ---")
|
| 79 |
+
|
| 80 |
+
scptr.pp.filter_genes(adata)
|
| 81 |
+
scptr.pp.normalize_layers(adata)
|
| 82 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 83 |
+
scptr.pp.smooth_layers(adata)
|
| 84 |
+
scptr.tl.estimate_beta(adata)
|
| 85 |
+
scptr.tl.estimate_gamma(adata)
|
| 86 |
+
scptr.tl.variance_decomposition(adata)
|
| 87 |
+
scptr.tl.pt_states(adata)
|
| 88 |
+
scptr.tl.pt_velocity(adata)
|
| 89 |
+
|
| 90 |
+
print(f" Pipeline complete: {adata.shape}")
|
| 91 |
+
return adata
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def analyze_neuroblastoma(adata):
|
| 95 |
+
"""Comprehensive analysis of neuroblastoma data."""
|
| 96 |
+
print(f"\n{'='*60}")
|
| 97 |
+
print("T3-1: NEUROBLASTOMA ANALYSIS")
|
| 98 |
+
print(f"{'='*60}")
|
| 99 |
+
|
| 100 |
+
res_dir = OUTPUT_DIR / "results"
|
| 101 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
|
| 103 |
+
gamma = adata.layers["gamma"]
|
| 104 |
+
n_cells, n_genes = gamma.shape
|
| 105 |
+
|
| 106 |
+
# Basic stats
|
| 107 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 108 |
+
informative = nonzero_frac >= 0.1
|
| 109 |
+
print(f" Cells: {n_cells}")
|
| 110 |
+
print(f" Genes: {n_genes}")
|
| 111 |
+
print(f" Gamma-informative genes: {informative.sum()} ({100*informative.mean():.1f}%)")
|
| 112 |
+
|
| 113 |
+
results = {
|
| 114 |
+
"n_cells": int(n_cells),
|
| 115 |
+
"n_genes": int(n_genes),
|
| 116 |
+
"n_informative": int(informative.sum()),
|
| 117 |
+
"frac_informative": float(informative.mean()),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# Half-life validation
|
| 121 |
+
print("\n Half-life correlation:")
|
| 122 |
+
median_gamma = np.median(gamma, axis=0)
|
| 123 |
+
datasets_data_dir = Path(__file__).parent.parent / "src" / "scptr" / "datasets" / "data"
|
| 124 |
+
hl_files = [
|
| 125 |
+
("mouse", "Mouse (Herzog 2017)", datasets_data_dir / "herzog2017_halflives.csv"),
|
| 126 |
+
("human", "Human (Schofield 2018)", datasets_data_dir / "schofield2018_halflives.csv"),
|
| 127 |
+
]
|
| 128 |
+
for species, label, hl_path in hl_files:
|
| 129 |
+
if not hl_path.exists():
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
hl_df = pd.read_csv(hl_path)
|
| 133 |
+
hl_df = hl_df[["gene_symbol", "half_life_hours"]].dropna()
|
| 134 |
+
hl_dict = dict(zip(hl_df["gene_symbol"].str.upper(), hl_df["half_life_hours"]))
|
| 135 |
+
|
| 136 |
+
# Match genes
|
| 137 |
+
matched_gamma = []
|
| 138 |
+
matched_hl = []
|
| 139 |
+
for i, gene in enumerate(adata.var_names):
|
| 140 |
+
g_upper = gene.upper()
|
| 141 |
+
if g_upper in hl_dict and informative[i]:
|
| 142 |
+
matched_gamma.append(median_gamma[i])
|
| 143 |
+
matched_hl.append(hl_dict[g_upper])
|
| 144 |
+
|
| 145 |
+
if len(matched_gamma) >= 50:
|
| 146 |
+
r, p = stats.spearmanr(matched_gamma, matched_hl)
|
| 147 |
+
print(f" {label}: r={r:.4f}, p={p:.2e}, n={len(matched_gamma)}")
|
| 148 |
+
results[f"halflife_{species}_r"] = float(r)
|
| 149 |
+
results[f"halflife_{species}_p"] = float(p)
|
| 150 |
+
results[f"halflife_{species}_n"] = len(matched_gamma)
|
| 151 |
+
|
| 152 |
+
# PT state discovery
|
| 153 |
+
print("\n PT state discovery:")
|
| 154 |
+
clusters = adata.obs.get("pt_clusters", adata.obs.get("clusters"))
|
| 155 |
+
if clusters is not None:
|
| 156 |
+
n_clusters = clusters.nunique()
|
| 157 |
+
print(f" PT clusters found: {n_clusters}")
|
| 158 |
+
print(f" Cluster sizes: {clusters.value_counts().to_dict()}")
|
| 159 |
+
results["n_pt_clusters"] = int(n_clusters)
|
| 160 |
+
|
| 161 |
+
# Sub-clustering within tumor cells for invisible states
|
| 162 |
+
print("\n Invisible state discovery (within tumor cells):")
|
| 163 |
+
tumor_mask = np.ones(n_cells, dtype=bool) # all cells are tumor
|
| 164 |
+
gamma_tumor = gamma[tumor_mask]
|
| 165 |
+
|
| 166 |
+
# Filter to informative genes
|
| 167 |
+
good = informative
|
| 168 |
+
if good.sum() >= 20:
|
| 169 |
+
gamma_filt = gamma_tumor[:, good]
|
| 170 |
+
n_pcs = min(30, n_cells - 1, gamma_filt.shape[1] - 1)
|
| 171 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 172 |
+
gamma_pcs = pca.fit_transform(gamma_filt)
|
| 173 |
+
|
| 174 |
+
# Try k=2,3,4,5
|
| 175 |
+
best_k, best_sil, best_labels = 1, -1, None
|
| 176 |
+
for k in [2, 3, 4, 5]:
|
| 177 |
+
km = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 178 |
+
labels = km.fit_predict(gamma_pcs)
|
| 179 |
+
if min(np.bincount(labels)) < 50:
|
| 180 |
+
continue
|
| 181 |
+
sil = silhouette_score(gamma_pcs, labels)
|
| 182 |
+
print(f" k={k}: silhouette={sil:.4f}")
|
| 183 |
+
if sil > best_sil:
|
| 184 |
+
best_k, best_sil, best_labels = k, sil, labels
|
| 185 |
+
|
| 186 |
+
if best_labels is not None:
|
| 187 |
+
print(f" Best k={best_k}, silhouette={best_sil:.4f}")
|
| 188 |
+
results["best_k"] = int(best_k)
|
| 189 |
+
results["best_silhouette"] = float(best_sil)
|
| 190 |
+
|
| 191 |
+
# Expression silhouette for same labels
|
| 192 |
+
expr = adata.X[tumor_mask].toarray() if hasattr(adata.X, 'toarray') else np.asarray(adata.X[tumor_mask])
|
| 193 |
+
n_expr_pcs = min(30, n_cells - 1, expr.shape[1] - 1)
|
| 194 |
+
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
|
| 195 |
+
expr_pcs = pca_expr.fit_transform(expr)
|
| 196 |
+
sil_expr = silhouette_score(expr_pcs, best_labels)
|
| 197 |
+
print(f" Expression silhouette (same labels): {sil_expr:.4f}")
|
| 198 |
+
results["expr_silhouette"] = float(sil_expr)
|
| 199 |
+
results["invisibility"] = float(best_sil - sil_expr)
|
| 200 |
+
|
| 201 |
+
if best_sil > sil_expr:
|
| 202 |
+
print(f" ** INVISIBLE STATES FOUND (gamma sil > expr sil) **")
|
| 203 |
+
else:
|
| 204 |
+
print(f" States are visible in expression")
|
| 205 |
+
|
| 206 |
+
# Store gamma sub-clusters
|
| 207 |
+
adata.obs["gamma_subcluster"] = "NA"
|
| 208 |
+
adata.obs.loc[adata.obs.index[tumor_mask], "gamma_subcluster"] = [
|
| 209 |
+
f"GC_{l}" for l in best_labels
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
# Differential gamma analysis between sub-clusters
|
| 213 |
+
print("\n Top differentially degraded genes between gamma sub-clusters:")
|
| 214 |
+
gene_names = adata.var_names[good]
|
| 215 |
+
diff_results = []
|
| 216 |
+
for gi, gene in enumerate(gene_names):
|
| 217 |
+
groups = [gamma_filt[best_labels == j, gi] for j in range(best_k)]
|
| 218 |
+
if all(len(g) >= 50 for g in groups):
|
| 219 |
+
if best_k == 2:
|
| 220 |
+
_, p_val = stats.mannwhitneyu(groups[0], groups[1],
|
| 221 |
+
alternative='two-sided')
|
| 222 |
+
else:
|
| 223 |
+
_, p_val = stats.kruskal(*groups)
|
| 224 |
+
medians = [np.median(g) for g in groups]
|
| 225 |
+
log_fc = np.log2((max(medians) + 0.01) / (min(medians) + 0.01))
|
| 226 |
+
diff_results.append({"gene": gene, "p_value": p_val,
|
| 227 |
+
"log2_fc_gamma": log_fc})
|
| 228 |
+
|
| 229 |
+
if diff_results:
|
| 230 |
+
diff_df = pd.DataFrame(diff_results)
|
| 231 |
+
from statsmodels.stats.multitest import multipletests
|
| 232 |
+
_, diff_df["fdr"], _, _ = multipletests(diff_df["p_value"], method="fdr_bh")
|
| 233 |
+
sig = diff_df[diff_df["fdr"] < 0.05].sort_values("log2_fc_gamma", ascending=False)
|
| 234 |
+
print(f" Differentially degraded (FDR<0.05): {len(sig)}/{len(diff_df)}")
|
| 235 |
+
if len(sig) > 0:
|
| 236 |
+
print(f" Top 10: {sig.head(10)['gene'].tolist()}")
|
| 237 |
+
sig.to_csv(res_dir / "neuroblastoma_diff_degraded.csv", index=False)
|
| 238 |
+
results["n_diff_genes"] = len(sig)
|
| 239 |
+
|
| 240 |
+
# RBP network (library-size corrected partial correlation)
|
| 241 |
+
print("\n RBP-target network (library-size corrected):")
|
| 242 |
+
rbp_path = Path(__file__).parent.parent / "src" / "scptr" / "tools" / "data" / "known_rbps.csv"
|
| 243 |
+
rbps = pd.read_csv(rbp_path)["gene_symbol"].tolist()
|
| 244 |
+
|
| 245 |
+
gene_upper_map = {g.upper(): i for i, g in enumerate(adata.var_names)}
|
| 246 |
+
rbp_in_data = {}
|
| 247 |
+
for r in rbps:
|
| 248 |
+
if r.upper() in gene_upper_map:
|
| 249 |
+
rbp_in_data[r.upper()] = gene_upper_map[r.upper()]
|
| 250 |
+
|
| 251 |
+
print(f" RBPs in dataset: {len(rbp_in_data)}")
|
| 252 |
+
|
| 253 |
+
if hasattr(adata.X, 'toarray'):
|
| 254 |
+
expr = adata.X.toarray()
|
| 255 |
+
else:
|
| 256 |
+
expr = np.asarray(adata.X)
|
| 257 |
+
|
| 258 |
+
# Library-size correction: rank-residualize against library size
|
| 259 |
+
lib_size = expr.sum(axis=1)
|
| 260 |
+
lib_rank = stats.rankdata(lib_size)
|
| 261 |
+
lib_rank_centered = lib_rank - lib_rank.mean()
|
| 262 |
+
lib_ss = np.dot(lib_rank_centered, lib_rank_centered)
|
| 263 |
+
|
| 264 |
+
# Top variable gamma genes as targets
|
| 265 |
+
gamma_var = np.var(gamma[:, informative], axis=0)
|
| 266 |
+
n_targets = min(200, informative.sum())
|
| 267 |
+
top_var_idx = np.argsort(gamma_var)[-n_targets:]
|
| 268 |
+
info_indices = np.where(informative)[0]
|
| 269 |
+
target_indices = info_indices[top_var_idx]
|
| 270 |
+
|
| 271 |
+
# Pre-compute residualized gamma ranks for all targets
|
| 272 |
+
gamma_resid_map = {}
|
| 273 |
+
for ti in target_indices:
|
| 274 |
+
target_gamma = gamma[:, ti]
|
| 275 |
+
if np.std(target_gamma) < 1e-8:
|
| 276 |
+
continue
|
| 277 |
+
t_rank = stats.rankdata(target_gamma)
|
| 278 |
+
t_rank_c = t_rank - t_rank.mean()
|
| 279 |
+
slope = np.dot(lib_rank_centered, t_rank_c) / lib_ss
|
| 280 |
+
resid = t_rank - slope * lib_rank
|
| 281 |
+
resid_c = resid - resid.mean()
|
| 282 |
+
resid_std = np.sqrt(np.dot(resid_c, resid_c))
|
| 283 |
+
if resid_std > 1e-8:
|
| 284 |
+
gamma_resid_map[ti] = (resid_c, resid_std)
|
| 285 |
+
|
| 286 |
+
# Raw edges (for comparison)
|
| 287 |
+
raw_edges = []
|
| 288 |
+
corrected_edges = []
|
| 289 |
+
for rbp_upper, rbp_idx in rbp_in_data.items():
|
| 290 |
+
rbp_expr = expr[:, rbp_idx]
|
| 291 |
+
if np.std(rbp_expr) < 1e-6:
|
| 292 |
+
continue
|
| 293 |
+
|
| 294 |
+
# Residualize RBP expression against library size
|
| 295 |
+
rbp_rank = stats.rankdata(rbp_expr)
|
| 296 |
+
rbp_rank_c = rbp_rank - rbp_rank.mean()
|
| 297 |
+
slope_rbp = np.dot(lib_rank_centered, rbp_rank_c) / lib_ss
|
| 298 |
+
rbp_resid = rbp_rank - slope_rbp * lib_rank
|
| 299 |
+
rbp_resid_c = rbp_resid - rbp_resid.mean()
|
| 300 |
+
rbp_resid_std = np.sqrt(np.dot(rbp_resid_c, rbp_resid_c))
|
| 301 |
+
if rbp_resid_std < 1e-8:
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
for ti in target_indices:
|
| 305 |
+
target_gamma = gamma[:, ti]
|
| 306 |
+
valid = target_gamma > 0
|
| 307 |
+
if valid.sum() < 50:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
# Raw correlation (for comparison)
|
| 311 |
+
r_raw, p_raw = stats.spearmanr(rbp_expr[valid], target_gamma[valid])
|
| 312 |
+
if p_raw < 0.05 / (len(rbp_in_data) * n_targets):
|
| 313 |
+
raw_edges.append({
|
| 314 |
+
"rbp": rbp_upper,
|
| 315 |
+
"target": adata.var_names[ti],
|
| 316 |
+
"spearman_r": r_raw,
|
| 317 |
+
"direction": "destabilizing" if r_raw > 0 else "stabilizing",
|
| 318 |
+
})
|
| 319 |
+
|
| 320 |
+
# Library-size corrected partial correlation
|
| 321 |
+
if ti not in gamma_resid_map:
|
| 322 |
+
continue
|
| 323 |
+
g_resid_c, g_resid_std = gamma_resid_map[ti]
|
| 324 |
+
r_corr = np.dot(rbp_resid_c, g_resid_c) / (rbp_resid_std * g_resid_std)
|
| 325 |
+
r_corr = np.clip(r_corr, -1.0, 1.0)
|
| 326 |
+
df = n_cells - 3
|
| 327 |
+
t_val = r_corr * np.sqrt(df / (1 - r_corr**2 + 1e-12))
|
| 328 |
+
p_corr = 2 * stats.t.sf(abs(t_val), df)
|
| 329 |
+
|
| 330 |
+
if p_corr < 0.05 / (len(rbp_in_data) * n_targets):
|
| 331 |
+
corrected_edges.append({
|
| 332 |
+
"rbp": rbp_upper,
|
| 333 |
+
"target": adata.var_names[ti],
|
| 334 |
+
"spearman_r": float(r_corr),
|
| 335 |
+
"direction": "destabilizing" if r_corr > 0 else "stabilizing",
|
| 336 |
+
})
|
| 337 |
+
|
| 338 |
+
# Report raw network stats
|
| 339 |
+
if raw_edges:
|
| 340 |
+
raw_df = pd.DataFrame(raw_edges)
|
| 341 |
+
raw_n_destab = (raw_df["spearman_r"] > 0).sum()
|
| 342 |
+
print(f" Raw network: {len(raw_df)} edges, "
|
| 343 |
+
f"{raw_n_destab} destab ({100*raw_n_destab/len(raw_df):.1f}%)")
|
| 344 |
+
raw_df.to_csv(res_dir / "neuroblastoma_network_raw.csv", index=False)
|
| 345 |
+
results["n_raw_edges"] = len(raw_df)
|
| 346 |
+
results["raw_destab_frac"] = float(raw_n_destab / len(raw_df))
|
| 347 |
+
|
| 348 |
+
# Report corrected network
|
| 349 |
+
if corrected_edges:
|
| 350 |
+
edges_df = pd.DataFrame(corrected_edges)
|
| 351 |
+
n_destab = (edges_df["spearman_r"] > 0).sum()
|
| 352 |
+
n_stab = (edges_df["spearman_r"] < 0).sum()
|
| 353 |
+
print(f" Corrected network: {len(edges_df)} edges")
|
| 354 |
+
print(f" Destabilizing: {n_destab} ({100*n_destab/len(edges_df):.1f}%), "
|
| 355 |
+
f"Stabilizing: {n_stab} ({100*n_stab/len(edges_df):.1f}%)")
|
| 356 |
+
results["n_network_edges"] = len(edges_df)
|
| 357 |
+
results["corrected_destab_frac"] = float(n_destab / len(edges_df))
|
| 358 |
+
|
| 359 |
+
# Top hubs
|
| 360 |
+
hub_counts = edges_df.groupby("rbp").size().sort_values(ascending=False)
|
| 361 |
+
print(f" Top RBP hubs (corrected):")
|
| 362 |
+
for rbp, count in hub_counts.head(10).items():
|
| 363 |
+
sub = edges_df[edges_df["rbp"] == rbp]
|
| 364 |
+
print(f" {rbp}: {count} targets "
|
| 365 |
+
f"({(sub['spearman_r'] < 0).sum()} stab, "
|
| 366 |
+
f"{(sub['spearman_r'] > 0).sum()} destab)")
|
| 367 |
+
|
| 368 |
+
edges_df.to_csv(res_dir / "neuroblastoma_network_corrected.csv", index=False)
|
| 369 |
+
results["top_hubs"] = hub_counts.head(10).to_dict()
|
| 370 |
+
else:
|
| 371 |
+
edges_df = pd.DataFrame()
|
| 372 |
+
|
| 373 |
+
# Stability program characterization via pathway enrichment
|
| 374 |
+
print("\n Stability program characterization:")
|
| 375 |
+
stability_programs = []
|
| 376 |
+
if best_labels is not None and best_k >= 2:
|
| 377 |
+
gene_names_good = adata.var_names[good]
|
| 378 |
+
for cluster_id in range(best_k):
|
| 379 |
+
cluster_mask = best_labels == cluster_id
|
| 380 |
+
other_mask = ~cluster_mask
|
| 381 |
+
|
| 382 |
+
# Top differentially degraded genes for this cluster
|
| 383 |
+
top_genes_up = []
|
| 384 |
+
top_genes_down = []
|
| 385 |
+
for gi, gene in enumerate(gene_names_good):
|
| 386 |
+
vals_in = gamma_filt[cluster_mask, gi]
|
| 387 |
+
vals_out = gamma_filt[other_mask, gi]
|
| 388 |
+
if len(vals_in) < 10 or len(vals_out) < 10:
|
| 389 |
+
continue
|
| 390 |
+
med_in = np.median(vals_in)
|
| 391 |
+
med_out = np.median(vals_out)
|
| 392 |
+
log_fc = np.log2((med_in + 0.01) / (med_out + 0.01))
|
| 393 |
+
if log_fc > 0.5:
|
| 394 |
+
top_genes_up.append((gene, log_fc))
|
| 395 |
+
elif log_fc < -0.5:
|
| 396 |
+
top_genes_down.append((gene, log_fc))
|
| 397 |
+
|
| 398 |
+
top_genes_up.sort(key=lambda x: x[1], reverse=True)
|
| 399 |
+
top_genes_down.sort(key=lambda x: x[1])
|
| 400 |
+
|
| 401 |
+
print(f" GC_{cluster_id}: {cluster_mask.sum()} cells, "
|
| 402 |
+
f"{len(top_genes_up)} up-degraded, {len(top_genes_down)} down-degraded")
|
| 403 |
+
|
| 404 |
+
# Pathway enrichment on top differentially degraded genes
|
| 405 |
+
gene_list = [g for g, _ in top_genes_up[:200]]
|
| 406 |
+
if len(gene_list) >= 10:
|
| 407 |
+
try:
|
| 408 |
+
import gseapy as gp
|
| 409 |
+
enr = gp.enrichr(
|
| 410 |
+
gene_list=gene_list,
|
| 411 |
+
gene_sets=["KEGG_2021_Human"],
|
| 412 |
+
organism="human",
|
| 413 |
+
outdir=None,
|
| 414 |
+
no_plot=True,
|
| 415 |
+
)
|
| 416 |
+
sig_enr = enr.results[enr.results["Adjusted P-value"] < 0.1].head(10)
|
| 417 |
+
if len(sig_enr) > 0:
|
| 418 |
+
print(f" Top KEGG pathways (up-degraded):")
|
| 419 |
+
for _, row in sig_enr.iterrows():
|
| 420 |
+
print(f" {row['Term'][:60]}: p={row['Adjusted P-value']:.4f}")
|
| 421 |
+
stability_programs.append({
|
| 422 |
+
"cluster": f"GC_{cluster_id}",
|
| 423 |
+
"direction": "up_degraded",
|
| 424 |
+
"pathway": row["Term"],
|
| 425 |
+
"fdr": row["Adjusted P-value"],
|
| 426 |
+
"n_overlap": row.get("Overlap", ""),
|
| 427 |
+
})
|
| 428 |
+
except Exception as e:
|
| 429 |
+
print(f" [WARNING] Enrichment failed: {e}")
|
| 430 |
+
|
| 431 |
+
if stability_programs:
|
| 432 |
+
sp_df = pd.DataFrame(stability_programs)
|
| 433 |
+
sp_df.to_csv(res_dir / "neuroblastoma_stability_programs.csv", index=False)
|
| 434 |
+
|
| 435 |
+
# Honest half-life framing
|
| 436 |
+
print("\n Half-life context:")
|
| 437 |
+
print(" Note: Weak half-life correlations (r~-0.05) are expected for")
|
| 438 |
+
print(" single-cell-type tumors. The heterogeneity assumption that drives")
|
| 439 |
+
print(" strong correlations in developmental data (r~-0.35) is violated")
|
| 440 |
+
print(" when all cells are a single tumor type.")
|
| 441 |
+
|
| 442 |
+
# Figures: 4-panel corrected overview
|
| 443 |
+
print("\n Computing UMAP...")
|
| 444 |
+
sc.tl.umap(adata)
|
| 445 |
+
coords = adata.obsm["X_umap"]
|
| 446 |
+
|
| 447 |
+
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
|
| 448 |
+
|
| 449 |
+
# Panel A: UMAP colored by gamma sub-cluster
|
| 450 |
+
if "gamma_subcluster" in adata.obs.columns:
|
| 451 |
+
sub_labels = adata.obs["gamma_subcluster"].values
|
| 452 |
+
unique_labels = sorted(set(sub_labels))
|
| 453 |
+
colors_sc = plt.cm.Set2(np.linspace(0, 1, max(len(unique_labels), 2)))
|
| 454 |
+
for li, label in enumerate(unique_labels):
|
| 455 |
+
mask_l = sub_labels == label
|
| 456 |
+
axes[0, 0].scatter(coords[mask_l, 0], coords[mask_l, 1], s=2, alpha=0.3,
|
| 457 |
+
c=[colors_sc[li]], label=label)
|
| 458 |
+
axes[0, 0].legend(fontsize=8, markerscale=3)
|
| 459 |
+
axes[0, 0].set_title("A: Gamma Sub-clusters (Stability Programs)")
|
| 460 |
+
axes[0, 0].set_xlabel("UMAP 1")
|
| 461 |
+
axes[0, 0].set_ylabel("UMAP 2")
|
| 462 |
+
|
| 463 |
+
# Panel B: Corrected network stats (raw vs corrected destabilizing fraction)
|
| 464 |
+
raw_destab = results.get("raw_destab_frac", 0.99)
|
| 465 |
+
corr_destab = results.get("corrected_destab_frac", 0.60)
|
| 466 |
+
bar_labels = ["Raw\nnetwork", "Library-size\ncorrected"]
|
| 467 |
+
bar_vals = [raw_destab * 100, corr_destab * 100]
|
| 468 |
+
bar_colors = ["salmon", "steelblue"]
|
| 469 |
+
bars = axes[0, 1].bar(bar_labels, bar_vals, color=bar_colors,
|
| 470 |
+
edgecolor="black", linewidth=0.5, width=0.5)
|
| 471 |
+
axes[0, 1].axhline(y=50, color="gray", linestyle="--", alpha=0.5, label="Null (50%)")
|
| 472 |
+
for bar, val in zip(bars, bar_vals):
|
| 473 |
+
axes[0, 1].text(bar.get_x() + bar.get_width()/2, val + 1,
|
| 474 |
+
f"{val:.1f}%", ha="center", fontsize=10)
|
| 475 |
+
axes[0, 1].set_ylabel("Destabilizing edges (%)")
|
| 476 |
+
axes[0, 1].set_title("B: Network Bias Correction")
|
| 477 |
+
axes[0, 1].set_ylim(0, 105)
|
| 478 |
+
axes[0, 1].legend(fontsize=8)
|
| 479 |
+
|
| 480 |
+
# Panel C: Top pathways differentially degraded between sub-clusters
|
| 481 |
+
if stability_programs:
|
| 482 |
+
sp_show = pd.DataFrame(stability_programs)
|
| 483 |
+
sp_show = sp_show.sort_values("fdr").head(10)
|
| 484 |
+
y_pos = np.arange(len(sp_show))
|
| 485 |
+
pathway_labels = [f"{row['cluster']}: {row['pathway'][:40]}"
|
| 486 |
+
for _, row in sp_show.iterrows()]
|
| 487 |
+
neg_log_p = [-np.log10(max(row["fdr"], 1e-20)) for _, row in sp_show.iterrows()]
|
| 488 |
+
sp_colors = ["steelblue" if "GC_0" in row["cluster"] else "coral"
|
| 489 |
+
for _, row in sp_show.iterrows()]
|
| 490 |
+
axes[1, 0].barh(y_pos, neg_log_p, color=sp_colors, edgecolor="black",
|
| 491 |
+
linewidth=0.5)
|
| 492 |
+
axes[1, 0].set_yticks(y_pos)
|
| 493 |
+
axes[1, 0].set_yticklabels(pathway_labels, fontsize=7)
|
| 494 |
+
axes[1, 0].set_xlabel("-log10(FDR)")
|
| 495 |
+
axes[1, 0].axvline(x=1, color="gray", linestyle="--", alpha=0.5)
|
| 496 |
+
axes[1, 0].set_title("C: Stability Programs (KEGG Pathways)")
|
| 497 |
+
|
| 498 |
+
# Panel D: Mean gamma per cell (proxy for DepMap hub essentiality context)
|
| 499 |
+
mean_gamma = np.mean(gamma, axis=1)
|
| 500 |
+
sc_plot = axes[1, 1].scatter(coords[:, 0], coords[:, 1], s=2, alpha=0.3,
|
| 501 |
+
c=np.clip(mean_gamma, 0, np.percentile(mean_gamma, 95)),
|
| 502 |
+
cmap="YlOrRd")
|
| 503 |
+
axes[1, 1].set_title("D: Mean Gamma (Degradation Rate)")
|
| 504 |
+
axes[1, 1].set_xlabel("UMAP 1")
|
| 505 |
+
axes[1, 1].set_ylabel("UMAP 2")
|
| 506 |
+
plt.colorbar(sc_plot, ax=axes[1, 1])
|
| 507 |
+
|
| 508 |
+
fig.suptitle("Neuroblastoma (GSE137804): Corrected scPTR Analysis", fontsize=14)
|
| 509 |
+
fig.tight_layout()
|
| 510 |
+
save_fig(fig, "neuroblastoma_corrected_overview")
|
| 511 |
+
|
| 512 |
+
with open(res_dir / "neuroblastoma_results.json", "w") as f:
|
| 513 |
+
json.dump(results, f, indent=2, default=str)
|
| 514 |
+
|
| 515 |
+
return results
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
# =========================================================================
|
| 519 |
+
# T3-2: DepMap/CRISPR validation
|
| 520 |
+
# =========================================================================
|
| 521 |
+
def depmap_validation(datasets_results):
|
| 522 |
+
"""Validate RBP hub predictions against DepMap CRISPR dependency scores.
|
| 523 |
+
|
| 524 |
+
Hypothesis: RBPs that are hub regulators in scPTR networks should be
|
| 525 |
+
more essential (lower CRISPR gene effect scores) than non-hub RBPs.
|
| 526 |
+
"""
|
| 527 |
+
print(f"\n{'='*60}")
|
| 528 |
+
print("T3-2: DepMap/CRISPR VALIDATION")
|
| 529 |
+
print(f"{'='*60}")
|
| 530 |
+
|
| 531 |
+
res_dir = OUTPUT_DIR / "results"
|
| 532 |
+
res_dir.mkdir(parents=True, exist_ok=True)
|
| 533 |
+
|
| 534 |
+
# Load DepMap CRISPR data
|
| 535 |
+
crispr_path = CACHE_DIR / "CRISPRGeneEffect.csv"
|
| 536 |
+
model_path = CACHE_DIR / "DepMap_Model.csv"
|
| 537 |
+
|
| 538 |
+
if not crispr_path.exists():
|
| 539 |
+
print(f" ERROR: {crispr_path} not found")
|
| 540 |
+
return None
|
| 541 |
+
|
| 542 |
+
print(" Loading DepMap CRISPR data...")
|
| 543 |
+
crispr = pd.read_csv(crispr_path, index_col=0)
|
| 544 |
+
print(f" CRISPR matrix: {crispr.shape} (cell lines x genes)")
|
| 545 |
+
|
| 546 |
+
# Parse gene names: "HUGO (Entrez)" -> "HUGO"
|
| 547 |
+
gene_map = {}
|
| 548 |
+
for col in crispr.columns:
|
| 549 |
+
gene = col.split(" (")[0].strip()
|
| 550 |
+
gene_map[col] = gene.upper()
|
| 551 |
+
crispr.columns = [gene_map[c] for c in crispr.columns]
|
| 552 |
+
|
| 553 |
+
# Compute mean dependency per gene (across all cell lines)
|
| 554 |
+
mean_dep = crispr.mean(axis=0)
|
| 555 |
+
print(f" Genes in DepMap: {len(mean_dep)}")
|
| 556 |
+
print(f" Mean dependency: median={mean_dep.median():.4f}, "
|
| 557 |
+
f"min={mean_dep.min():.4f}, max={mean_dep.max():.4f}")
|
| 558 |
+
|
| 559 |
+
# Load RBP list
|
| 560 |
+
rbp_path = Path(__file__).parent.parent / "src" / "scptr" / "tools" / "data" / "known_rbps.csv"
|
| 561 |
+
rbps = set(g.upper() for g in pd.read_csv(rbp_path)["gene_symbol"])
|
| 562 |
+
rbps_in_depmap = rbps & set(mean_dep.index)
|
| 563 |
+
print(f" RBPs in DepMap: {len(rbps_in_depmap)}/{len(rbps)}")
|
| 564 |
+
|
| 565 |
+
# Also check A549 specifically (for sci-fate comparison)
|
| 566 |
+
model = pd.read_csv(model_path)
|
| 567 |
+
a549_rows = model[model["CellLineName"].str.contains("A549", case=False, na=False)]
|
| 568 |
+
a549_id = a549_rows.iloc[0]["ModelID"] if len(a549_rows) > 0 else None
|
| 569 |
+
|
| 570 |
+
if a549_id and a549_id in crispr.index:
|
| 571 |
+
a549_dep = crispr.loc[a549_id]
|
| 572 |
+
print(f" A549 cell line found: {a549_id}")
|
| 573 |
+
else:
|
| 574 |
+
a549_dep = None
|
| 575 |
+
print(" A549 not found in CRISPR data")
|
| 576 |
+
|
| 577 |
+
# For each dataset with network results, test hub RBPs vs non-hub RBPs
|
| 578 |
+
all_results = []
|
| 579 |
+
|
| 580 |
+
for dataset_name, network_file in [
|
| 581 |
+
("pancreas", OUTPUT_DIR.parent / "tier1_fixes" / "results" / "network_bias" / "edges_pancreas.csv"),
|
| 582 |
+
("dentate_gyrus", OUTPUT_DIR.parent / "tier1_fixes" / "results" / "network_bias" / "edges_dentate_gyrus.csv"),
|
| 583 |
+
("neuroblastoma", res_dir / "neuroblastoma_network_corrected.csv"),
|
| 584 |
+
]:
|
| 585 |
+
if not network_file.exists():
|
| 586 |
+
# Try the run_gaps output
|
| 587 |
+
alt = OUTPUT_DIR.parent / "gaps" / "results" / f"network_{dataset_name}.csv"
|
| 588 |
+
if alt.exists():
|
| 589 |
+
network_file = alt
|
| 590 |
+
else:
|
| 591 |
+
print(f"\n {dataset_name}: no network file found, skipping")
|
| 592 |
+
continue
|
| 593 |
+
|
| 594 |
+
print(f"\n === {dataset_name} ===")
|
| 595 |
+
edges = pd.read_csv(network_file)
|
| 596 |
+
print(f" Network edges: {len(edges)}")
|
| 597 |
+
|
| 598 |
+
# Count targets per RBP
|
| 599 |
+
hub_counts = edges.groupby("rbp").size().sort_values(ascending=False)
|
| 600 |
+
hub_rbps = set(hub_counts.head(20).index)
|
| 601 |
+
hub_rbps_upper = set(r.upper() for r in hub_rbps)
|
| 602 |
+
non_hub_rbps = rbps_in_depmap - hub_rbps_upper
|
| 603 |
+
|
| 604 |
+
print(f" Top 20 hub RBPs: {len(hub_rbps_upper & rbps_in_depmap)} in DepMap")
|
| 605 |
+
print(f" Non-hub RBPs: {len(non_hub_rbps)} in DepMap")
|
| 606 |
+
|
| 607 |
+
if len(hub_rbps_upper & rbps_in_depmap) < 5:
|
| 608 |
+
print(f" Too few hub RBPs in DepMap")
|
| 609 |
+
continue
|
| 610 |
+
|
| 611 |
+
# Mean dependency for hub vs non-hub
|
| 612 |
+
hub_deps = [mean_dep[g] for g in hub_rbps_upper if g in mean_dep.index]
|
| 613 |
+
nonhub_deps = [mean_dep[g] for g in non_hub_rbps if g in mean_dep.index]
|
| 614 |
+
|
| 615 |
+
hub_mean = np.mean(hub_deps)
|
| 616 |
+
nonhub_mean = np.mean(nonhub_deps)
|
| 617 |
+
u_stat, u_p = stats.mannwhitneyu(hub_deps, nonhub_deps, alternative="less")
|
| 618 |
+
|
| 619 |
+
print(f" Hub RBP mean dependency: {hub_mean:.4f} (n={len(hub_deps)})")
|
| 620 |
+
print(f" Non-hub RBP mean dependency: {nonhub_mean:.4f} (n={len(nonhub_deps)})")
|
| 621 |
+
print(f" Mann-Whitney (hub < non-hub): p = {u_p:.4f}")
|
| 622 |
+
|
| 623 |
+
if u_p < 0.05:
|
| 624 |
+
print(f" ** Hub RBPs are MORE ESSENTIAL than non-hub RBPs **")
|
| 625 |
+
|
| 626 |
+
# A549-specific comparison
|
| 627 |
+
if a549_dep is not None:
|
| 628 |
+
hub_a549 = [a549_dep[g] for g in hub_rbps_upper if g in a549_dep.index]
|
| 629 |
+
nonhub_a549 = [a549_dep[g] for g in non_hub_rbps if g in a549_dep.index]
|
| 630 |
+
if len(hub_a549) >= 5 and len(nonhub_a549) >= 5:
|
| 631 |
+
a549_hub_mean = np.mean(hub_a549)
|
| 632 |
+
a549_nonhub_mean = np.mean(nonhub_a549)
|
| 633 |
+
a549_u, a549_p = stats.mannwhitneyu(hub_a549, nonhub_a549, alternative="less")
|
| 634 |
+
print(f" A549 hub dependency: {a549_hub_mean:.4f}")
|
| 635 |
+
print(f" A549 non-hub dependency: {a549_nonhub_mean:.4f}")
|
| 636 |
+
print(f" A549 Mann-Whitney: p = {a549_p:.4f}")
|
| 637 |
+
|
| 638 |
+
# Correlation: number of targets vs dependency score
|
| 639 |
+
rbp_dep_corr = []
|
| 640 |
+
for rbp, n_targets in hub_counts.items():
|
| 641 |
+
rbp_upper = rbp.upper()
|
| 642 |
+
if rbp_upper in mean_dep.index:
|
| 643 |
+
rbp_dep_corr.append((rbp_upper, n_targets, mean_dep[rbp_upper]))
|
| 644 |
+
|
| 645 |
+
if len(rbp_dep_corr) >= 10:
|
| 646 |
+
corr_df = pd.DataFrame(rbp_dep_corr, columns=["rbp", "n_targets", "dependency"])
|
| 647 |
+
r, p = stats.spearmanr(corr_df["n_targets"], corr_df["dependency"])
|
| 648 |
+
print(f" Corr(n_targets, dependency): r={r:.4f}, p={p:.4f}")
|
| 649 |
+
|
| 650 |
+
dataset_result = {
|
| 651 |
+
"dataset": dataset_name,
|
| 652 |
+
"n_hub_rbps": len(hub_deps),
|
| 653 |
+
"n_nonhub_rbps": len(nonhub_deps),
|
| 654 |
+
"hub_mean_dep": float(hub_mean),
|
| 655 |
+
"nonhub_mean_dep": float(nonhub_mean),
|
| 656 |
+
"mannwhitney_p": float(u_p),
|
| 657 |
+
"hub_more_essential": bool(u_p < 0.05),
|
| 658 |
+
}
|
| 659 |
+
all_results.append(dataset_result)
|
| 660 |
+
|
| 661 |
+
if not all_results:
|
| 662 |
+
print(" No results to report")
|
| 663 |
+
return None
|
| 664 |
+
|
| 665 |
+
results_df = pd.DataFrame(all_results)
|
| 666 |
+
results_df.to_csv(res_dir / "depmap_validation.csv", index=False)
|
| 667 |
+
|
| 668 |
+
# Figure: hub vs non-hub dependency comparison
|
| 669 |
+
fig, axes = plt.subplots(1, len(all_results), figsize=(6 * len(all_results), 5))
|
| 670 |
+
if len(all_results) == 1:
|
| 671 |
+
axes = [axes]
|
| 672 |
+
|
| 673 |
+
for ax, res in zip(axes, all_results):
|
| 674 |
+
dataset = res["dataset"]
|
| 675 |
+
# Reload edges for this dataset
|
| 676 |
+
if dataset == "neuroblastoma":
|
| 677 |
+
nf = res_dir / "neuroblastoma_network_corrected.csv"
|
| 678 |
+
else:
|
| 679 |
+
nf = OUTPUT_DIR.parent / "tier1_fixes" / "results" / "network_bias" / f"edges_{dataset}.csv"
|
| 680 |
+
if not nf.exists():
|
| 681 |
+
continue
|
| 682 |
+
|
| 683 |
+
edges = pd.read_csv(nf)
|
| 684 |
+
hub_counts = edges.groupby("rbp").size().sort_values(ascending=False)
|
| 685 |
+
hub_rbps_upper = set(r.upper() for r in hub_counts.head(20).index)
|
| 686 |
+
non_hub = rbps_in_depmap - hub_rbps_upper
|
| 687 |
+
|
| 688 |
+
hub_vals = [mean_dep[g] for g in hub_rbps_upper if g in mean_dep.index]
|
| 689 |
+
nonhub_vals = [mean_dep[g] for g in non_hub if g in mean_dep.index]
|
| 690 |
+
|
| 691 |
+
bp = ax.boxplot([hub_vals, nonhub_vals],
|
| 692 |
+
tick_labels=["Hub RBPs\n(top 20)", "Non-hub\nRBPs"],
|
| 693 |
+
patch_artist=True, showfliers=True)
|
| 694 |
+
bp["boxes"][0].set_facecolor("steelblue")
|
| 695 |
+
bp["boxes"][1].set_facecolor("lightgray")
|
| 696 |
+
ax.axhline(y=-1, color="red", linestyle="--", alpha=0.5, label="Pan-essential threshold")
|
| 697 |
+
ax.set_ylabel("CRISPR Gene Effect (more negative = more essential)")
|
| 698 |
+
ax.set_title(f"{dataset}\np={res['mannwhitney_p']:.4f}")
|
| 699 |
+
ax.legend(fontsize=8)
|
| 700 |
+
|
| 701 |
+
fig.suptitle("DepMap Validation: Hub RBPs vs Non-Hub RBPs", fontsize=14)
|
| 702 |
+
fig.tight_layout()
|
| 703 |
+
save_fig(fig, "depmap_validation")
|
| 704 |
+
|
| 705 |
+
# Also make a scatter: n_targets vs dependency
|
| 706 |
+
fig2, ax2 = plt.subplots(figsize=(8, 6))
|
| 707 |
+
colors_map = {"pancreas": "steelblue", "dentate_gyrus": "darkgreen",
|
| 708 |
+
"neuroblastoma": "firebrick"}
|
| 709 |
+
|
| 710 |
+
for dataset_name in ["pancreas", "dentate_gyrus", "neuroblastoma"]:
|
| 711 |
+
if dataset_name == "neuroblastoma":
|
| 712 |
+
nf = res_dir / "neuroblastoma_network_corrected.csv"
|
| 713 |
+
else:
|
| 714 |
+
nf = OUTPUT_DIR.parent / "tier1_fixes" / "results" / "network_bias" / f"edges_{dataset_name}.csv"
|
| 715 |
+
if not nf.exists():
|
| 716 |
+
continue
|
| 717 |
+
edges = pd.read_csv(nf)
|
| 718 |
+
hub_counts = edges.groupby("rbp").size().sort_values(ascending=False)
|
| 719 |
+
scatter_data = []
|
| 720 |
+
for rbp, n_targets in hub_counts.items():
|
| 721 |
+
rbp_upper = rbp.upper()
|
| 722 |
+
if rbp_upper in mean_dep.index:
|
| 723 |
+
scatter_data.append((n_targets, mean_dep[rbp_upper], rbp_upper))
|
| 724 |
+
|
| 725 |
+
if scatter_data:
|
| 726 |
+
xs = [d[0] for d in scatter_data]
|
| 727 |
+
ys = [d[1] for d in scatter_data]
|
| 728 |
+
ax2.scatter(xs, ys, s=20, alpha=0.6,
|
| 729 |
+
c=colors_map.get(dataset_name, "gray"),
|
| 730 |
+
label=dataset_name)
|
| 731 |
+
# Label top hubs
|
| 732 |
+
for x, y, name in sorted(scatter_data, key=lambda d: d[0], reverse=True)[:5]:
|
| 733 |
+
ax2.annotate(name, (x, y), fontsize=7, alpha=0.7)
|
| 734 |
+
|
| 735 |
+
ax2.set_xlabel("Number of scPTR-predicted targets")
|
| 736 |
+
ax2.set_ylabel("DepMap CRISPR Gene Effect")
|
| 737 |
+
ax2.set_title("RBP Hub Size vs CRISPR Essentiality")
|
| 738 |
+
ax2.axhline(y=-0.5, color="red", linestyle="--", alpha=0.3, label="Dependency threshold")
|
| 739 |
+
ax2.legend()
|
| 740 |
+
fig2.tight_layout()
|
| 741 |
+
save_fig(fig2, "depmap_scatter")
|
| 742 |
+
|
| 743 |
+
return results_df
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
# =========================================================================
|
| 747 |
+
# MAIN
|
| 748 |
+
# =========================================================================
|
| 749 |
+
def main():
|
| 750 |
+
set_figure_style()
|
| 751 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 752 |
+
|
| 753 |
+
# T3-1: Neuroblastoma analysis
|
| 754 |
+
adata_nb = load_neuroblastoma()
|
| 755 |
+
adata_nb = run_neuroblastoma_pipeline(adata_nb)
|
| 756 |
+
nb_results = analyze_neuroblastoma(adata_nb)
|
| 757 |
+
|
| 758 |
+
# T3-2: DepMap validation
|
| 759 |
+
depmap_results = depmap_validation(nb_results)
|
| 760 |
+
|
| 761 |
+
print(f"\n{'='*60}")
|
| 762 |
+
print("ALL TIER 3 ANALYSES COMPLETE")
|
| 763 |
+
print(f"{'='*60}")
|
| 764 |
+
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
if __name__ == "__main__":
|
| 768 |
+
main()
|
analyses/run_velocity_comparison.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Generate PT velocity streamline comparison figures for pancreas and dentate gyrus.
|
| 3 |
+
|
| 4 |
+
For each dataset:
|
| 5 |
+
- Left panel: PT velocity streamlines (cell types colored underneath)
|
| 6 |
+
- Right panel: RNA velocity (scVelo) quiver from existing gap_analysis output
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import matplotlib
|
| 15 |
+
matplotlib.use("Agg")
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
import numpy as np
|
| 18 |
+
import scanpy as sc
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 21 |
+
from _common import set_figure_style
|
| 22 |
+
|
| 23 |
+
import scptr
|
| 24 |
+
|
| 25 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "velocity_comparison"
|
| 26 |
+
GAP_DIR = Path(__file__).parent.parent / "output" / "gap_analysis"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def save_fig(fig, name, subdir="figures"):
|
| 30 |
+
out_dir = OUTPUT_DIR / subdir
|
| 31 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
path = out_dir / f"{name}.png"
|
| 33 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 34 |
+
plt.close(fig)
|
| 35 |
+
print(f" Saved: {path}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_pipeline(adata, name):
|
| 39 |
+
"""Run standard scPTR pipeline."""
|
| 40 |
+
print(f"\n--- Pipeline: {name} ---")
|
| 41 |
+
scptr.pp.filter_genes(adata)
|
| 42 |
+
scptr.pp.normalize_layers(adata)
|
| 43 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 44 |
+
scptr.pp.smooth_layers(adata)
|
| 45 |
+
scptr.tl.estimate_beta(adata)
|
| 46 |
+
scptr.tl.estimate_gamma(adata)
|
| 47 |
+
scptr.tl.variance_decomposition(adata)
|
| 48 |
+
scptr.tl.pt_states(adata)
|
| 49 |
+
scptr.tl.pt_velocity(adata)
|
| 50 |
+
print(f" Done: {adata.shape}")
|
| 51 |
+
return adata
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def generate_streamline_figure(adata, name):
|
| 55 |
+
"""Generate 2-panel figure: PT velocity streamlines + RNA velocity quiver."""
|
| 56 |
+
print(f"\n Generating streamline figure for {name}...")
|
| 57 |
+
|
| 58 |
+
cluster_col = "clusters"
|
| 59 |
+
basis = "X_gamma_umap"
|
| 60 |
+
|
| 61 |
+
if basis not in adata.obsm:
|
| 62 |
+
print(f" No {basis}, computing UMAP on gamma PCA...")
|
| 63 |
+
from sklearn.decomposition import PCA
|
| 64 |
+
gamma = adata.layers["gamma"]
|
| 65 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 66 |
+
good = nonzero_frac >= 0.1
|
| 67 |
+
n_pcs = min(30, gamma.shape[0] - 1, good.sum() - 1)
|
| 68 |
+
pca = PCA(n_components=n_pcs, random_state=42)
|
| 69 |
+
gamma_pcs = pca.fit_transform(gamma[:, good])
|
| 70 |
+
adata.obsm["X_gamma_pca"] = gamma_pcs
|
| 71 |
+
sc.pp.neighbors(adata, use_rep="X_gamma_pca", key_added="gamma")
|
| 72 |
+
sc.tl.umap(adata, neighbors_key="gamma")
|
| 73 |
+
adata.obsm[basis] = adata.obsm["X_umap"].copy()
|
| 74 |
+
|
| 75 |
+
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
|
| 76 |
+
|
| 77 |
+
# Left panel: PT velocity streamlines with cell types
|
| 78 |
+
coords = adata.obsm[basis]
|
| 79 |
+
clusters = adata.obs[cluster_col]
|
| 80 |
+
|
| 81 |
+
for ci, cat in enumerate(clusters.unique()):
|
| 82 |
+
mask = (clusters == cat).values
|
| 83 |
+
axes[0].scatter(coords[mask, 0], coords[mask, 1],
|
| 84 |
+
s=3, alpha=0.2, label=cat,
|
| 85 |
+
c=[plt.cm.tab20(ci / 20)],
|
| 86 |
+
rasterized=True)
|
| 87 |
+
|
| 88 |
+
# Project velocity to 2D and build streamlines
|
| 89 |
+
from scptr.plotting._velocity import _project_velocity_to_2d
|
| 90 |
+
from scipy.ndimage import gaussian_filter
|
| 91 |
+
from scipy.stats import binned_statistic_2d
|
| 92 |
+
|
| 93 |
+
v_emb = _project_velocity_to_2d(adata, basis)
|
| 94 |
+
grid_size = 50
|
| 95 |
+
|
| 96 |
+
x_min, x_max = coords[:, 0].min(), coords[:, 0].max()
|
| 97 |
+
y_min, y_max = coords[:, 1].min(), coords[:, 1].max()
|
| 98 |
+
pad_x = (x_max - x_min) * 0.05
|
| 99 |
+
pad_y = (y_max - y_min) * 0.05
|
| 100 |
+
x_edges = np.linspace(x_min - pad_x, x_max + pad_x, grid_size + 1)
|
| 101 |
+
y_edges = np.linspace(y_min - pad_y, y_max + pad_y, grid_size + 1)
|
| 102 |
+
|
| 103 |
+
U, _, _, _ = binned_statistic_2d(
|
| 104 |
+
coords[:, 0], coords[:, 1], v_emb[:, 0],
|
| 105 |
+
statistic="mean", bins=[x_edges, y_edges])
|
| 106 |
+
V, _, _, _ = binned_statistic_2d(
|
| 107 |
+
coords[:, 0], coords[:, 1], v_emb[:, 1],
|
| 108 |
+
statistic="mean", bins=[x_edges, y_edges])
|
| 109 |
+
|
| 110 |
+
U = gaussian_filter(np.nan_to_num(U, nan=0.0), sigma=1.5)
|
| 111 |
+
V = gaussian_filter(np.nan_to_num(V, nan=0.0), sigma=1.5)
|
| 112 |
+
|
| 113 |
+
gx = 0.5 * (x_edges[:-1] + x_edges[1:])
|
| 114 |
+
gy = 0.5 * (y_edges[:-1] + y_edges[1:])
|
| 115 |
+
speed = np.sqrt(U**2 + V**2)
|
| 116 |
+
|
| 117 |
+
axes[0].streamplot(gx, gy, U.T, V.T,
|
| 118 |
+
color=speed.T, cmap="coolwarm",
|
| 119 |
+
density=1.0, linewidth=0.8, arrowsize=1.2)
|
| 120 |
+
axes[0].set_title(f"PT Velocity Streamlines: {name}")
|
| 121 |
+
axes[0].set_xlabel("UMAP 1")
|
| 122 |
+
axes[0].set_ylabel("UMAP 2")
|
| 123 |
+
axes[0].legend(fontsize=5, markerscale=3, loc="best", ncol=2)
|
| 124 |
+
|
| 125 |
+
# Right panel: PT velocity quiver (discrete arrows for comparison)
|
| 126 |
+
for ci, cat in enumerate(clusters.unique()):
|
| 127 |
+
mask = (clusters == cat).values
|
| 128 |
+
axes[1].scatter(coords[mask, 0], coords[mask, 1],
|
| 129 |
+
s=3, alpha=0.2,
|
| 130 |
+
c=[plt.cm.tab20(ci / 20)],
|
| 131 |
+
rasterized=True)
|
| 132 |
+
|
| 133 |
+
n_show = min(500, adata.n_obs)
|
| 134 |
+
idx = np.random.choice(adata.n_obs, n_show, replace=False)
|
| 135 |
+
norms = np.linalg.norm(v_emb, axis=1)
|
| 136 |
+
cap = np.percentile(norms[norms > 0], 95) if (norms > 0).any() else 1.0
|
| 137 |
+
v_scaled = v_emb / max(cap, 1e-10)
|
| 138 |
+
arrow_mask = norms[idx] > 0.01 * cap
|
| 139 |
+
|
| 140 |
+
axes[1].quiver(coords[idx[arrow_mask], 0], coords[idx[arrow_mask], 1],
|
| 141 |
+
v_scaled[idx[arrow_mask], 0], v_scaled[idx[arrow_mask], 1],
|
| 142 |
+
color="black", alpha=0.5, scale=20, width=0.003,
|
| 143 |
+
headwidth=4, headlength=5)
|
| 144 |
+
axes[1].set_title(f"PT Velocity Quiver: {name}")
|
| 145 |
+
axes[1].set_xlabel("UMAP 1")
|
| 146 |
+
axes[1].set_ylabel("UMAP 2")
|
| 147 |
+
|
| 148 |
+
fig.suptitle(f"PT Velocity Visualization: {name}", fontsize=14)
|
| 149 |
+
fig.tight_layout()
|
| 150 |
+
save_fig(fig, f"streamlines_{name}")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def main():
|
| 154 |
+
set_figure_style()
|
| 155 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
|
| 157 |
+
# Pancreas
|
| 158 |
+
print("=" * 60)
|
| 159 |
+
print("PANCREAS")
|
| 160 |
+
print("=" * 60)
|
| 161 |
+
adata_pan = scptr.datasets.pancreas()
|
| 162 |
+
adata_pan = run_pipeline(adata_pan, "pancreas")
|
| 163 |
+
generate_streamline_figure(adata_pan, "pancreas")
|
| 164 |
+
|
| 165 |
+
# Dentate Gyrus
|
| 166 |
+
print("\n" + "=" * 60)
|
| 167 |
+
print("DENTATE GYRUS")
|
| 168 |
+
print("=" * 60)
|
| 169 |
+
adata_dg = scptr.datasets.dentate_gyrus()
|
| 170 |
+
adata_dg = run_pipeline(adata_dg, "dentate_gyrus")
|
| 171 |
+
generate_streamline_figure(adata_dg, "dentate_gyrus")
|
| 172 |
+
|
| 173 |
+
print(f"\n{'='*60}")
|
| 174 |
+
print("VELOCITY COMPARISON COMPLETE")
|
| 175 |
+
print(f"{'='*60}")
|
| 176 |
+
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
if __name__ == "__main__":
|
| 180 |
+
main()
|
analyses/run_wrapup_analysis.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Comprehensive wrap-up analysis: address weaknesses, add rigor.
|
| 3 |
+
|
| 4 |
+
No retraining — uses existing fitted results + re-analyzes data.
|
| 5 |
+
|
| 6 |
+
1. Fair comparison: analytical vs DeepPTR on SAME 300 genes
|
| 7 |
+
2. Bootstrap CIs on half-life correlations
|
| 8 |
+
3. Validate PT-specific genes against eCLIP RBP targets
|
| 9 |
+
4. Examine sci-fate tautology honestly
|
| 10 |
+
5. Sparsity analysis: gamma quality vs unspliced detection rate
|
| 11 |
+
6. CI coverage breakdown: where does the posterior fail?
|
| 12 |
+
7. ARE/NMD enrichment of PT-specific genes
|
| 13 |
+
8. Honest limitations table
|
| 14 |
+
|
| 15 |
+
All results saved to output/wrapup/.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
os.environ["OMP_NUM_THREADS"] = "4"
|
| 22 |
+
os.environ["MKL_NUM_THREADS"] = "4"
|
| 23 |
+
os.environ["OPENBLAS_NUM_THREADS"] = "4"
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import sys
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import matplotlib
|
| 30 |
+
matplotlib.use("Agg")
|
| 31 |
+
import matplotlib.pyplot as plt
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pandas as pd
|
| 34 |
+
from scipy import stats
|
| 35 |
+
|
| 36 |
+
import torch
|
| 37 |
+
torch.set_num_threads(4)
|
| 38 |
+
|
| 39 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 40 |
+
from _common import set_figure_style
|
| 41 |
+
|
| 42 |
+
import scptr
|
| 43 |
+
|
| 44 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "wrapup"
|
| 45 |
+
DATA_DIR = Path(scptr.benchmark.__file__).parent / "data"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def save_fig(fig, name, subdir="figures"):
|
| 49 |
+
if fig is None:
|
| 50 |
+
return
|
| 51 |
+
out_dir = OUTPUT_DIR / subdir
|
| 52 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
path = out_dir / f"{name}.png"
|
| 54 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 55 |
+
plt.close(fig)
|
| 56 |
+
print(f" Saved: {path}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def ensure_dirs():
|
| 60 |
+
for sub in ("figures", "results"):
|
| 61 |
+
(OUTPUT_DIR / sub).mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def select_top_genes(adata, n_top=300):
|
| 65 |
+
from scipy.sparse import issparse
|
| 66 |
+
u = adata.layers["unspliced"]
|
| 67 |
+
if issparse(u):
|
| 68 |
+
u = np.asarray(u.todense())
|
| 69 |
+
u = np.asarray(u, dtype=np.float32)
|
| 70 |
+
score = u.sum(axis=0) * (u > 0).mean(axis=0)
|
| 71 |
+
top_idx = np.sort(np.argsort(score)[::-1][:n_top])
|
| 72 |
+
return adata.var_names[top_idx].tolist()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def prepare_analytical(adata_loader):
|
| 76 |
+
adata = adata_loader()
|
| 77 |
+
scptr.pp.filter_genes(adata)
|
| 78 |
+
scptr.pp.normalize_layers(adata)
|
| 79 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 80 |
+
scptr.pp.smooth_layers(adata)
|
| 81 |
+
scptr.tl.estimate_beta(adata)
|
| 82 |
+
scptr.tl.estimate_gamma(adata)
|
| 83 |
+
return adata
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ============================================================================
|
| 87 |
+
# 1. FAIR COMPARISON: SAME 300 GENES
|
| 88 |
+
# ============================================================================
|
| 89 |
+
|
| 90 |
+
def analysis_fair_comparison(adata_an, dataset_name, top_genes):
|
| 91 |
+
"""Compare half-life correlation using analytical gamma on the SAME 300 genes."""
|
| 92 |
+
print(f"\n{'=' * 60}")
|
| 93 |
+
print(f"1. FAIR COMPARISON: Same 300 genes ({dataset_name})")
|
| 94 |
+
print("=" * 60)
|
| 95 |
+
|
| 96 |
+
# Load previous DeepPTR results
|
| 97 |
+
prev_file = Path(__file__).parent.parent / "output" / "deep_benchmark" / "results" / f"{dataset_name}_benchmark.json"
|
| 98 |
+
if prev_file.exists():
|
| 99 |
+
with open(prev_file) as f:
|
| 100 |
+
prev = json.load(f)
|
| 101 |
+
else:
|
| 102 |
+
prev = {}
|
| 103 |
+
|
| 104 |
+
# Analytical on ALL genes
|
| 105 |
+
hl_mouse = scptr.datasets.herzog2017_halflives()
|
| 106 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 107 |
+
|
| 108 |
+
gamma_all = np.median(adata_an.layers["gamma"], axis=0)
|
| 109 |
+
|
| 110 |
+
# Analytical on SAME 300 genes
|
| 111 |
+
gene_mask = np.isin(adata_an.var_names, top_genes)
|
| 112 |
+
gamma_300 = gamma_all.copy()
|
| 113 |
+
gamma_300[~gene_mask] = 0 # zero out genes not in top-300
|
| 114 |
+
|
| 115 |
+
results = {}
|
| 116 |
+
for ref_name, hl_df in [("mouse", hl_mouse), ("human", hl_human)]:
|
| 117 |
+
# Full analytical
|
| 118 |
+
corr_full = scptr.benchmark.correlate_with_halflives(adata_an, hl_df)
|
| 119 |
+
|
| 120 |
+
# Analytical restricted to 300 genes (create temp adata)
|
| 121 |
+
adata_300 = adata_an[:, top_genes].copy()
|
| 122 |
+
# Need gamma layer
|
| 123 |
+
an_300_idx = [list(adata_an.var_names).index(g) for g in top_genes if g in adata_an.var_names]
|
| 124 |
+
adata_300.layers["gamma"] = adata_an.layers["gamma"][:, an_300_idx]
|
| 125 |
+
corr_300 = scptr.benchmark.correlate_with_halflives(adata_300, hl_df)
|
| 126 |
+
|
| 127 |
+
# DeepPTR from previous results
|
| 128 |
+
hl_key = "mouse_herzog" if ref_name == "mouse" else "human_schofield"
|
| 129 |
+
dp_r = prev.get("halflife", {}).get(hl_key, {}).get("deepptr", {}).get("spearman_r", np.nan)
|
| 130 |
+
dp_n = prev.get("halflife", {}).get(hl_key, {}).get("deepptr", {}).get("n_genes", 0)
|
| 131 |
+
|
| 132 |
+
results[ref_name] = {
|
| 133 |
+
"analytical_all": {"r": corr_full["spearman_r"], "n": corr_full["n_genes"]},
|
| 134 |
+
"analytical_300": {"r": corr_300["spearman_r"], "n": corr_300["n_genes"]},
|
| 135 |
+
"deepptr_300": {"r": dp_r, "n": dp_n},
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
print(f"\n {ref_name}:")
|
| 139 |
+
print(f" Analytical (all {adata_an.n_vars} genes): r={corr_full['spearman_r']:.4f} (n={corr_full['n_genes']})")
|
| 140 |
+
print(f" Analytical (same 300 genes): r={corr_300['spearman_r']:.4f} (n={corr_300['n_genes']})")
|
| 141 |
+
print(f" DeepPTR (same 300 genes): r={dp_r:.4f} (n={dp_n})")
|
| 142 |
+
|
| 143 |
+
return results
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ============================================================================
|
| 147 |
+
# 2. BOOTSTRAP CONFIDENCE INTERVALS
|
| 148 |
+
# ============================================================================
|
| 149 |
+
|
| 150 |
+
def analysis_bootstrap_ci(adata_an, dataset_name, n_boot=1000):
|
| 151 |
+
"""Bootstrap CIs on half-life correlations."""
|
| 152 |
+
print(f"\n{'=' * 60}")
|
| 153 |
+
print(f"2. BOOTSTRAP CIs ({dataset_name})")
|
| 154 |
+
print("=" * 60)
|
| 155 |
+
|
| 156 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 157 |
+
hl_s = hl_human.set_index("gene_symbol")["half_life_hours"]
|
| 158 |
+
|
| 159 |
+
gamma_med = np.median(adata_an.layers["gamma"], axis=0)
|
| 160 |
+
gamma_s = pd.Series(gamma_med, index=adata_an.var_names)
|
| 161 |
+
|
| 162 |
+
# Case-insensitive match
|
| 163 |
+
gamma_upper = {g.upper(): g for g in gamma_s.index}
|
| 164 |
+
hl_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 165 |
+
shared_upper = set(gamma_upper.keys()) & set(hl_upper.keys())
|
| 166 |
+
|
| 167 |
+
g_vals = np.array([gamma_s[gamma_upper[u]] for u in shared_upper], dtype=float)
|
| 168 |
+
h_vals = np.array([hl_s[hl_upper[u]] for u in shared_upper], dtype=float)
|
| 169 |
+
|
| 170 |
+
valid = np.isfinite(g_vals) & np.isfinite(h_vals) & (g_vals > 0) & (h_vals > 0)
|
| 171 |
+
g_vals, h_vals = g_vals[valid], h_vals[valid]
|
| 172 |
+
n = len(g_vals)
|
| 173 |
+
|
| 174 |
+
# Point estimate
|
| 175 |
+
sp_r, _ = stats.spearmanr(g_vals, h_vals)
|
| 176 |
+
|
| 177 |
+
# Bootstrap
|
| 178 |
+
rng = np.random.RandomState(42)
|
| 179 |
+
boot_rs = np.zeros(n_boot)
|
| 180 |
+
for i in range(n_boot):
|
| 181 |
+
idx = rng.choice(n, size=n, replace=True)
|
| 182 |
+
boot_rs[i], _ = stats.spearmanr(g_vals[idx], h_vals[idx])
|
| 183 |
+
|
| 184 |
+
ci_lo, ci_hi = np.percentile(boot_rs, [2.5, 97.5])
|
| 185 |
+
se = np.std(boot_rs)
|
| 186 |
+
|
| 187 |
+
print(f" Spearman r = {sp_r:.4f} (n={n})")
|
| 188 |
+
print(f" 95% CI: [{ci_lo:.4f}, {ci_hi:.4f}]")
|
| 189 |
+
print(f" Bootstrap SE: {se:.4f}")
|
| 190 |
+
|
| 191 |
+
result = {
|
| 192 |
+
"spearman_r": float(sp_r),
|
| 193 |
+
"n_genes": n,
|
| 194 |
+
"ci_95_lo": float(ci_lo),
|
| 195 |
+
"ci_95_hi": float(ci_hi),
|
| 196 |
+
"bootstrap_se": float(se),
|
| 197 |
+
}
|
| 198 |
+
return result
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ============================================================================
|
| 202 |
+
# 3. eCLIP VALIDATION OF PT-SPECIFIC GENES
|
| 203 |
+
# ============================================================================
|
| 204 |
+
|
| 205 |
+
def analysis_eclip_validation(dataset_name):
|
| 206 |
+
"""Check if PT-specific genes are enriched for eCLIP RBP targets."""
|
| 207 |
+
print(f"\n{'=' * 60}")
|
| 208 |
+
print(f"3. eCLIP VALIDATION ({dataset_name})")
|
| 209 |
+
print("=" * 60)
|
| 210 |
+
|
| 211 |
+
# Load PT-specific genes from previous analysis
|
| 212 |
+
adv_file = Path(__file__).parent.parent / "output" / "deep_advantages" / "results" / f"{dataset_name}_advantages.json"
|
| 213 |
+
if not adv_file.exists():
|
| 214 |
+
print(" [SKIP] No advantage results found")
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
with open(adv_file) as f:
|
| 218 |
+
adv = json.load(f)
|
| 219 |
+
|
| 220 |
+
pt_genes = adv.get("disentanglement", {}).get("pt_specific_genes", [])
|
| 221 |
+
if not pt_genes:
|
| 222 |
+
print(" [SKIP] No PT-specific genes")
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
# Load eCLIP targets
|
| 226 |
+
eclip = pd.read_csv(DATA_DIR / "eclip_targets.csv")
|
| 227 |
+
eclip_targets = set(eclip["target_gene"].str.upper())
|
| 228 |
+
eclip_by_rbp = eclip.groupby("rbp")["target_gene"].apply(lambda x: set(x.str.upper())).to_dict()
|
| 229 |
+
|
| 230 |
+
# Test: are PT-specific genes enriched for eCLIP targets?
|
| 231 |
+
pt_upper = set(g.upper() for g in pt_genes)
|
| 232 |
+
|
| 233 |
+
# Also load the full gene list for background
|
| 234 |
+
# Use all 300 DeepPTR genes as background
|
| 235 |
+
pt_de_genes = [g["gene"] for g in adv.get("disentanglement", {}).get("top_pt_de_genes", [])]
|
| 236 |
+
all_genes_upper = pt_upper | set(g.upper() for g in pt_de_genes)
|
| 237 |
+
|
| 238 |
+
# If we don't have enough background, we can't do enrichment
|
| 239 |
+
# Let's just count overlap
|
| 240 |
+
pt_in_eclip = pt_upper & eclip_targets
|
| 241 |
+
frac_pt = len(pt_in_eclip) / max(len(pt_upper), 1)
|
| 242 |
+
|
| 243 |
+
print(f" PT-specific genes: {len(pt_genes)}")
|
| 244 |
+
print(f" In eCLIP database: {len(pt_in_eclip)} ({frac_pt*100:.0f}%)")
|
| 245 |
+
if pt_in_eclip:
|
| 246 |
+
print(f" Validated genes: {sorted(pt_in_eclip)[:20]}")
|
| 247 |
+
|
| 248 |
+
# Per-RBP enrichment: which RBPs target PT-specific genes?
|
| 249 |
+
rbp_hits = {}
|
| 250 |
+
for rbp, targets in eclip_by_rbp.items():
|
| 251 |
+
overlap = pt_upper & targets
|
| 252 |
+
if overlap:
|
| 253 |
+
rbp_hits[rbp] = sorted(overlap)
|
| 254 |
+
|
| 255 |
+
if rbp_hits:
|
| 256 |
+
print(f"\n RBPs targeting PT-specific genes:")
|
| 257 |
+
for rbp in sorted(rbp_hits, key=lambda x: len(rbp_hits[x]), reverse=True)[:10]:
|
| 258 |
+
print(f" {rbp}: {len(rbp_hits[rbp])} targets — {rbp_hits[rbp][:5]}")
|
| 259 |
+
|
| 260 |
+
# Fisher's exact test: are PT genes more likely to be eCLIP targets than random?
|
| 261 |
+
# Background: all genes in the dataset
|
| 262 |
+
result = {
|
| 263 |
+
"n_pt_genes": len(pt_genes),
|
| 264 |
+
"n_in_eclip": len(pt_in_eclip),
|
| 265 |
+
"frac_in_eclip": frac_pt,
|
| 266 |
+
"validated_genes": sorted(pt_in_eclip),
|
| 267 |
+
"rbp_hits": {k: v for k, v in sorted(rbp_hits.items(), key=lambda x: len(x[1]), reverse=True)[:15]},
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return result
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ============================================================================
|
| 274 |
+
# 4. SCI-FATE TAUTOLOGY ANALYSIS
|
| 275 |
+
# ============================================================================
|
| 276 |
+
|
| 277 |
+
def analysis_scifate_tautology():
|
| 278 |
+
"""Honestly examine the sci-fate tautology concern.
|
| 279 |
+
|
| 280 |
+
gamma ∝ beta * Mu / Ms ∝ new / old (approximately)
|
| 281 |
+
ground truth = new / old
|
| 282 |
+
|
| 283 |
+
How much of the r=0.99 is structural vs learned?
|
| 284 |
+
"""
|
| 285 |
+
print(f"\n{'=' * 60}")
|
| 286 |
+
print("4. SCI-FATE TAUTOLOGY ANALYSIS")
|
| 287 |
+
print("=" * 60)
|
| 288 |
+
|
| 289 |
+
import gzip
|
| 290 |
+
from scipy.io import mmread
|
| 291 |
+
from scipy.sparse import csc_matrix
|
| 292 |
+
|
| 293 |
+
CACHE_DIR = Path.home() / ".cache" / "scptr" / "scifate"
|
| 294 |
+
if not CACHE_DIR.exists():
|
| 295 |
+
print(" [SKIP] sci-fate data not cached")
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
# Load data
|
| 299 |
+
cell_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_cell_annotate.txt.gz", compression="gzip")
|
| 300 |
+
gene_ann = pd.read_csv(CACHE_DIR / "GSM3770930_A549_gene_annotate.txt.gz", compression="gzip")
|
| 301 |
+
|
| 302 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count.txt.gz", "rb") as f:
|
| 303 |
+
total_mat = csc_matrix(mmread(f)).T
|
| 304 |
+
with gzip.open(CACHE_DIR / "GSM3770930_A549_gene_count_newly_synthesised.txt.gz", "rb") as f:
|
| 305 |
+
new_mat = csc_matrix(mmread(f)).T
|
| 306 |
+
|
| 307 |
+
total = np.asarray(total_mat.todense())
|
| 308 |
+
new = np.asarray(new_mat.todense())
|
| 309 |
+
old = total - new
|
| 310 |
+
|
| 311 |
+
mean_new = new.mean(axis=0)
|
| 312 |
+
mean_old = old.mean(axis=0)
|
| 313 |
+
mean_total = total.mean(axis=0)
|
| 314 |
+
|
| 315 |
+
reliable = (mean_total >= 0.5) & (mean_old > 0.1)
|
| 316 |
+
gt_ratio = np.full(total.shape[1], np.nan)
|
| 317 |
+
gt_ratio[reliable] = mean_new[reliable] / mean_old[reliable]
|
| 318 |
+
|
| 319 |
+
# The mapping: unspliced=new, spliced=old
|
| 320 |
+
# So gamma = beta * mean(new) / mean(old) [approximately, after smoothing]
|
| 321 |
+
# And ground truth = mean(new) / mean(old)
|
| 322 |
+
# Therefore gamma ≈ beta * ground_truth
|
| 323 |
+
# Correlation(gamma, ground_truth) ≈ Correlation(beta * GT, GT) = high if beta has low variance
|
| 324 |
+
|
| 325 |
+
# Compute the "trivial baseline": raw ratio new/old (no model needed)
|
| 326 |
+
trivial_ratio = np.full(total.shape[1], np.nan)
|
| 327 |
+
trivial_ratio[reliable] = mean_new[reliable] / mean_old[reliable]
|
| 328 |
+
|
| 329 |
+
# Now run the pipeline to get actual gamma
|
| 330 |
+
import anndata as ad
|
| 331 |
+
keep = mean_total >= 0.5
|
| 332 |
+
if "gene_type" in gene_ann.columns:
|
| 333 |
+
is_pc = gene_ann["gene_type"] == "protein_coding"
|
| 334 |
+
keep = keep & is_pc.values
|
| 335 |
+
|
| 336 |
+
gene_ann_indexed = gene_ann.set_index("gene_id")
|
| 337 |
+
adata = ad.AnnData(
|
| 338 |
+
X=total[:, keep].astype(np.float32),
|
| 339 |
+
obs=cell_ann.set_index("sample"),
|
| 340 |
+
var=gene_ann_indexed.iloc[keep].copy(),
|
| 341 |
+
)
|
| 342 |
+
adata.layers["unspliced"] = new[:, keep].astype(np.float32)
|
| 343 |
+
adata.layers["spliced"] = old[:, keep].astype(np.float32)
|
| 344 |
+
adata.var_names = adata.var["gene_short_name"].values
|
| 345 |
+
adata.var_names_make_unique()
|
| 346 |
+
|
| 347 |
+
scptr.pp.filter_genes(adata, min_unspliced_counts=1, min_unspliced_cells=1)
|
| 348 |
+
scptr.pp.normalize_layers(adata)
|
| 349 |
+
scptr.pp.neighbors(adata, n_neighbors=30)
|
| 350 |
+
scptr.pp.smooth_layers(adata)
|
| 351 |
+
scptr.tl.estimate_beta(adata)
|
| 352 |
+
scptr.tl.estimate_gamma(adata)
|
| 353 |
+
|
| 354 |
+
gamma_med = np.median(adata.layers["gamma"], axis=0)
|
| 355 |
+
beta_vals = adata.var["beta"].values
|
| 356 |
+
|
| 357 |
+
# Match with ground truth using case-insensitive matching
|
| 358 |
+
gamma_s = pd.Series(gamma_med, index=adata.var_names)
|
| 359 |
+
beta_s = pd.Series(beta_vals, index=adata.var_names)
|
| 360 |
+
|
| 361 |
+
# Build ground truth series indexed by gene short names (deduplicated)
|
| 362 |
+
gene_names_raw = gene_ann["gene_short_name"].values
|
| 363 |
+
gt_dict = {}
|
| 364 |
+
for i, gn in enumerate(gene_names_raw):
|
| 365 |
+
if isinstance(gn, str) and reliable[i] and gn not in gt_dict:
|
| 366 |
+
gt_dict[gn] = gt_ratio[i]
|
| 367 |
+
gt_s = pd.Series(gt_dict)
|
| 368 |
+
|
| 369 |
+
shared = gamma_s.index.intersection(gt_s.dropna().index)
|
| 370 |
+
g = gamma_s[shared].values.astype(float)
|
| 371 |
+
t = gt_s[shared].values.astype(float)
|
| 372 |
+
b = beta_s[shared].values.astype(float)
|
| 373 |
+
|
| 374 |
+
valid = np.isfinite(g) & np.isfinite(t) & (g > 0) & (t > 0) & np.isfinite(b)
|
| 375 |
+
g, t, b = g[valid], t[valid], b[valid]
|
| 376 |
+
|
| 377 |
+
# Correlations
|
| 378 |
+
r_gamma_gt, _ = stats.spearmanr(g, t) # gamma vs ground truth
|
| 379 |
+
r_trivial, _ = stats.spearmanr(t, t) # trivial = 1.0
|
| 380 |
+
|
| 381 |
+
# Partial out beta: correlation of gamma with GT controlling for beta
|
| 382 |
+
# gamma ≈ beta * GT, so gamma/beta ≈ GT
|
| 383 |
+
gamma_over_beta = g / (b + 1e-8)
|
| 384 |
+
r_residual, _ = stats.spearmanr(gamma_over_beta, t)
|
| 385 |
+
|
| 386 |
+
# How much does beta vary?
|
| 387 |
+
beta_cv = np.std(b) / np.mean(b)
|
| 388 |
+
|
| 389 |
+
# Correlation of beta with gamma (if beta is constant, gamma ∝ GT exactly)
|
| 390 |
+
r_beta_gamma, _ = stats.spearmanr(b, g)
|
| 391 |
+
|
| 392 |
+
print(f" n genes: {len(g)}")
|
| 393 |
+
print(f" gamma vs ground truth: r = {r_gamma_gt:.4f}")
|
| 394 |
+
print(f" gamma/beta vs GT: r = {r_residual:.4f}")
|
| 395 |
+
print(f" beta CV: {beta_cv:.4f}")
|
| 396 |
+
print(f" beta vs gamma: r = {r_beta_gamma:.4f}")
|
| 397 |
+
print(f"\n Interpretation:")
|
| 398 |
+
print(f" gamma = beta * (Mu/Ms) ≈ beta * (new/old) = beta * GT")
|
| 399 |
+
print(f" Since beta CV = {beta_cv:.2f}, beta adds {'modest' if beta_cv < 0.5 else 'substantial'} variation")
|
| 400 |
+
print(f" After dividing out beta, residual r = {r_residual:.4f}")
|
| 401 |
+
print(f" → The r={r_gamma_gt:.3f} correlation is {'largely' if r_residual > 0.95 else 'partially'} "
|
| 402 |
+
f"tautological")
|
| 403 |
+
|
| 404 |
+
# What scPTR ADDS beyond the trivial ratio: the smoothing, beta correction,
|
| 405 |
+
# and clipping — test if these improve the correlation
|
| 406 |
+
# Raw ratio (no smoothing, no beta): just new/old per cell, median across cells
|
| 407 |
+
raw_ratio = np.median(new[:, keep], axis=0) / np.clip(np.median(old[:, keep], axis=0), 1e-8, None)
|
| 408 |
+
raw_s = pd.Series(raw_ratio, index=adata.var_names[:len(raw_ratio)])
|
| 409 |
+
shared2 = raw_s.index.intersection(gt_s.dropna().index)
|
| 410 |
+
r_raw_vals = raw_s[shared2].values.astype(float)
|
| 411 |
+
t_raw_vals = gt_s[shared2].values.astype(float)
|
| 412 |
+
v2 = np.isfinite(r_raw_vals) & np.isfinite(t_raw_vals) & (r_raw_vals > 0) & (t_raw_vals > 0)
|
| 413 |
+
if v2.sum() > 3:
|
| 414 |
+
r_raw, _ = stats.spearmanr(r_raw_vals[v2], t_raw_vals[v2])
|
| 415 |
+
print(f"\n Raw median(new)/median(old) vs GT: r = {r_raw:.4f} (n={v2.sum()})")
|
| 416 |
+
print(f" scPTR pipeline adds: Δr = {r_gamma_gt - r_raw:.4f}")
|
| 417 |
+
else:
|
| 418 |
+
r_raw = np.nan
|
| 419 |
+
|
| 420 |
+
result = {
|
| 421 |
+
"r_gamma_gt": float(r_gamma_gt),
|
| 422 |
+
"r_gamma_over_beta_gt": float(r_residual),
|
| 423 |
+
"r_raw_ratio_gt": float(r_raw) if not np.isnan(r_raw) else None,
|
| 424 |
+
"beta_cv": float(beta_cv),
|
| 425 |
+
"r_beta_gamma": float(r_beta_gamma),
|
| 426 |
+
"n_genes": len(g),
|
| 427 |
+
"tautology_severity": "high" if r_residual > 0.98 else "moderate" if r_residual > 0.90 else "low",
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
return result
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
# ============================================================================
|
| 434 |
+
# 5. SPARSITY ANALYSIS
|
| 435 |
+
# ============================================================================
|
| 436 |
+
|
| 437 |
+
def analysis_sparsity(adata_an, dataset_name):
|
| 438 |
+
"""Does gamma quality depend on unspliced detection rate?"""
|
| 439 |
+
print(f"\n{'=' * 60}")
|
| 440 |
+
print(f"5. SPARSITY ANALYSIS ({dataset_name})")
|
| 441 |
+
print("=" * 60)
|
| 442 |
+
|
| 443 |
+
from scipy.sparse import issparse
|
| 444 |
+
|
| 445 |
+
u = adata_an.layers["unspliced"]
|
| 446 |
+
if issparse(u):
|
| 447 |
+
u = np.asarray(u.todense())
|
| 448 |
+
u = np.asarray(u)
|
| 449 |
+
|
| 450 |
+
# Per-gene: fraction of cells with unspliced > 0
|
| 451 |
+
frac_detected = (u > 0).mean(axis=0)
|
| 452 |
+
|
| 453 |
+
gamma_med = np.median(adata_an.layers["gamma"], axis=0)
|
| 454 |
+
|
| 455 |
+
# Half-life correlation stratified by detection rate
|
| 456 |
+
hl_human = scptr.datasets.schofield2018_halflives()
|
| 457 |
+
hl_s = hl_human.set_index("gene_symbol")["half_life_hours"]
|
| 458 |
+
|
| 459 |
+
gamma_upper = {g.upper(): i for i, g in enumerate(adata_an.var_names)}
|
| 460 |
+
hl_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)}
|
| 461 |
+
shared = set(gamma_upper.keys()) & set(hl_upper.keys())
|
| 462 |
+
|
| 463 |
+
g_idx = np.array([gamma_upper[u] for u in shared])
|
| 464 |
+
h_vals = np.array([hl_s[hl_upper[u]] for u in shared], dtype=float)
|
| 465 |
+
g_vals = gamma_med[g_idx]
|
| 466 |
+
det_vals = frac_detected[g_idx]
|
| 467 |
+
|
| 468 |
+
valid = np.isfinite(g_vals) & np.isfinite(h_vals) & (g_vals > 0) & (h_vals > 0)
|
| 469 |
+
g_vals, h_vals, det_vals = g_vals[valid], h_vals[valid], det_vals[valid]
|
| 470 |
+
|
| 471 |
+
# Stratify by detection quartile
|
| 472 |
+
quartiles = np.percentile(det_vals, [25, 50, 75])
|
| 473 |
+
bins = [
|
| 474 |
+
("Q1 (lowest)", det_vals <= quartiles[0]),
|
| 475 |
+
("Q2", (det_vals > quartiles[0]) & (det_vals <= quartiles[1])),
|
| 476 |
+
("Q3", (det_vals > quartiles[1]) & (det_vals <= quartiles[2])),
|
| 477 |
+
("Q4 (highest)", det_vals > quartiles[2]),
|
| 478 |
+
]
|
| 479 |
+
|
| 480 |
+
records = []
|
| 481 |
+
print(f"\n Half-life correlation by unspliced detection rate:")
|
| 482 |
+
for label, mask in bins:
|
| 483 |
+
if mask.sum() < 10:
|
| 484 |
+
continue
|
| 485 |
+
sp_r, _ = stats.spearmanr(g_vals[mask], h_vals[mask])
|
| 486 |
+
records.append({
|
| 487 |
+
"quartile": label,
|
| 488 |
+
"n_genes": int(mask.sum()),
|
| 489 |
+
"spearman_r": float(sp_r),
|
| 490 |
+
"median_detection": float(np.median(det_vals[mask])),
|
| 491 |
+
})
|
| 492 |
+
print(f" {label}: r={sp_r:.4f} (n={mask.sum()}, median det={np.median(det_vals[mask]):.2f})")
|
| 493 |
+
|
| 494 |
+
# Overall correlation: detection rate vs |gamma - halflife rank correlation|
|
| 495 |
+
r_det, p_det = stats.spearmanr(det_vals, np.abs(g_vals))
|
| 496 |
+
print(f"\n Detection rate vs |gamma|: r={r_det:.4f} (p={p_det:.2e})")
|
| 497 |
+
|
| 498 |
+
# Plot
|
| 499 |
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
| 500 |
+
|
| 501 |
+
ax = axes[0]
|
| 502 |
+
for rec in records:
|
| 503 |
+
ax.bar(rec["quartile"], abs(rec["spearman_r"]), color="steelblue", alpha=0.7)
|
| 504 |
+
ax.set_ylabel("|Spearman r| with half-life")
|
| 505 |
+
ax.set_title(f"{dataset_name}: Half-life r by detection rate")
|
| 506 |
+
ax.set_xticklabels([r["quartile"] for r in records], rotation=30, ha="right")
|
| 507 |
+
|
| 508 |
+
ax = axes[1]
|
| 509 |
+
ax.scatter(det_vals, g_vals, alpha=0.1, s=3, c="steelblue")
|
| 510 |
+
ax.set_xlabel("Unspliced detection rate")
|
| 511 |
+
ax.set_ylabel("Median gamma")
|
| 512 |
+
ax.set_title(f"Detection rate vs gamma (r={r_det:.3f})")
|
| 513 |
+
|
| 514 |
+
fig.tight_layout()
|
| 515 |
+
save_fig(fig, f"{dataset_name}_sparsity")
|
| 516 |
+
|
| 517 |
+
return {"stratified": records, "detection_gamma_r": float(r_det)}
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ============================================================================
|
| 521 |
+
# 6. CI COVERAGE BREAKDOWN
|
| 522 |
+
# ============================================================================
|
| 523 |
+
|
| 524 |
+
def analysis_ci_breakdown():
|
| 525 |
+
"""Examine where DeepPTR CI coverage fails on synthetic data."""
|
| 526 |
+
print(f"\n{'=' * 60}")
|
| 527 |
+
print("6. CI COVERAGE BREAKDOWN (synthetic)")
|
| 528 |
+
print("=" * 60)
|
| 529 |
+
|
| 530 |
+
from scptr.deep.synthetic import generate_kinetic_data
|
| 531 |
+
|
| 532 |
+
adata, truth = generate_kinetic_data(n_cells=1500, n_genes=100, seed=0)
|
| 533 |
+
|
| 534 |
+
torch.set_num_threads(4)
|
| 535 |
+
model, history = scptr.deep.fit_deepptr(
|
| 536 |
+
adata, d_T=8, d_PT=8, d_hidden=48, n_enc_layers=2,
|
| 537 |
+
batch_size=256, max_epochs=150, kl_warmup_epochs=20,
|
| 538 |
+
patience=15, n_posterior_samples=30,
|
| 539 |
+
device="cpu", seed=0, verbose=False,
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
gamma_true = truth["gamma"]
|
| 543 |
+
gamma_mean = adata.layers["gamma"]
|
| 544 |
+
gamma_var = adata.layers["gamma_var"]
|
| 545 |
+
|
| 546 |
+
z = 1.96 # 95% CI
|
| 547 |
+
std = np.sqrt(np.clip(gamma_var, 1e-10, None))
|
| 548 |
+
lower = gamma_mean - z * std
|
| 549 |
+
upper = gamma_mean + z * std
|
| 550 |
+
inside = (gamma_true >= lower) & (gamma_true <= upper)
|
| 551 |
+
|
| 552 |
+
overall_coverage = float(inside.mean())
|
| 553 |
+
print(f" Overall 95% CI coverage: {overall_coverage:.4f} (target: 0.95)")
|
| 554 |
+
|
| 555 |
+
# Per-gene coverage
|
| 556 |
+
per_gene_cov = inside.mean(axis=0)
|
| 557 |
+
# Per-cell coverage
|
| 558 |
+
per_cell_cov = inside.mean(axis=1)
|
| 559 |
+
|
| 560 |
+
# What predicts poor coverage?
|
| 561 |
+
# 1. Genes with high true gamma variance?
|
| 562 |
+
gene_gamma_std = gamma_true.std(axis=0)
|
| 563 |
+
r_cov_std, _ = stats.spearmanr(per_gene_cov, gene_gamma_std)
|
| 564 |
+
print(f" Per-gene coverage vs true gamma std: r={r_cov_std:.4f}")
|
| 565 |
+
|
| 566 |
+
# 2. Coverage by gamma magnitude
|
| 567 |
+
gene_gamma_mean = gamma_true.mean(axis=0)
|
| 568 |
+
r_cov_mean, _ = stats.spearmanr(per_gene_cov, gene_gamma_mean)
|
| 569 |
+
print(f" Per-gene coverage vs true gamma mean: r={r_cov_mean:.4f}")
|
| 570 |
+
|
| 571 |
+
# 3. Is the problem overconfidence (too narrow CI) or bias (wrong mean)?
|
| 572 |
+
error = gamma_mean - gamma_true
|
| 573 |
+
relative_error = np.abs(error) / (gamma_true + 1e-8)
|
| 574 |
+
mean_rel_error = np.median(relative_error)
|
| 575 |
+
mean_ci_width = np.median(2 * z * std)
|
| 576 |
+
mean_true_range = np.median(np.ptp(gamma_true, axis=0))
|
| 577 |
+
|
| 578 |
+
print(f"\n Diagnosis:")
|
| 579 |
+
print(f" Median relative error: {mean_rel_error:.4f}")
|
| 580 |
+
print(f" Median 95% CI width: {mean_ci_width:.4f}")
|
| 581 |
+
print(f" Median true range: {mean_true_range:.4f}")
|
| 582 |
+
print(f" → CI width / true range = {mean_ci_width / max(mean_true_range, 1e-8):.4f}")
|
| 583 |
+
print(f" → {'Overconfident (CI too narrow)' if overall_coverage < 0.5 else 'Moderate calibration'}")
|
| 584 |
+
|
| 585 |
+
result = {
|
| 586 |
+
"overall_coverage": overall_coverage,
|
| 587 |
+
"target_coverage": 0.95,
|
| 588 |
+
"per_gene_cov_vs_std_r": float(r_cov_std),
|
| 589 |
+
"per_gene_cov_vs_mean_r": float(r_cov_mean),
|
| 590 |
+
"median_relative_error": float(mean_rel_error),
|
| 591 |
+
"median_ci_width": float(mean_ci_width),
|
| 592 |
+
"median_true_range": float(mean_true_range),
|
| 593 |
+
"diagnosis": "overconfident" if overall_coverage < 0.5 else "moderate",
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
return result
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
# ============================================================================
|
| 600 |
+
# 7. ARE/NMD ENRICHMENT OF PT-SPECIFIC GENES
|
| 601 |
+
# ============================================================================
|
| 602 |
+
|
| 603 |
+
def analysis_pt_gene_enrichment():
|
| 604 |
+
"""Are PT-specific genes enriched for ARE or NMD targets?"""
|
| 605 |
+
print(f"\n{'=' * 60}")
|
| 606 |
+
print("7. ARE/NMD ENRICHMENT OF PT-SPECIFIC GENES")
|
| 607 |
+
print("=" * 60)
|
| 608 |
+
|
| 609 |
+
are_genes = set()
|
| 610 |
+
with open(DATA_DIR / "are_genes.txt") as f:
|
| 611 |
+
for line in f:
|
| 612 |
+
are_genes.add(line.strip().upper())
|
| 613 |
+
|
| 614 |
+
nmd_genes = set()
|
| 615 |
+
with open(DATA_DIR / "nmd_genes.txt") as f:
|
| 616 |
+
for line in f:
|
| 617 |
+
nmd_genes.add(line.strip().upper())
|
| 618 |
+
|
| 619 |
+
results = {}
|
| 620 |
+
for dataset_name in ("pancreas", "dentate_gyrus"):
|
| 621 |
+
adv_file = Path(__file__).parent.parent / "output" / "deep_advantages" / "results" / f"{dataset_name}_advantages.json"
|
| 622 |
+
if not adv_file.exists():
|
| 623 |
+
continue
|
| 624 |
+
|
| 625 |
+
with open(adv_file) as f:
|
| 626 |
+
adv = json.load(f)
|
| 627 |
+
|
| 628 |
+
pt_genes = adv.get("disentanglement", {}).get("pt_specific_genes", [])
|
| 629 |
+
pt_upper = set(g.upper() for g in pt_genes)
|
| 630 |
+
|
| 631 |
+
are_overlap = pt_upper & are_genes
|
| 632 |
+
nmd_overlap = pt_upper & nmd_genes
|
| 633 |
+
|
| 634 |
+
print(f"\n {dataset_name}: {len(pt_genes)} PT-specific genes")
|
| 635 |
+
print(f" ARE overlap: {len(are_overlap)} ({len(are_overlap)/max(len(pt_upper),1)*100:.0f}%)")
|
| 636 |
+
if are_overlap:
|
| 637 |
+
print(f" {sorted(are_overlap)}")
|
| 638 |
+
print(f" NMD overlap: {len(nmd_overlap)} ({len(nmd_overlap)/max(len(pt_upper),1)*100:.0f}%)")
|
| 639 |
+
if nmd_overlap:
|
| 640 |
+
print(f" {sorted(nmd_overlap)}")
|
| 641 |
+
|
| 642 |
+
results[dataset_name] = {
|
| 643 |
+
"n_pt_genes": len(pt_genes),
|
| 644 |
+
"are_overlap": sorted(are_overlap),
|
| 645 |
+
"nmd_overlap": sorted(nmd_overlap),
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
return results
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
# ============================================================================
|
| 652 |
+
# 8. HONEST LIMITATIONS TABLE
|
| 653 |
+
# ============================================================================
|
| 654 |
+
|
| 655 |
+
def print_limitations():
|
| 656 |
+
print(f"\n{'=' * 60}")
|
| 657 |
+
print("8. HONEST LIMITATIONS")
|
| 658 |
+
print("=" * 60)
|
| 659 |
+
|
| 660 |
+
limitations = [
|
| 661 |
+
("Steady-state assumption", "Violated in actively differentiating cells; dynamic mode requires velocity (circular)"),
|
| 662 |
+
("Smoothing pre-processing", "Neighbor averaging collapses per-cell variation before gamma estimation"),
|
| 663 |
+
("Beta estimation", "Upper-quantile regression is crude; beta errors propagate directly into gamma"),
|
| 664 |
+
("Half-life correlations", "r=-0.35 to -0.40 explains ~15% of variance; modest biological signal"),
|
| 665 |
+
("sci-fate tautology", "gamma ∝ new/old ≈ ground truth; high correlation is partially structural"),
|
| 666 |
+
("DeepPTR CI coverage", "27% for 95% CI; posterior is severely overconfident (amortized VI gap)"),
|
| 667 |
+
("Gene subset", "DeepPTR evaluated on 300 genes for CPU tractability; not full genome"),
|
| 668 |
+
("No method comparison", "No benchmarking against velVI, DeepVelo, scVI, or other deep methods"),
|
| 669 |
+
("Single seed", "No error bars; results may vary across random initializations"),
|
| 670 |
+
("PT-specific genes", "No external perturbation validation; could be technical artifacts"),
|
| 671 |
+
("Scalability", "Tested on 3K-7K cells; untested on modern 100K+ cell atlases"),
|
| 672 |
+
]
|
| 673 |
+
|
| 674 |
+
for name, desc in limitations:
|
| 675 |
+
print(f" {name:<25} {desc}")
|
| 676 |
+
|
| 677 |
+
return limitations
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
# ============================================================================
|
| 681 |
+
# MAIN
|
| 682 |
+
# ============================================================================
|
| 683 |
+
|
| 684 |
+
def main():
|
| 685 |
+
set_figure_style()
|
| 686 |
+
ensure_dirs()
|
| 687 |
+
|
| 688 |
+
all_results = {}
|
| 689 |
+
|
| 690 |
+
# Prepare datasets
|
| 691 |
+
datasets = [
|
| 692 |
+
("pancreas", scptr.datasets.pancreas, "clusters"),
|
| 693 |
+
("dentate_gyrus", scptr.datasets.dentate_gyrus, "clusters"),
|
| 694 |
+
]
|
| 695 |
+
|
| 696 |
+
for name, loader, cluster_key in datasets:
|
| 697 |
+
print(f"\n{'#' * 60}")
|
| 698 |
+
print(f"# {name.upper()}")
|
| 699 |
+
print(f"{'#' * 60}")
|
| 700 |
+
|
| 701 |
+
adata_an = prepare_analytical(loader)
|
| 702 |
+
top_genes = select_top_genes(adata_an, n_top=300)
|
| 703 |
+
ds_results = {}
|
| 704 |
+
|
| 705 |
+
# 1. Fair comparison
|
| 706 |
+
ds_results["fair_comparison"] = analysis_fair_comparison(adata_an, name, top_genes)
|
| 707 |
+
|
| 708 |
+
# 2. Bootstrap CIs
|
| 709 |
+
ds_results["bootstrap_ci"] = analysis_bootstrap_ci(adata_an, name)
|
| 710 |
+
|
| 711 |
+
# 3. eCLIP validation
|
| 712 |
+
ds_results["eclip_validation"] = analysis_eclip_validation(name)
|
| 713 |
+
|
| 714 |
+
# 5. Sparsity
|
| 715 |
+
ds_results["sparsity"] = analysis_sparsity(adata_an, name)
|
| 716 |
+
|
| 717 |
+
all_results[name] = ds_results
|
| 718 |
+
|
| 719 |
+
# 4. sci-fate tautology
|
| 720 |
+
all_results["scifate_tautology"] = analysis_scifate_tautology()
|
| 721 |
+
|
| 722 |
+
# 6. CI breakdown (synthetic)
|
| 723 |
+
all_results["ci_breakdown"] = analysis_ci_breakdown()
|
| 724 |
+
|
| 725 |
+
# 7. PT gene enrichment
|
| 726 |
+
all_results["pt_enrichment"] = analysis_pt_gene_enrichment()
|
| 727 |
+
|
| 728 |
+
# 8. Limitations
|
| 729 |
+
limitations = print_limitations()
|
| 730 |
+
all_results["limitations"] = [{"name": n, "description": d} for n, d in limitations]
|
| 731 |
+
|
| 732 |
+
# Save
|
| 733 |
+
with open(OUTPUT_DIR / "results" / "wrapup_results.json", "w") as f:
|
| 734 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 735 |
+
|
| 736 |
+
print(f"\n{'=' * 60}")
|
| 737 |
+
print("WRAP-UP COMPLETE")
|
| 738 |
+
print("=" * 60)
|
| 739 |
+
print(f"Results saved to: {OUTPUT_DIR}")
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
if __name__ == "__main__":
|
| 743 |
+
main()
|
example_paper.bib
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@inproceedings{langley00,
|
| 2 |
+
author = {P. Langley},
|
| 3 |
+
title = {Crafting Papers on Machine Learning},
|
| 4 |
+
year = {2000},
|
| 5 |
+
pages = {1207--1216},
|
| 6 |
+
editor = {Pat Langley},
|
| 7 |
+
booktitle = {Proceedings of the 17th International Conference
|
| 8 |
+
on Machine Learning (ICML 2000)},
|
| 9 |
+
address = {Stanford, CA},
|
| 10 |
+
publisher = {Morgan Kaufmann}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
@TechReport{mitchell80,
|
| 14 |
+
author = "T. M. Mitchell",
|
| 15 |
+
title = "The Need for Biases in Learning Generalizations",
|
| 16 |
+
institution = "Computer Science Department, Rutgers University",
|
| 17 |
+
year = "1980",
|
| 18 |
+
address = "New Brunswick, MA",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@phdthesis{kearns89,
|
| 22 |
+
author = {M. J. Kearns},
|
| 23 |
+
title = {Computational Complexity of Machine Learning},
|
| 24 |
+
school = {Department of Computer Science, Harvard University},
|
| 25 |
+
year = {1989}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
@Book{MachineLearningI,
|
| 29 |
+
editor = "R. S. Michalski and J. G. Carbonell and T.
|
| 30 |
+
M. Mitchell",
|
| 31 |
+
title = "Machine Learning: An Artificial Intelligence
|
| 32 |
+
Approach, Vol. I",
|
| 33 |
+
publisher = "Tioga",
|
| 34 |
+
year = "1983",
|
| 35 |
+
address = "Palo Alto, CA"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
@Book{DudaHart2nd,
|
| 39 |
+
author = "R. O. Duda and P. E. Hart and D. G. Stork",
|
| 40 |
+
title = "Pattern Classification",
|
| 41 |
+
publisher = "John Wiley and Sons",
|
| 42 |
+
edition = "2nd",
|
| 43 |
+
year = "2000"
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
@misc{anonymous,
|
| 47 |
+
title= {Suppressed for Anonymity},
|
| 48 |
+
author= {Author, N. N.},
|
| 49 |
+
year= {2021}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@InCollection{Newell81,
|
| 53 |
+
author = "A. Newell and P. S. Rosenbloom",
|
| 54 |
+
title = "Mechanisms of Skill Acquisition and the Law of
|
| 55 |
+
Practice",
|
| 56 |
+
booktitle = "Cognitive Skills and Their Acquisition",
|
| 57 |
+
pages = "1--51",
|
| 58 |
+
publisher = "Lawrence Erlbaum Associates, Inc.",
|
| 59 |
+
year = "1981",
|
| 60 |
+
editor = "J. R. Anderson",
|
| 61 |
+
chapter = "1",
|
| 62 |
+
address = "Hillsdale, NJ"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@Article{Samuel59,
|
| 67 |
+
author = "A. L. Samuel",
|
| 68 |
+
title = "Some Studies in Machine Learning Using the Game of
|
| 69 |
+
Checkers",
|
| 70 |
+
journal = "IBM Journal of Research and Development",
|
| 71 |
+
year = "1959",
|
| 72 |
+
volume = "3",
|
| 73 |
+
number = "3",
|
| 74 |
+
pages = "211--229"
|
| 75 |
+
}
|
example_paper.tex
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%%%%%%%% ICML 2026 EXAMPLE LATEX SUBMISSION FILE %%%%%%%%%%%%%%%%%
|
| 2 |
+
|
| 3 |
+
\documentclass{article}
|
| 4 |
+
|
| 5 |
+
% Recommended, but optional, packages for figures and better typesetting:
|
| 6 |
+
\usepackage{microtype}
|
| 7 |
+
\usepackage{graphicx}
|
| 8 |
+
\usepackage{subcaption}
|
| 9 |
+
\usepackage{booktabs} % for professional tables
|
| 10 |
+
|
| 11 |
+
% hyperref makes hyperlinks in the resulting PDF.
|
| 12 |
+
% If your build breaks (sometimes temporarily if a hyperlink spans a page)
|
| 13 |
+
% please comment out the following usepackage line and replace
|
| 14 |
+
% \usepackage{icml2026} with \usepackage[nohyperref]{icml2026} above.
|
| 15 |
+
\usepackage{hyperref}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
% Attempt to make hyperref and algorithmic work together better:
|
| 19 |
+
\newcommand{\theHalgorithm}{\arabic{algorithm}}
|
| 20 |
+
|
| 21 |
+
% Use the following line for the initial blind version submitted for review:
|
| 22 |
+
\usepackage{icml2026}
|
| 23 |
+
|
| 24 |
+
% For preprint, use
|
| 25 |
+
% \usepackage[preprint]{icml2026}
|
| 26 |
+
|
| 27 |
+
% If accepted, instead use the following line for the camera-ready submission:
|
| 28 |
+
% \usepackage[accepted]{icml2026}
|
| 29 |
+
|
| 30 |
+
\usepackage{amsmath}
|
| 31 |
+
\usepackage{amssymb}
|
| 32 |
+
\usepackage{mathtools}
|
| 33 |
+
\usepackage{amsthm}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
% if you use cleveref..
|
| 37 |
+
\usepackage[capitalize,noabbrev]{cleveref}
|
| 38 |
+
|
| 39 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 40 |
+
% THEOREMS
|
| 41 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 42 |
+
\theoremstyle{plain}
|
| 43 |
+
\newtheorem{theorem}{Theorem}[section]
|
| 44 |
+
\newtheorem{proposition}[theorem]{Proposition}
|
| 45 |
+
\newtheorem{lemma}[theorem]{Lemma}
|
| 46 |
+
\newtheorem{corollary}[theorem]{Corollary}
|
| 47 |
+
\theoremstyle{definition}
|
| 48 |
+
\newtheorem{definition}[theorem]{Definition}
|
| 49 |
+
\newtheorem{assumption}[theorem]{Assumption}
|
| 50 |
+
\theoremstyle{remark}
|
| 51 |
+
\newtheorem{remark}[theorem]{Remark}
|
| 52 |
+
|
| 53 |
+
% Todonotes is useful during development; simply uncomment the next line
|
| 54 |
+
% and comment out the line below the next line to turn off comments
|
| 55 |
+
%\usepackage[disable,textsize=tiny]{todonotes}
|
| 56 |
+
\usepackage[textsize=tiny]{todonotes}
|
| 57 |
+
|
| 58 |
+
% The \icmltitle you define below is probably too long as a header.
|
| 59 |
+
% Therefore, a short form for the running title is supplied here:
|
| 60 |
+
\icmltitlerunning{Submission and Formatting Instructions for ICML 2026}
|
| 61 |
+
|
| 62 |
+
\begin{document}
|
| 63 |
+
|
| 64 |
+
\twocolumn[
|
| 65 |
+
\icmltitle{Submission and Formatting Instructions for \\
|
| 66 |
+
International Conference on Machine Learning (ICML 2026)}
|
| 67 |
+
|
| 68 |
+
% It is OKAY to include author information, even for blind submissions: the
|
| 69 |
+
% style file will automatically remove it for you unless you've provided
|
| 70 |
+
% the [accepted] option to the icml2026 package.
|
| 71 |
+
|
| 72 |
+
% List of affiliations: The first argument should be a (short) identifier you
|
| 73 |
+
% will use later to specify author affiliations Academic affiliations
|
| 74 |
+
% should list Department, University, City, Region, Country Industry
|
| 75 |
+
% affiliations should list Company, City, Region, Country
|
| 76 |
+
|
| 77 |
+
% You can specify symbols, otherwise they are numbered in order. Ideally, you
|
| 78 |
+
% should not use this facility. Affiliations will be numbered in order of
|
| 79 |
+
% appearance and this is the preferred way.
|
| 80 |
+
\icmlsetsymbol{equal}{*}
|
| 81 |
+
|
| 82 |
+
\begin{icmlauthorlist}
|
| 83 |
+
\icmlauthor{Firstname1 Lastname1}{equal,yyy}
|
| 84 |
+
\icmlauthor{Firstname2 Lastname2}{equal,yyy,comp}
|
| 85 |
+
\icmlauthor{Firstname3 Lastname3}{comp}
|
| 86 |
+
\icmlauthor{Firstname4 Lastname4}{sch}
|
| 87 |
+
\icmlauthor{Firstname5 Lastname5}{yyy}
|
| 88 |
+
\icmlauthor{Firstname6 Lastname6}{sch,yyy,comp}
|
| 89 |
+
\icmlauthor{Firstname7 Lastname7}{comp}
|
| 90 |
+
%\icmlauthor{}{sch}
|
| 91 |
+
\icmlauthor{Firstname8 Lastname8}{sch}
|
| 92 |
+
\icmlauthor{Firstname8 Lastname8}{yyy,comp}
|
| 93 |
+
%\icmlauthor{}{sch}
|
| 94 |
+
%\icmlauthor{}{sch}
|
| 95 |
+
\end{icmlauthorlist}
|
| 96 |
+
|
| 97 |
+
\icmlaffiliation{yyy}{Department of XXX, University of YYY, Location, Country}
|
| 98 |
+
\icmlaffiliation{comp}{Company Name, Location, Country}
|
| 99 |
+
\icmlaffiliation{sch}{School of ZZZ, Institute of WWW, Location, Country}
|
| 100 |
+
|
| 101 |
+
\icmlcorrespondingauthor{Firstname1 Lastname1}{first1.last1@xxx.edu}
|
| 102 |
+
\icmlcorrespondingauthor{Firstname2 Lastname2}{first2.last2@www.uk}
|
| 103 |
+
|
| 104 |
+
% You may provide any keywords that you find helpful for describing your
|
| 105 |
+
% paper; these are used to populate the "keywords" metadata in the PDF but
|
| 106 |
+
% will not be shown in the document
|
| 107 |
+
\icmlkeywords{Machine Learning, ICML}
|
| 108 |
+
|
| 109 |
+
\vskip 0.3in
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
% this must go after the closing bracket ] following \twocolumn[ ...
|
| 113 |
+
|
| 114 |
+
% This command actually creates the footnote in the first column listing the
|
| 115 |
+
% affiliations and the copyright notice. The command takes one argument, which
|
| 116 |
+
% is text to display at the start of the footnote. The \icmlEqualContribution
|
| 117 |
+
% command is standard text for equal contribution. Remove it (just {}) if you
|
| 118 |
+
% do not need this facility.
|
| 119 |
+
|
| 120 |
+
% Use ONE of the following lines. DO NOT remove the command.
|
| 121 |
+
% If you have no special notice, KEEP empty braces:
|
| 122 |
+
\printAffiliationsAndNotice{} % no special notice (required even if empty)
|
| 123 |
+
% Or, if applicable, use the standard equal contribution text:
|
| 124 |
+
% \printAffiliationsAndNotice{\icmlEqualContribution}
|
| 125 |
+
|
| 126 |
+
\begin{abstract}
|
| 127 |
+
This document provides a basic paper template and submission guidelines.
|
| 128 |
+
Abstracts must be a single paragraph, ideally between 4--6 sentences long.
|
| 129 |
+
Gross violations will trigger corrections at the camera-ready phase.
|
| 130 |
+
\end{abstract}
|
| 131 |
+
|
| 132 |
+
\section{Electronic Submission}
|
| 133 |
+
|
| 134 |
+
Submission to ICML 2026 will be entirely electronic, via a web site
|
| 135 |
+
(not email). Information about the submission process and \LaTeX\ templates
|
| 136 |
+
are available on the conference web site at:
|
| 137 |
+
\begin{center}
|
| 138 |
+
\texttt{http://icml.cc/}
|
| 139 |
+
\end{center}
|
| 140 |
+
|
| 141 |
+
The guidelines below will be enforced for initial submissions and
|
| 142 |
+
camera-ready copies. Here is a brief summary:
|
| 143 |
+
\begin{itemize}
|
| 144 |
+
\item Submissions must be in PDF\@.
|
| 145 |
+
\item If your paper has appendices, submit the appendix together with the
|
| 146 |
+
main body and the references \textbf{as a single file}. Reviewers will not
|
| 147 |
+
look for appendices as a separate PDF file. So if you submit such an extra
|
| 148 |
+
file, reviewers will very likely miss it.
|
| 149 |
+
\item Page limit: The main body of the paper has to be fitted to 8 pages,
|
| 150 |
+
excluding references and appendices; the space for the latter two is not
|
| 151 |
+
limited in pages, but the total file size may not exceed 10MB. For the
|
| 152 |
+
final version of the paper, authors can add one extra page to the main
|
| 153 |
+
body.
|
| 154 |
+
\item \textbf{Do not include author information or acknowledgements} in your
|
| 155 |
+
initial submission.
|
| 156 |
+
\item Your paper should be in \textbf{10 point Times font}.
|
| 157 |
+
\item Make sure your PDF file only uses Type-1 fonts.
|
| 158 |
+
\item Place figure captions \emph{under} the figure (and omit titles from
|
| 159 |
+
inside the graphic file itself). Place table captions \emph{over} the
|
| 160 |
+
table.
|
| 161 |
+
\item References must include page numbers whenever possible and be as
|
| 162 |
+
complete as possible. Place multiple citations in chronological order.
|
| 163 |
+
\item Do not alter the style template; in particular, do not compress the
|
| 164 |
+
paper format by reducing the vertical spaces.
|
| 165 |
+
\item Keep your abstract brief and self-contained, one paragraph and roughly
|
| 166 |
+
4--6 sentences. Gross violations will require correction at the
|
| 167 |
+
camera-ready phase. The title should have content words capitalized.
|
| 168 |
+
\end{itemize}
|
| 169 |
+
|
| 170 |
+
\subsection{Submitting Papers}
|
| 171 |
+
|
| 172 |
+
\textbf{Anonymous Submission:} ICML uses double-blind review: no identifying
|
| 173 |
+
author information may appear on the title page or in the paper
|
| 174 |
+
itself. \cref{author info} gives further details.
|
| 175 |
+
|
| 176 |
+
\medskip
|
| 177 |
+
|
| 178 |
+
Authors must provide their manuscripts in \textbf{PDF} format.
|
| 179 |
+
Furthermore, please make sure that files contain only embedded Type-1 fonts
|
| 180 |
+
(e.g.,~using the program \texttt{pdffonts} in linux or using
|
| 181 |
+
File/DocumentProperties/Fonts in Acrobat). Other fonts (like Type-3)
|
| 182 |
+
might come from graphics files imported into the document.
|
| 183 |
+
|
| 184 |
+
Authors using \textbf{Word} must convert their document to PDF\@. Most
|
| 185 |
+
of the latest versions of Word have the facility to do this
|
| 186 |
+
automatically. Submissions will not be accepted in Word format or any
|
| 187 |
+
format other than PDF\@. Really. We're not joking. Don't send Word.
|
| 188 |
+
|
| 189 |
+
Those who use \textbf{\LaTeX} should avoid including Type-3 fonts.
|
| 190 |
+
Those using \texttt{latex} and \texttt{dvips} may need the following
|
| 191 |
+
two commands:
|
| 192 |
+
|
| 193 |
+
{\footnotesize
|
| 194 |
+
\begin{verbatim}
|
| 195 |
+
dvips -Ppdf -tletter -G0 -o paper.ps paper.dvi
|
| 196 |
+
ps2pdf paper.ps
|
| 197 |
+
\end{verbatim}}
|
| 198 |
+
It is a zero following the ``-G'', which tells dvips to use
|
| 199 |
+
the config.pdf file. Newer \TeX\ distributions don't always need this
|
| 200 |
+
option.
|
| 201 |
+
|
| 202 |
+
Using \texttt{pdflatex} rather than \texttt{latex}, often gives better
|
| 203 |
+
results. This program avoids the Type-3 font problem, and supports more
|
| 204 |
+
advanced features in the \texttt{microtype} package.
|
| 205 |
+
|
| 206 |
+
\textbf{Graphics files} should be a reasonable size, and included from
|
| 207 |
+
an appropriate format. Use vector formats (.eps/.pdf) for plots,
|
| 208 |
+
lossless bitmap formats (.png) for raster graphics with sharp lines, and
|
| 209 |
+
jpeg for photo-like images.
|
| 210 |
+
|
| 211 |
+
The style file uses the \texttt{hyperref} package to make clickable
|
| 212 |
+
links in documents. If this causes problems for you, add
|
| 213 |
+
\texttt{nohyperref} as one of the options to the \texttt{icml2026}
|
| 214 |
+
usepackage statement.
|
| 215 |
+
|
| 216 |
+
\subsection{Submitting Final Camera-Ready Copy}
|
| 217 |
+
|
| 218 |
+
The final versions of papers accepted for publication should follow the
|
| 219 |
+
same format and naming convention as initial submissions, except that
|
| 220 |
+
author information (names and affiliations) should be given. See
|
| 221 |
+
\cref{final author} for formatting instructions.
|
| 222 |
+
|
| 223 |
+
The footnote, ``Preliminary work. Under review by the International
|
| 224 |
+
Conference on Machine Learning (ICML). Do not distribute.'' must be
|
| 225 |
+
modified to ``\textit{Proceedings of the
|
| 226 |
+
$\mathit{43}^{rd}$ International Conference on Machine Learning},
|
| 227 |
+
Seoul, South Korea, PMLR 306, 2026.
|
| 228 |
+
Copyright 2026 by the author(s).''
|
| 229 |
+
|
| 230 |
+
For those using the \textbf{\LaTeX} style file, this change (and others) is
|
| 231 |
+
handled automatically by simply changing
|
| 232 |
+
$\mathtt{\backslash usepackage\{icml2026\}}$ to
|
| 233 |
+
$$\mathtt{\backslash usepackage[accepted]\{icml2026\}}$$
|
| 234 |
+
Authors using \textbf{Word} must edit the
|
| 235 |
+
footnote on the first page of the document themselves.
|
| 236 |
+
|
| 237 |
+
Camera-ready copies should have the title of the paper as running head
|
| 238 |
+
on each page except the first one. The running title consists of a
|
| 239 |
+
single line centered above a horizontal rule which is $1$~point thick.
|
| 240 |
+
The running head should be centered, bold and in $9$~point type. The
|
| 241 |
+
rule should be $10$~points above the main text. For those using the
|
| 242 |
+
\textbf{\LaTeX} style file, the original title is automatically set as running
|
| 243 |
+
head using the \texttt{fancyhdr} package which is included in the ICML
|
| 244 |
+
2026 style file package. In case that the original title exceeds the
|
| 245 |
+
size restrictions, a shorter form can be supplied by using
|
| 246 |
+
|
| 247 |
+
\verb|\icmltitlerunning{...}|
|
| 248 |
+
|
| 249 |
+
just before $\mathtt{\backslash begin\{document\}}$.
|
| 250 |
+
Authors using \textbf{Word} must edit the header of the document themselves.
|
| 251 |
+
|
| 252 |
+
\section{Format of the Paper}
|
| 253 |
+
|
| 254 |
+
All submissions must follow the specified format.
|
| 255 |
+
|
| 256 |
+
\subsection{Dimensions}
|
| 257 |
+
|
| 258 |
+
The text of the paper should be formatted in two columns, with an
|
| 259 |
+
overall width of 6.75~inches, height of 9.0~inches, and 0.25~inches
|
| 260 |
+
between the columns. The left margin should be 0.75~inches and the top
|
| 261 |
+
margin 1.0~inch (2.54~cm). The right and bottom margins will depend on
|
| 262 |
+
whether you print on US letter or A4 paper, but all final versions
|
| 263 |
+
must be produced for US letter size.
|
| 264 |
+
Do not write anything on the margins.
|
| 265 |
+
|
| 266 |
+
The paper body should be set in 10~point type with a vertical spacing
|
| 267 |
+
of 11~points. Please use Times typeface throughout the text.
|
| 268 |
+
|
| 269 |
+
\subsection{Title}
|
| 270 |
+
|
| 271 |
+
The paper title should be set in 14~point bold type and centered
|
| 272 |
+
between two horizontal rules that are 1~point thick, with 1.0~inch
|
| 273 |
+
between the top rule and the top edge of the page. Capitalize the
|
| 274 |
+
first letter of content words and put the rest of the title in lower
|
| 275 |
+
case.
|
| 276 |
+
You can use TeX math in the title (we suggest sparingly),
|
| 277 |
+
but no custom macros, images, or other TeX commands.
|
| 278 |
+
Please make sure that accents, special characters, etc., are entered using
|
| 279 |
+
TeX commands and not using non-English characters.
|
| 280 |
+
|
| 281 |
+
\subsection{Author Information for Submission}
|
| 282 |
+
\label{author info}
|
| 283 |
+
|
| 284 |
+
ICML uses double-blind review, so author information must not appear. If
|
| 285 |
+
you are using \LaTeX\/ and the \texttt{icml2026.sty} file, use
|
| 286 |
+
\verb+\icmlauthor{...}+ to specify authors and \verb+\icmlaffiliation{...}+
|
| 287 |
+
to specify affiliations. (Read the TeX code used to produce this document for
|
| 288 |
+
an example usage.) The author information will not be printed unless
|
| 289 |
+
\texttt{accepted} is passed as an argument to the style file. Submissions that
|
| 290 |
+
include the author information will not be reviewed.
|
| 291 |
+
|
| 292 |
+
\subsubsection{Self-Citations}
|
| 293 |
+
|
| 294 |
+
If you are citing published papers for which you are an author, refer
|
| 295 |
+
to yourself in the third person. In particular, do not use phrases
|
| 296 |
+
that reveal your identity (e.g., ``in previous work \cite{langley00}, we
|
| 297 |
+
have shown \ldots'').
|
| 298 |
+
|
| 299 |
+
Do not anonymize citations in the reference section. The only exception are manuscripts that are
|
| 300 |
+
not yet published (e.g., under submission). If you choose to refer to
|
| 301 |
+
such unpublished manuscripts \cite{anonymous}, anonymized copies have
|
| 302 |
+
to be submitted
|
| 303 |
+
as Supplementary Material via OpenReview\@. However, keep in mind that an ICML
|
| 304 |
+
paper should be self contained and should contain sufficient detail
|
| 305 |
+
for the reviewers to evaluate the work. In particular, reviewers are
|
| 306 |
+
not required to look at the Supplementary Material when writing their
|
| 307 |
+
review (they are not required to look at more than the first $8$ pages of the submitted document).
|
| 308 |
+
|
| 309 |
+
\subsubsection{Camera-Ready Author Information}
|
| 310 |
+
\label{final author}
|
| 311 |
+
|
| 312 |
+
If a paper is accepted, a final camera-ready copy must be prepared.
|
| 313 |
+
%
|
| 314 |
+
For camera-ready papers, author information should start 0.3~inches below the
|
| 315 |
+
bottom rule surrounding the title. The authors' names should appear in 10~point
|
| 316 |
+
bold type, in a row, separated by white space, and centered. Author names should
|
| 317 |
+
not be broken across lines. Unbolded superscripted numbers, starting 1, should
|
| 318 |
+
be used to refer to affiliations.
|
| 319 |
+
|
| 320 |
+
Affiliations should be numbered in the order of appearance. A single footnote
|
| 321 |
+
block of text should be used to list all the affiliations. (Academic
|
| 322 |
+
affiliations should list Department, University, City, State/Region, Country.
|
| 323 |
+
Similarly for industrial affiliations.)
|
| 324 |
+
|
| 325 |
+
Each distinct affiliations should be listed once. If an author has multiple
|
| 326 |
+
affiliations, multiple superscripts should be placed after the name, separated
|
| 327 |
+
by thin spaces. If the authors would like to highlight equal contribution by
|
| 328 |
+
multiple first authors, those authors should have an asterisk placed after their
|
| 329 |
+
name in superscript, and the term ``\textsuperscript{*}Equal contribution"
|
| 330 |
+
should be placed in the footnote block ahead of the list of affiliations. A
|
| 331 |
+
list of corresponding authors and their emails (in the format Full Name
|
| 332 |
+
\textless{}email@domain.com\textgreater{}) can follow the list of affiliations.
|
| 333 |
+
Ideally only one or two names should be listed.
|
| 334 |
+
|
| 335 |
+
A sample file with author names is included in the ICML2026 style file
|
| 336 |
+
package. Turn on the \texttt{[accepted]} option to the stylefile to
|
| 337 |
+
see the names rendered. All of the guidelines above are implemented
|
| 338 |
+
by the \LaTeX\ style file.
|
| 339 |
+
|
| 340 |
+
\subsection{Abstract}
|
| 341 |
+
|
| 342 |
+
The paper abstract should begin in the left column, 0.4~inches below the final
|
| 343 |
+
address. The heading `Abstract' should be centered, bold, and in 11~point type.
|
| 344 |
+
The abstract body should use 10~point type, with a vertical spacing of
|
| 345 |
+
11~points, and should be indented 0.25~inches more than normal on left-hand and
|
| 346 |
+
right-hand margins. Insert 0.4~inches of blank space after the body. Keep your
|
| 347 |
+
abstract brief and self-contained, limiting it to one paragraph and roughly 4--6
|
| 348 |
+
sentences. Gross violations will require correction at the camera-ready phase.
|
| 349 |
+
|
| 350 |
+
\subsection{Partitioning the Text}
|
| 351 |
+
|
| 352 |
+
You should organize your paper into sections and paragraphs to help readers
|
| 353 |
+
place a structure on the material and understand its contributions.
|
| 354 |
+
|
| 355 |
+
\subsubsection{Sections and Subsections}
|
| 356 |
+
|
| 357 |
+
Section headings should be numbered, flush left, and set in 11~pt bold type
|
| 358 |
+
with the content words capitalized. Leave 0.25~inches of space before the
|
| 359 |
+
heading and 0.15~inches after the heading.
|
| 360 |
+
|
| 361 |
+
Similarly, subsection headings should be numbered, flush left, and set in 10~pt
|
| 362 |
+
bold type with the content words capitalized. Leave
|
| 363 |
+
0.2~inches of space before the heading and 0.13~inches afterward.
|
| 364 |
+
|
| 365 |
+
Finally, subsubsection headings should be numbered, flush left, and set in
|
| 366 |
+
10~pt small caps with the content words capitalized. Leave
|
| 367 |
+
0.18~inches of space before the heading and 0.1~inches after the heading.
|
| 368 |
+
|
| 369 |
+
Please use no more than three levels of headings.
|
| 370 |
+
|
| 371 |
+
\subsubsection{Paragraphs and Footnotes}
|
| 372 |
+
|
| 373 |
+
Within each section or subsection, you should further partition the paper into
|
| 374 |
+
paragraphs. Do not indent the first line of a given paragraph, but insert a
|
| 375 |
+
blank line between succeeding ones.
|
| 376 |
+
|
| 377 |
+
You can use footnotes\footnote{Footnotes should be complete sentences.}
|
| 378 |
+
to provide readers with additional information about a topic without
|
| 379 |
+
interrupting the flow of the paper. Indicate footnotes with a number in the
|
| 380 |
+
text where the point is most relevant. Place the footnote in 9~point type at
|
| 381 |
+
the bottom of the column in which it appears. Precede the first footnote in a
|
| 382 |
+
column with a horizontal rule of 0.8~inches.\footnote{Multiple footnotes can
|
| 383 |
+
appear in each column, in the same order as they appear in the text,
|
| 384 |
+
but spread them across columns and pages if possible.}
|
| 385 |
+
|
| 386 |
+
\begin{figure}[ht]
|
| 387 |
+
\vskip 0.2in
|
| 388 |
+
\begin{center}
|
| 389 |
+
\centerline{\includegraphics[width=\columnwidth]{icml_numpapers}}
|
| 390 |
+
\caption{
|
| 391 |
+
Historical locations and number of accepted papers for International
|
| 392 |
+
Machine Learning Conferences (ICML 1993 -- ICML 2008) and International
|
| 393 |
+
Workshops on Machine Learning (ML 1988 -- ML 1992). At the time this
|
| 394 |
+
figure was produced, the number of accepted papers for ICML 2008 was
|
| 395 |
+
unknown and instead estimated.
|
| 396 |
+
}
|
| 397 |
+
\label{icml-historical}
|
| 398 |
+
\end{center}
|
| 399 |
+
\end{figure}
|
| 400 |
+
|
| 401 |
+
\subsection{Figures}
|
| 402 |
+
|
| 403 |
+
You may want to include figures in the paper to illustrate your approach and
|
| 404 |
+
results. Such artwork should be centered, legible, and separated from the text.
|
| 405 |
+
Lines should be dark and at least 0.5~points thick for purposes of
|
| 406 |
+
reproduction, and text should not appear on a gray background.
|
| 407 |
+
|
| 408 |
+
Label all distinct components of each figure. If the figure takes the form of a
|
| 409 |
+
graph, then give a name for each axis and include a legend that briefly
|
| 410 |
+
describes each curve. Do not include a title inside the figure; instead, the
|
| 411 |
+
caption should serve this function.
|
| 412 |
+
|
| 413 |
+
Number figures sequentially, placing the figure number and caption \emph{after}
|
| 414 |
+
the graphics, with at least 0.1~inches of space before the caption and
|
| 415 |
+
0.1~inches after it, as in \cref{icml-historical}. The figure caption should be
|
| 416 |
+
set in 9~point type and centered unless it runs two or more lines, in which
|
| 417 |
+
case it should be flush left. You may float figures to the top or bottom of a
|
| 418 |
+
column, and you may set wide figures across both columns (use the environment
|
| 419 |
+
\texttt{figure*} in \LaTeX). Always place two-column figures at the top or
|
| 420 |
+
bottom of the page.
|
| 421 |
+
|
| 422 |
+
\subsection{Algorithms}
|
| 423 |
+
|
| 424 |
+
If you are using \LaTeX, please use the ``algorithm'' and ``algorithmic''
|
| 425 |
+
environments to format pseudocode. These require the corresponding stylefiles,
|
| 426 |
+
algorithm.sty and algorithmic.sty, which are supplied with this package.
|
| 427 |
+
\cref{alg:example} shows an example.
|
| 428 |
+
|
| 429 |
+
\begin{algorithm}[tb]
|
| 430 |
+
\caption{Bubble Sort}
|
| 431 |
+
\label{alg:example}
|
| 432 |
+
\begin{algorithmic}
|
| 433 |
+
\STATE {\bfseries Input:} data $x_i$, size $m$
|
| 434 |
+
\REPEAT
|
| 435 |
+
\STATE Initialize $noChange = true$.
|
| 436 |
+
\FOR{$i=1$ {\bfseries to} $m-1$}
|
| 437 |
+
\IF{$x_i > x_{i+1}$}
|
| 438 |
+
\STATE Swap $x_i$ and $x_{i+1}$
|
| 439 |
+
\STATE $noChange = false$
|
| 440 |
+
\ENDIF
|
| 441 |
+
\ENDFOR
|
| 442 |
+
\UNTIL{$noChange$ is $true$}
|
| 443 |
+
\end{algorithmic}
|
| 444 |
+
\end{algorithm}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
\subsection{Tables}
|
| 448 |
+
|
| 449 |
+
You may also want to include tables that summarize material. Like figures,
|
| 450 |
+
these should be centered, legible, and numbered consecutively. However, place
|
| 451 |
+
the title \emph{above} the table with at least 0.1~inches of space before the
|
| 452 |
+
title and the same after it, as in \cref{sample-table}. The table title should
|
| 453 |
+
be set in 9~point type and centered unless it runs two or more lines, in which
|
| 454 |
+
case it should be flush left.
|
| 455 |
+
|
| 456 |
+
% Note use of \abovespace and \belowspace to get reasonable spacing
|
| 457 |
+
% above and below tabular lines.
|
| 458 |
+
|
| 459 |
+
\begin{table}[t]
|
| 460 |
+
\caption{Classification accuracies for naive Bayes and flexible
|
| 461 |
+
Bayes on various data sets.}
|
| 462 |
+
\label{sample-table}
|
| 463 |
+
\begin{center}
|
| 464 |
+
\begin{small}
|
| 465 |
+
\begin{sc}
|
| 466 |
+
\begin{tabular}{lcccr}
|
| 467 |
+
\toprule
|
| 468 |
+
Data set & Naive & Flexible & Better? \\
|
| 469 |
+
\midrule
|
| 470 |
+
Breast & 95.9$\pm$ 0.2 & 96.7$\pm$ 0.2 & $\surd$ \\
|
| 471 |
+
Cleveland & 83.3$\pm$ 0.6 & 80.0$\pm$ 0.6 & $\times$ \\
|
| 472 |
+
Glass2 & 61.9$\pm$ 1.4 & 83.8$\pm$ 0.7 & $\surd$ \\
|
| 473 |
+
Credit & 74.8$\pm$ 0.5 & 78.3$\pm$ 0.6 & \\
|
| 474 |
+
Horse & 73.3$\pm$ 0.9 & 69.7$\pm$ 1.0 & $\times$ \\
|
| 475 |
+
Meta & 67.1$\pm$ 0.6 & 76.5$\pm$ 0.5 & $\surd$ \\
|
| 476 |
+
Pima & 75.1$\pm$ 0.6 & 73.9$\pm$ 0.5 & \\
|
| 477 |
+
Vehicle & 44.9$\pm$ 0.6 & 61.5$\pm$ 0.4 & $\surd$ \\
|
| 478 |
+
\bottomrule
|
| 479 |
+
\end{tabular}
|
| 480 |
+
\end{sc}
|
| 481 |
+
\end{small}
|
| 482 |
+
\end{center}
|
| 483 |
+
\vskip -0.1in
|
| 484 |
+
\end{table}
|
| 485 |
+
|
| 486 |
+
Tables contain textual material, whereas figures contain graphical material.
|
| 487 |
+
Specify the contents of each row and column in the table's topmost row. Again,
|
| 488 |
+
you may float tables to a column's top or bottom, and set wide tables across
|
| 489 |
+
both columns. Place two-column tables at the top or bottom of the page.
|
| 490 |
+
|
| 491 |
+
\subsection{Theorems and Such}
|
| 492 |
+
The preferred way is to number definitions, propositions, lemmas, etc.
|
| 493 |
+
consecutively, within sections, as shown below.
|
| 494 |
+
\begin{definition}
|
| 495 |
+
\label{def:inj}
|
| 496 |
+
A function $f:X \to Y$ is injective if for any $x,y\in X$ different, $f(x)\ne
|
| 497 |
+
f(y)$.
|
| 498 |
+
\end{definition}
|
| 499 |
+
Using \cref{def:inj} we immediate get the following result:
|
| 500 |
+
\begin{proposition}
|
| 501 |
+
If $f$ is injective mapping a set $X$ to another set $Y$,
|
| 502 |
+
the cardinality of $Y$ is at least as large as that of $X$
|
| 503 |
+
\end{proposition}
|
| 504 |
+
\begin{proof}
|
| 505 |
+
Left as an exercise to the reader.
|
| 506 |
+
\end{proof}
|
| 507 |
+
\cref{lem:usefullemma} stated next will prove to be useful.
|
| 508 |
+
\begin{lemma}
|
| 509 |
+
\label{lem:usefullemma}
|
| 510 |
+
For any $f:X \to Y$ and $g:Y\to Z$ injective functions, $f \circ g$ is
|
| 511 |
+
injective.
|
| 512 |
+
\end{lemma}
|
| 513 |
+
\begin{theorem}
|
| 514 |
+
\label{thm:bigtheorem}
|
| 515 |
+
If $f:X\to Y$ is bijective, the cardinality of $X$ and $Y$ are the same.
|
| 516 |
+
\end{theorem}
|
| 517 |
+
An easy corollary of \cref{thm:bigtheorem} is the following:
|
| 518 |
+
\begin{corollary}
|
| 519 |
+
If $f:X\to Y$ is bijective,
|
| 520 |
+
the cardinality of $X$ is at least as large as that of $Y$.
|
| 521 |
+
\end{corollary}
|
| 522 |
+
\begin{assumption}
|
| 523 |
+
The set $X$ is finite.
|
| 524 |
+
\label{ass:xfinite}
|
| 525 |
+
\end{assumption}
|
| 526 |
+
\begin{remark}
|
| 527 |
+
According to some, it is only the finite case (cf. \cref{ass:xfinite}) that
|
| 528 |
+
is interesting.
|
| 529 |
+
\end{remark}
|
| 530 |
+
%restatable
|
| 531 |
+
|
| 532 |
+
\subsection{Citations and References}
|
| 533 |
+
|
| 534 |
+
Please use APA reference format regardless of your formatter or word processor.
|
| 535 |
+
If you rely on the \LaTeX\/ bibliographic facility, use \texttt{natbib.sty} and
|
| 536 |
+
\texttt{icml2026.bst} included in the style-file package to obtain this format.
|
| 537 |
+
|
| 538 |
+
Citations within the text should include the authors' last names and year. If
|
| 539 |
+
the authors' names are included in the sentence, place only the year in
|
| 540 |
+
parentheses, for example when referencing Arthur Samuel's pioneering work
|
| 541 |
+
\yrcite{Samuel59}. Otherwise place the entire reference in parentheses with the
|
| 542 |
+
authors and year separated by a comma \cite{Samuel59}. List multiple references
|
| 543 |
+
separated by semicolons \cite{kearns89,Samuel59,mitchell80}. Use the `et~al.'
|
| 544 |
+
construct only for citations with three or more authors or after listing all
|
| 545 |
+
authors to a publication in an earlier reference \cite{MachineLearningI}.
|
| 546 |
+
|
| 547 |
+
Authors should cite their own work in the third person in the initial version
|
| 548 |
+
of their paper submitted for blind review. Please refer to \cref{author info}
|
| 549 |
+
for detailed instructions on how to cite your own papers.
|
| 550 |
+
|
| 551 |
+
Use an unnumbered first-level section heading for the references, and use a
|
| 552 |
+
hanging indent style, with the first line of the reference flush against the
|
| 553 |
+
left margin and subsequent lines indented by 10 points. The references at the
|
| 554 |
+
end of this document give examples for journal articles \cite{Samuel59},
|
| 555 |
+
conference publications \cite{langley00}, book chapters \cite{Newell81}, books
|
| 556 |
+
\cite{DudaHart2nd}, edited volumes \cite{MachineLearningI}, technical reports
|
| 557 |
+
\cite{mitchell80}, and dissertations \cite{kearns89}.
|
| 558 |
+
|
| 559 |
+
Alphabetize references by the surnames of the first authors, with single author
|
| 560 |
+
entries preceding multiple author entries. Order references for the same
|
| 561 |
+
authors by year of publication, with the earliest first. Make sure that each
|
| 562 |
+
reference includes all relevant information (e.g., page numbers).
|
| 563 |
+
|
| 564 |
+
Please put some effort into making references complete, presentable, and
|
| 565 |
+
consistent, e.g. use the actual current name of authors. If using bibtex,
|
| 566 |
+
please protect capital letters of names and abbreviations in titles, for
|
| 567 |
+
example, use \{B\}ayesian or \{L\}ipschitz in your .bib file.
|
| 568 |
+
|
| 569 |
+
\section*{Accessibility}
|
| 570 |
+
|
| 571 |
+
Authors are kindly asked to make their submissions as accessible as possible
|
| 572 |
+
for everyone including people with disabilities and sensory or neurological
|
| 573 |
+
differences. Tips of how to achieve this and what to pay attention to will be
|
| 574 |
+
provided on the conference website \url{http://icml.cc/}.
|
| 575 |
+
|
| 576 |
+
\section*{Software and Data}
|
| 577 |
+
|
| 578 |
+
If a paper is accepted, we strongly encourage the publication of software and
|
| 579 |
+
data with the camera-ready version of the paper whenever appropriate. This can
|
| 580 |
+
be done by including a URL in the camera-ready copy. However, \textbf{do not}
|
| 581 |
+
include URLs that reveal your institution or identity in your submission for
|
| 582 |
+
review. Instead, provide an anonymous URL or upload the material as
|
| 583 |
+
``Supplementary Material'' into the OpenReview reviewing system. Note that
|
| 584 |
+
reviewers are not required to look at this material when writing their review.
|
| 585 |
+
|
| 586 |
+
% Acknowledgements should only appear in the accepted version.
|
| 587 |
+
\section*{Acknowledgements}
|
| 588 |
+
|
| 589 |
+
\textbf{Do not} include acknowledgements in the initial version of the paper
|
| 590 |
+
submitted for blind review.
|
| 591 |
+
|
| 592 |
+
If a paper is accepted, the final camera-ready version can (and usually should)
|
| 593 |
+
include acknowledgements. Such acknowledgements should be placed at the end of
|
| 594 |
+
the section, in an unnumbered section that does not count towards the paper
|
| 595 |
+
page limit. Typically, this will include thanks to reviewers who gave useful
|
| 596 |
+
comments, to colleagues who contributed to the ideas, and to funding agencies
|
| 597 |
+
and corporate sponsors that provided financial support.
|
| 598 |
+
|
| 599 |
+
\section*{Impact Statement}
|
| 600 |
+
|
| 601 |
+
Authors are \textbf{required} to include a statement of the potential broader
|
| 602 |
+
impact of their work, including its ethical aspects and future societal
|
| 603 |
+
consequences. This statement should be in an unnumbered section at the end of
|
| 604 |
+
the paper (co-located with Acknowledgements -- the two may appear in either
|
| 605 |
+
order, but both must be before References), and does not count toward the paper
|
| 606 |
+
page limit. In many cases, where the ethical impacts and expected societal
|
| 607 |
+
implications are those that are well established when advancing the field of
|
| 608 |
+
Machine Learning, substantial discussion is not required, and a simple
|
| 609 |
+
statement such as the following will suffice:
|
| 610 |
+
|
| 611 |
+
``This paper presents work whose goal is to advance the field of Machine
|
| 612 |
+
Learning. There are many potential societal consequences of our work, none
|
| 613 |
+
which we feel must be specifically highlighted here.''
|
| 614 |
+
|
| 615 |
+
The above statement can be used verbatim in such cases, but we encourage
|
| 616 |
+
authors to think about whether there is content which does warrant further
|
| 617 |
+
discussion, as this statement will be apparent if the paper is later flagged
|
| 618 |
+
for ethics review.
|
| 619 |
+
|
| 620 |
+
% In the unusual situation where you want a paper to appear in the
|
| 621 |
+
% references without citing it in the main text, use \nocite
|
| 622 |
+
\nocite{langley00}
|
| 623 |
+
|
| 624 |
+
\bibliography{example_paper}
|
| 625 |
+
\bibliographystyle{icml2026}
|
| 626 |
+
|
| 627 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 628 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 629 |
+
% APPENDIX
|
| 630 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 631 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 632 |
+
\newpage
|
| 633 |
+
\appendix
|
| 634 |
+
\onecolumn
|
| 635 |
+
\section{You \emph{can} have an appendix here.}
|
| 636 |
+
|
| 637 |
+
You can have as much text here as you want. The main body must be at most $8$
|
| 638 |
+
pages long. For the final version, one more page can be added. If you want, you
|
| 639 |
+
can use an appendix like this one.
|
| 640 |
+
|
| 641 |
+
The $\mathtt{\backslash onecolumn}$ command above can be kept in place if you
|
| 642 |
+
prefer a one-column appendix, or can be removed if you prefer a two-column
|
| 643 |
+
appendix. Apart from this possible change, the style (font size, spacing,
|
| 644 |
+
margins, page numbering, etc.) should be kept the same as the main body.
|
| 645 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 646 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 647 |
+
|
| 648 |
+
\end{document}
|
| 649 |
+
|
| 650 |
+
% This document was modified from the file originally made available by
|
| 651 |
+
% Pat Langley and Andrea Danyluk for ICML-2K. This version was created
|
| 652 |
+
% by Iain Murray in 2018, and modified by Alexandre Bouchard in
|
| 653 |
+
% 2019 and 2021 and by Csaba Szepesvari, Gang Niu and Sivan Sabato in 2022.
|
| 654 |
+
% Modified again in 2023 and 2024 by Sivan Sabato and Jonathan Scarlett.
|
| 655 |
+
% Previous contributors include Dan Roy, Lise Getoor and Tobias
|
| 656 |
+
% Scheffer, which was slightly modified from the 2010 version by
|
| 657 |
+
% Thorsten Joachims & Johannes Fuernkranz, slightly modified from the
|
| 658 |
+
% 2009 version by Kiri Wagstaff and Sam Roweis's 2008 version, which is
|
| 659 |
+
% slightly modified from Prasad Tadepalli's 2007 version which is a
|
| 660 |
+
% lightly changed version of the previous year's version by Andrew
|
| 661 |
+
% Moore, which was in turn edited from those of Kristian Kersting and
|
| 662 |
+
% Codrina Lauth. Alex Smola contributed to the algorithmic style files.
|
fancyhdr.sty
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%%
|
| 2 |
+
%% This is file `fancyhdr.sty',
|
| 3 |
+
%% generated with the docstrip utility.
|
| 4 |
+
%%
|
| 5 |
+
%% The original source files were:
|
| 6 |
+
%%
|
| 7 |
+
%% fancyhdr.dtx (with options: `fancyhdr')
|
| 8 |
+
%%
|
| 9 |
+
%% This is a generated file.
|
| 10 |
+
%%
|
| 11 |
+
%% This file may be distributed and/or modified under the conditions of
|
| 12 |
+
%% the LaTeX Project Public License, either version 1.3 of this license
|
| 13 |
+
%% or (at your option) any later version. The latest version of this
|
| 14 |
+
%% license is in:
|
| 15 |
+
%%
|
| 16 |
+
%% http://www.latex-project.org/lppl.txt
|
| 17 |
+
%%
|
| 18 |
+
%% and version 1.3 or later is part of all distributions of LaTeX version
|
| 19 |
+
%% 2005/12/01 or later.
|
| 20 |
+
%%
|
| 21 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 22 |
+
\NeedsTeXFormat{LaTeX2e}[2018-04-01]
|
| 23 |
+
\ProvidesPackage{fancyhdr}%
|
| 24 |
+
[2025/02/07 v5.2
|
| 25 |
+
Extensive control of page headers and footers]%
|
| 26 |
+
% Copyright (C) 1994-2025 by Pieter van Oostrum <pieter@vanoostrum.org>
|
| 27 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 28 |
+
\ifdefined\NewDocumentCommand\else\RequirePackage{xparse}\fi
|
| 29 |
+
\newif\iff@nch@check
|
| 30 |
+
\f@nch@checktrue
|
| 31 |
+
\DeclareOption{nocheck}{%
|
| 32 |
+
\f@nch@checkfalse
|
| 33 |
+
}
|
| 34 |
+
\let\f@nch@gbl\relax
|
| 35 |
+
\newif\iff@nch@compatViii
|
| 36 |
+
\DeclareOption{compatV3}{%
|
| 37 |
+
\PackageWarningNoLine{fancyhdr}{The `compatV3' option is deprecated.\MessageBreak
|
| 38 |
+
It will disappear in one of the following releases.\MessageBreak
|
| 39 |
+
Please change your document to work\MessageBreak
|
| 40 |
+
without this option}
|
| 41 |
+
\let\f@nch@gbl\global
|
| 42 |
+
\f@nch@compatViiitrue
|
| 43 |
+
}
|
| 44 |
+
\newif\iff@nch@twoside
|
| 45 |
+
\f@nch@twosidefalse
|
| 46 |
+
\DeclareOption{twoside}{%
|
| 47 |
+
\if@twoside\else\f@nch@twosidetrue\fi
|
| 48 |
+
}
|
| 49 |
+
\newcommand\f@nch@def[2]{%
|
| 50 |
+
\def\temp@a{#2}\ifx\temp@a\@empty\f@nch@gbl\def#1{}%
|
| 51 |
+
\else\f@nch@gbl\def#1{#2\strut}\fi}
|
| 52 |
+
\DeclareOption{myheadings}{%
|
| 53 |
+
\@ifundefined{chapter}{%
|
| 54 |
+
\def\ps@myheadings{\ps@f@nch@fancyproto \let\@mkboth\@gobbletwo
|
| 55 |
+
\fancyhf{}
|
| 56 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 57 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 58 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 59 |
+
\let\sectionmark\@gobble
|
| 60 |
+
\let\subsectionmark\@gobble
|
| 61 |
+
}%
|
| 62 |
+
}%
|
| 63 |
+
{\def\ps@myheadings{\ps@f@nch@fancyproto \let\@mkboth\@gobbletwo
|
| 64 |
+
\fancyhf{}
|
| 65 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 66 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 67 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 68 |
+
\let\chaptermark\@gobble
|
| 69 |
+
\let\sectionmark\@gobble
|
| 70 |
+
}%
|
| 71 |
+
}%
|
| 72 |
+
}
|
| 73 |
+
\DeclareOption{headings}{%
|
| 74 |
+
\@ifundefined{chapter}{%
|
| 75 |
+
\if@twoside
|
| 76 |
+
\def\ps@headings{\ps@f@nch@fancyproto \def\@mkboth{\protect\markboth}
|
| 77 |
+
\fancyhf{}
|
| 78 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 79 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 80 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 81 |
+
\def\sectionmark##1{%
|
| 82 |
+
\markboth{\MakeUppercase{%
|
| 83 |
+
\ifnum \c@secnumdepth >\z@ \thesection\quad \fi##1}}{}}%
|
| 84 |
+
\def\subsectionmark##1{%
|
| 85 |
+
\markright{%
|
| 86 |
+
\ifnum \c@secnumdepth >\@ne \thesubsection\quad \fi##1}}%
|
| 87 |
+
}%
|
| 88 |
+
\else
|
| 89 |
+
\def\ps@headings{\ps@f@nch@fancyproto \def\@mkboth{\protect\markboth}
|
| 90 |
+
\fancyhf{}
|
| 91 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 92 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 93 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 94 |
+
\def\sectionmark##1{%
|
| 95 |
+
\markright {\MakeUppercase{%
|
| 96 |
+
\ifnum \c@secnumdepth >\z@ \thesection\quad \fi##1}}}%
|
| 97 |
+
\let\subsectionmark\@gobble % Not needed but inserted for safety
|
| 98 |
+
}%
|
| 99 |
+
\fi
|
| 100 |
+
}{\if@twoside
|
| 101 |
+
\def\ps@headings{\ps@f@nch@fancyproto \def\@mkboth{\protect\markboth}
|
| 102 |
+
\fancyhf{}
|
| 103 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 104 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 105 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 106 |
+
\def\chaptermark##1{%
|
| 107 |
+
\markboth{\MakeUppercase{%
|
| 108 |
+
\ifnum \c@secnumdepth >\m@ne \if@mainmatter
|
| 109 |
+
\@chapapp\ \thechapter. \ \fi\fi##1}}{}}%
|
| 110 |
+
\def\sectionmark##1{%
|
| 111 |
+
\markright {\MakeUppercase{%
|
| 112 |
+
\ifnum \c@secnumdepth >\z@ \thesection. \ \fi##1}}}%
|
| 113 |
+
}%
|
| 114 |
+
\else
|
| 115 |
+
\def\ps@headings{\ps@f@nch@fancyproto \def\@mkboth{\protect\markboth}
|
| 116 |
+
\fancyhf{}
|
| 117 |
+
\fancyhead[LE,RO]{\thepage}%
|
| 118 |
+
\fancyhead[RE]{\slshape\leftmark}%
|
| 119 |
+
\fancyhead[LO]{\slshape\rightmark}%
|
| 120 |
+
\def\chaptermark##1{%
|
| 121 |
+
\markright{\MakeUppercase{%
|
| 122 |
+
\ifnum \c@secnumdepth >\m@ne \if@mainmatter
|
| 123 |
+
\@chapapp\ \thechapter. \ \fi\fi##1}}}%
|
| 124 |
+
\let\sectionmark\@gobble % Not needed but inserted for safety
|
| 125 |
+
}%
|
| 126 |
+
\fi
|
| 127 |
+
}%
|
| 128 |
+
}
|
| 129 |
+
\ProcessOptions*
|
| 130 |
+
\newcommand{\f@nch@forc}[3]{\expandafter\f@nchf@rc\expandafter#1\expandafter{#2}{#3}}
|
| 131 |
+
\newcommand{\f@nchf@rc}[3]{\def\temp@ty{#2}\ifx\@empty\temp@ty\else
|
| 132 |
+
\f@nch@rc#1#2\f@nch@rc{#3}\fi}
|
| 133 |
+
\long\def\f@nch@rc#1#2#3\f@nch@rc#4{\def#1{#2}#4\f@nchf@rc#1{#3}{#4}}
|
| 134 |
+
\newcommand{\f@nch@for}[3]{\edef\@fortmp{#2}%
|
| 135 |
+
\expandafter\@forloop#2,\@nil,\@nil\@@#1{#3}}
|
| 136 |
+
\newcommand\f@nch@default[3]{%
|
| 137 |
+
\edef\temp@a{\lowercase{\edef\noexpand\temp@a{#3}}}\temp@a \def#1{}%
|
| 138 |
+
\f@nch@forc\tmpf@ra{#2}%
|
| 139 |
+
{\expandafter\f@nch@ifin\tmpf@ra\temp@a{\edef#1{#1\tmpf@ra}}{}}%
|
| 140 |
+
\ifx\@empty#1\def#1{#2}\fi}
|
| 141 |
+
\newcommand{\f@nch@ifin}[4]{%
|
| 142 |
+
\edef\temp@a{#2}\def\temp@b##1#1##2\temp@b{\def\temp@b{##1}}%
|
| 143 |
+
\expandafter\temp@b#2#1\temp@b\ifx\temp@a\temp@b #4\else #3\fi}
|
| 144 |
+
\newcommand{\fancyhead}[2][]{\f@nch@fancyhf\fancyhead h[#1]{#2}}%
|
| 145 |
+
\newcommand{\fancyfoot}[2][]{\f@nch@fancyhf\fancyfoot f[#1]{#2}}%
|
| 146 |
+
\newcommand{\fancyhf}[2][]{\f@nch@fancyhf\fancyhf {}[#1]{#2}}%
|
| 147 |
+
\newcommand{\fancyheadoffset}[2][]{\f@nch@fancyhfoffs\fancyheadoffset h[#1]{#2}}%
|
| 148 |
+
\newcommand{\fancyfootoffset}[2][]{\f@nch@fancyhfoffs\fancyfootoffset f[#1]{#2}}%
|
| 149 |
+
\newcommand{\fancyhfoffset}[2][]{\f@nch@fancyhfoffs\fancyhfoffset {}[#1]{#2}}%
|
| 150 |
+
\def\f@nch@fancyhf@Echeck#1{%
|
| 151 |
+
\if@twoside\else
|
| 152 |
+
\iff@nch@twoside\else
|
| 153 |
+
\if\f@nch@@eo e%
|
| 154 |
+
\PackageWarning{fancyhdr} {\string#1's `E' option without twoside option is useless.\MessageBreak
|
| 155 |
+
Please consider using the `twoside' option}%
|
| 156 |
+
\fi\fi\fi
|
| 157 |
+
}
|
| 158 |
+
\long\def\f@nch@fancyhf#1#2[#3]#4{%
|
| 159 |
+
\def\temp@c{}%
|
| 160 |
+
\f@nch@forc\tmpf@ra{#3}%
|
| 161 |
+
{\expandafter\f@nch@ifin\tmpf@ra{eolcrhf,EOLCRHF}%
|
| 162 |
+
{}{\edef\temp@c{\temp@c\tmpf@ra}}}%
|
| 163 |
+
\ifx\@empty\temp@c\else \PackageError{fancyhdr}{Illegal char `\temp@c' in
|
| 164 |
+
\string#1 argument: [#3]}{}%
|
| 165 |
+
\fi \f@nch@for\temp@c{#3}%
|
| 166 |
+
{\f@nch@default\f@nch@@eo{eo}\temp@c
|
| 167 |
+
\f@nch@fancyhf@Echeck{#1}%
|
| 168 |
+
\f@nch@default\f@nch@@lcr{lcr}\temp@c
|
| 169 |
+
\f@nch@default\f@nch@@hf{hf}{#2\temp@c}%
|
| 170 |
+
\f@nch@forc\f@nch@eo\f@nch@@eo
|
| 171 |
+
{\f@nch@forc\f@nch@lcr\f@nch@@lcr
|
| 172 |
+
{\f@nch@forc\f@nch@hf\f@nch@@hf
|
| 173 |
+
{\expandafter\f@nch@def\csname
|
| 174 |
+
f@nch@\f@nch@eo\f@nch@lcr\f@nch@hf\endcsname {#4}}}}}}
|
| 175 |
+
\def\f@nch@fancyhfoffs#1#2[#3]#4{%
|
| 176 |
+
\def\temp@c{}%
|
| 177 |
+
\f@nch@forc\tmpf@ra{#3}%
|
| 178 |
+
{\expandafter\f@nch@ifin\tmpf@ra{eolrhf,EOLRHF}%
|
| 179 |
+
{}{\edef\temp@c{\temp@c\tmpf@ra}}}%
|
| 180 |
+
\ifx\@empty\temp@c\else \PackageError{fancyhdr}{Illegal char `\temp@c' in
|
| 181 |
+
\string#1 argument: [#3]}{}%
|
| 182 |
+
\fi \f@nch@for\temp@c{#3}%
|
| 183 |
+
{\f@nch@default\f@nch@@eo{eo}\temp@c
|
| 184 |
+
\f@nch@fancyhf@Echeck{#1}%
|
| 185 |
+
\f@nch@default\f@nch@@lcr{lr}\temp@c
|
| 186 |
+
\f@nch@default\f@nch@@hf{hf}{#2\temp@c}%
|
| 187 |
+
\f@nch@forc\f@nch@eo\f@nch@@eo
|
| 188 |
+
{\f@nch@forc\f@nch@lcr\f@nch@@lcr
|
| 189 |
+
{\f@nch@forc\f@nch@hf\f@nch@@hf
|
| 190 |
+
{\expandafter\setlength\csname
|
| 191 |
+
f@nch@offset@\f@nch@eo\f@nch@lcr\f@nch@hf\endcsname {#4}}}}}%
|
| 192 |
+
\f@nch@setoffs}
|
| 193 |
+
\NewDocumentCommand {\fancyheadwidth}{ s O{} O{} m }
|
| 194 |
+
{\f@nch@fancyhfwidth{#1}\fancyheadwidth h[#2][#3]{#4}}%
|
| 195 |
+
\NewDocumentCommand {\fancyfootwidth}{ s O{} O{} m }
|
| 196 |
+
{\f@nch@fancyhfwidth{#1}\fancyfootwidth f[#2][#3]{#4}}%
|
| 197 |
+
\NewDocumentCommand {\fancyhfwidth} { s O{} O{} m }
|
| 198 |
+
{\f@nch@fancyhfwidth{#1}\fancyhfwidth {}[#2][#3]{#4}}%
|
| 199 |
+
\def\f@nch@fancyhfwidth#1#2#3[#4][#5]#6{%
|
| 200 |
+
\setlength\@tempdima{#6}%
|
| 201 |
+
\def\temp@c{}%
|
| 202 |
+
\f@nch@forc\tmpf@ra{#4}%
|
| 203 |
+
{\expandafter\f@nch@ifin\tmpf@ra{eolcrhf,EOLCRHF}%
|
| 204 |
+
{}{\edef\temp@c{\temp@c\tmpf@ra}}}%
|
| 205 |
+
\ifx\@empty\temp@c\else \PackageError{fancyhdr}{Illegal char `\temp@c' in
|
| 206 |
+
\string#2 argument: [#4]}{}%
|
| 207 |
+
\fi
|
| 208 |
+
\f@nch@for\temp@c{#4}%
|
| 209 |
+
{\f@nch@default\f@nch@@eo{eo}\temp@c
|
| 210 |
+
\f@nch@fancyhf@Echeck{#2}%
|
| 211 |
+
\f@nch@default\f@nch@@lcr{lcr}\temp@c
|
| 212 |
+
\f@nch@default\f@nch@@hf{hf}{#3\temp@c}%
|
| 213 |
+
\f@nch@forc\f@nch@eo\f@nch@@eo
|
| 214 |
+
{\f@nch@forc\f@nch@lcr\f@nch@@lcr
|
| 215 |
+
{\f@nch@forc\f@nch@hf\f@nch@@hf
|
| 216 |
+
{%
|
| 217 |
+
\IfBooleanTF{#1}{%
|
| 218 |
+
\expandafter\edef\csname
|
| 219 |
+
f@nch@width@\f@nch@eo\f@nch@lcr\f@nch@hf\endcsname{\the\@tempdima}%
|
| 220 |
+
}%
|
| 221 |
+
{%
|
| 222 |
+
\expandafter\def\csname
|
| 223 |
+
f@nch@width@\f@nch@eo\f@nch@lcr\f@nch@hf\endcsname{#6}%
|
| 224 |
+
}%
|
| 225 |
+
\csname f@nchdrwdt@align@v@\f@nch@hf\endcsname
|
| 226 |
+
\edef\f@nch@align@@h{\f@nch@lcr}%
|
| 227 |
+
\def\temp@a{#5}%
|
| 228 |
+
\ifx\temp@a\@empty \else \f@nchdrwdt@align#5\@nil{#2}\fi
|
| 229 |
+
\expandafter\edef\csname
|
| 230 |
+
f@nch@align@\f@nch@eo\f@nch@lcr\f@nch@hf\endcsname
|
| 231 |
+
{\f@nch@align@@v\f@nch@align@@h}}}}}}
|
| 232 |
+
\def\f@nch@width@elh{\headwidth}
|
| 233 |
+
\def\f@nch@width@ech{\headwidth}
|
| 234 |
+
\def\f@nch@width@erh{\headwidth}
|
| 235 |
+
\def\f@nch@width@olh{\headwidth}
|
| 236 |
+
\def\f@nch@width@och{\headwidth}
|
| 237 |
+
\def\f@nch@width@orh{\headwidth}
|
| 238 |
+
\def\f@nch@width@elf{\headwidth}
|
| 239 |
+
\def\f@nch@width@ecf{\headwidth}
|
| 240 |
+
\def\f@nch@width@erf{\headwidth}
|
| 241 |
+
\def\f@nch@width@olf{\headwidth}
|
| 242 |
+
\def\f@nch@width@ocf{\headwidth}
|
| 243 |
+
\def\f@nch@width@orf{\headwidth}
|
| 244 |
+
\def\f@nch@align@elh{bl}
|
| 245 |
+
\def\f@nch@align@ech{bc}
|
| 246 |
+
\def\f@nch@align@erh{br}
|
| 247 |
+
\def\f@nch@align@olh{bl}
|
| 248 |
+
\def\f@nch@align@och{bc}
|
| 249 |
+
\def\f@nch@align@orh{br}
|
| 250 |
+
\def\f@nch@align@elf{tl}
|
| 251 |
+
\def\f@nch@align@ecf{tc}
|
| 252 |
+
\def\f@nch@align@erf{tr}
|
| 253 |
+
\def\f@nch@align@olf{tl}
|
| 254 |
+
\def\f@nch@align@ocf{tc}
|
| 255 |
+
\def\f@nch@align@orf{tr}
|
| 256 |
+
\def\f@nchdrwdt@align@v@h{\def\f@nch@align@@v{b}}%
|
| 257 |
+
\def\f@nchdrwdt@align@v@f{\def\f@nch@align@@v{t}}%
|
| 258 |
+
\long\def\f@nchdrwdt@align#1#2\@nil#3{%
|
| 259 |
+
\f@nch@ifin{#1}{TtcbB-}{%
|
| 260 |
+
\f@nch@ifin{#1}{-}{}{\def\f@nch@align@@v{#1}}%
|
| 261 |
+
\def\@tempa{#2}%
|
| 262 |
+
\ifx\@tempa\@empty \else \def\f@nch@align@@h{#2}\fi
|
| 263 |
+
}%
|
| 264 |
+
{\def\f@nch@align@@h{#1}}%
|
| 265 |
+
\expandafter\f@nch@ifin\expandafter{\f@nch@align@@h}{lcrj}{}%
|
| 266 |
+
{\PackageError{fancyhdr}
|
| 267 |
+
{\string#3: Illegal char `\f@nch@align@@h'\MessageBreak
|
| 268 |
+
in alignment argument}{}}%
|
| 269 |
+
}
|
| 270 |
+
\newcommand{\lhead}[2][\f@nch@olh]%
|
| 271 |
+
{\f@nch@def\f@nch@olh{#2}\f@nch@def\f@nch@elh{#1}}
|
| 272 |
+
\newcommand{\chead}[2][\f@nch@och]%
|
| 273 |
+
{\f@nch@def\f@nch@och{#2}\f@nch@def\f@nch@ech{#1}}
|
| 274 |
+
\newcommand{\rhead}[2][\f@nch@orh]%
|
| 275 |
+
{\f@nch@def\f@nch@orh{#2}\f@nch@def\f@nch@erh{#1}}
|
| 276 |
+
\newcommand{\lfoot}[2][\f@nch@olf]%
|
| 277 |
+
{\f@nch@def\f@nch@olf{#2}\f@nch@def\f@nch@elf{#1}}
|
| 278 |
+
\newcommand{\cfoot}[2][\f@nch@ocf]%
|
| 279 |
+
{\f@nch@def\f@nch@ocf{#2}\f@nch@def\f@nch@ecf{#1}}
|
| 280 |
+
\newcommand{\rfoot}[2][\f@nch@orf]%
|
| 281 |
+
{\f@nch@def\f@nch@orf{#2}\f@nch@def\f@nch@erf{#1}}
|
| 282 |
+
\newlength{\f@nch@headwidth} \let\headwidth\f@nch@headwidth
|
| 283 |
+
\newlength{\f@nch@offset@elh}
|
| 284 |
+
\newlength{\f@nch@offset@erh}
|
| 285 |
+
\newlength{\f@nch@offset@olh}
|
| 286 |
+
\newlength{\f@nch@offset@orh}
|
| 287 |
+
\newlength{\f@nch@offset@elf}
|
| 288 |
+
\newlength{\f@nch@offset@erf}
|
| 289 |
+
\newlength{\f@nch@offset@olf}
|
| 290 |
+
\newlength{\f@nch@offset@orf}
|
| 291 |
+
\newcommand{\headrulewidth}{0.4pt}
|
| 292 |
+
\newcommand{\footrulewidth}{0pt}
|
| 293 |
+
\@ifundefined{headruleskip}%
|
| 294 |
+
{\newcommand{\headruleskip}{0pt}}{}
|
| 295 |
+
\@ifundefined{footruleskip}%
|
| 296 |
+
{\newcommand{\footruleskip}{.3\normalbaselineskip}}{}
|
| 297 |
+
\newcommand{\plainheadrulewidth}{0pt}
|
| 298 |
+
\newcommand{\plainfootrulewidth}{0pt}
|
| 299 |
+
\newif\if@fancyplain \@fancyplainfalse
|
| 300 |
+
\def\fancyplain#1#2{\if@fancyplain#1\else#2\fi}
|
| 301 |
+
\headwidth=-123456789sp
|
| 302 |
+
\let\f@nch@raggedleft\raggedleft
|
| 303 |
+
\let\f@nch@raggedright\raggedright
|
| 304 |
+
\let\f@nch@centering\centering
|
| 305 |
+
\let\f@nch@everypar\everypar
|
| 306 |
+
\ifdefined\ExplSyntaxOn
|
| 307 |
+
\ExplSyntaxOn
|
| 308 |
+
\providecommand\IfFormatAtLeastTF{\@ifl@t@r\fmtversion}
|
| 309 |
+
\IfFormatAtLeastTF{2021-06-01}{
|
| 310 |
+
\def\f@nch@saveclr@parhook #1{
|
| 311 |
+
\expandafter\let\csname f@nch@__hook~#1\expandafter\endcsname
|
| 312 |
+
\csname __hook~#1\endcsname
|
| 313 |
+
\expandafter\let\csname f@nch@__hook_toplevel~#1\expandafter\endcsname
|
| 314 |
+
\csname __hook_toplevel~#1\endcsname
|
| 315 |
+
\expandafter\let\csname f@nch@__hook_next~#1\expandafter\endcsname
|
| 316 |
+
\csname __hook_next~#1\endcsname
|
| 317 |
+
\expandafter\let\csname f@nch@g__hook_#1_code_prop\expandafter\endcsname
|
| 318 |
+
\csname g__hook_#1_code_prop\endcsname
|
| 319 |
+
\RemoveFromHook{#1}[*]
|
| 320 |
+
\ClearHookNext{#1}
|
| 321 |
+
}
|
| 322 |
+
\def\f@nch@restore@parhook #1{
|
| 323 |
+
\global\expandafter\let\csname __hook~#1\expandafter\endcsname
|
| 324 |
+
\csname f@nch@__hook~#1\endcsname
|
| 325 |
+
\global\expandafter\let\csname __hook_toplevel~#1\expandafter\endcsname
|
| 326 |
+
\csname f@nch@__hook_toplevel~#1\endcsname
|
| 327 |
+
\global\expandafter\let\csname __hook_next~#1\expandafter\endcsname
|
| 328 |
+
\csname f@nch@__hook_next~#1\endcsname
|
| 329 |
+
\global\expandafter\let\csname g__hook_#1_code_prop\expandafter\endcsname
|
| 330 |
+
\csname f@nch@g__hook_#1_code_prop\endcsname
|
| 331 |
+
}
|
| 332 |
+
\def\f@nch@resetpar{
|
| 333 |
+
\f@nch@everypar{}
|
| 334 |
+
\f@nch@saveclr@parhook{para/before}
|
| 335 |
+
\f@nch@saveclr@parhook{para/begin}
|
| 336 |
+
\f@nch@saveclr@parhook{para/end}
|
| 337 |
+
\f@nch@saveclr@parhook{para/after}
|
| 338 |
+
}
|
| 339 |
+
\def\f@nch@restorepar{
|
| 340 |
+
\f@nch@restore@parhook{para/before}
|
| 341 |
+
\f@nch@restore@parhook{para/begin}
|
| 342 |
+
\f@nch@restore@parhook{para/end}
|
| 343 |
+
\f@nch@restore@parhook{para/after}
|
| 344 |
+
}
|
| 345 |
+
}{
|
| 346 |
+
\def\f@nch@resetpar{
|
| 347 |
+
\f@nch@everypar{}
|
| 348 |
+
}
|
| 349 |
+
\def\f@nch@restorepar{}
|
| 350 |
+
}
|
| 351 |
+
\ExplSyntaxOff
|
| 352 |
+
\else
|
| 353 |
+
\def\f@nch@resetpar{%
|
| 354 |
+
\f@nch@everypar{}%
|
| 355 |
+
}
|
| 356 |
+
\def\f@nch@restorepar{}
|
| 357 |
+
\fi
|
| 358 |
+
\newcommand\f@nch@noUppercase[2][]{#2}
|
| 359 |
+
\def\f@nch@reset{\f@nch@resetpar\restorecr\endlinechar=13
|
| 360 |
+
\catcode`\\=0\catcode`\{=1\catcode`\}=2\catcode`\$=3\catcode`\&=4
|
| 361 |
+
\catcode`\#=6\catcode`\^=7\catcode`\_=8\catcode`\ =10\catcode`\@=11
|
| 362 |
+
\catcode`\:=11\catcode`\~=13\catcode`\%=14
|
| 363 |
+
\catcode0=15 %NULL
|
| 364 |
+
\catcode9=10 %TAB
|
| 365 |
+
\let\\\@normalcr \let\raggedleft\f@nch@raggedleft
|
| 366 |
+
\let\raggedright\f@nch@raggedright \let\centering\f@nch@centering
|
| 367 |
+
\def\baselinestretch{1}%
|
| 368 |
+
\hsize=\headwidth
|
| 369 |
+
\def\nouppercase##1{{%
|
| 370 |
+
\let\uppercase\relax\let\MakeUppercase\f@nch@noUppercase
|
| 371 |
+
\expandafter\let\csname MakeUppercase \endcsname\relax
|
| 372 |
+
\expandafter\def\csname MakeUppercase\space\space\space\endcsname
|
| 373 |
+
[####1]####2{####2}%
|
| 374 |
+
##1}}%
|
| 375 |
+
\@ifundefined{@normalsize} {\normalsize} % for ucthesis.cls
|
| 376 |
+
{\@normalsize}%
|
| 377 |
+
}
|
| 378 |
+
\newcommand*{\fancycenter}[1][1em]{%
|
| 379 |
+
\@ifnextchar[{\f@nch@center{#1}}{\f@nch@center{#1}[3]}%
|
| 380 |
+
}
|
| 381 |
+
\def\f@nch@center#1[#2]#3#4#5{%
|
| 382 |
+
\def\@tempa{#4}\ifx\@tempa\@empty
|
| 383 |
+
\hbox to\linewidth{\color@begingroup{#3}\hfil {#5}\color@endgroup}%
|
| 384 |
+
\else
|
| 385 |
+
\setlength\@tempdima{#1}%
|
| 386 |
+
\setlength{\@tempdimb}{#2\@tempdima}%
|
| 387 |
+
\@tempdimc \@tempdimb \advance\@tempdimc -\@tempdima
|
| 388 |
+
\setlength\@tempskipa{\@tempdimb \@plus 1fil \@minus \@tempdimc}%
|
| 389 |
+
\@tempskipb\@tempskipa
|
| 390 |
+
\def\@tempa{#3}\ifx\@tempa\@empty
|
| 391 |
+
\addtolength\@tempskipa{\z@ \@minus \@tempdima}%
|
| 392 |
+
\fi
|
| 393 |
+
\def\@tempa{#5}\ifx\@tempa\@empty % empty right
|
| 394 |
+
\addtolength\@tempskipb{\z@ \@minus \@tempdima}%
|
| 395 |
+
\fi
|
| 396 |
+
\settowidth{\@tempdimb}{#3}%
|
| 397 |
+
\settowidth{\@tempdimc}{#5}%
|
| 398 |
+
\ifdim\@tempdimb>\@tempdimc
|
| 399 |
+
\advance\@tempdimb -\@tempdimc
|
| 400 |
+
\addtolength\@tempskipb{\@tempdimb \@minus \@tempdimb}%
|
| 401 |
+
\else
|
| 402 |
+
\advance\@tempdimc -\@tempdimb
|
| 403 |
+
\addtolength\@tempskipa{\@tempdimc \@minus \@tempdimc}%
|
| 404 |
+
\fi
|
| 405 |
+
\hbox to\linewidth{\color@begingroup{#3}\hskip \@tempskipa
|
| 406 |
+
{#4}\hskip \@tempskipb {#5}\color@endgroup}%
|
| 407 |
+
\fi
|
| 408 |
+
}
|
| 409 |
+
\newcommand{\f@nch@headinit}{}
|
| 410 |
+
\newcommand{\fancyheadinit}[1]{%
|
| 411 |
+
\def\f@nch@headinit{#1}%
|
| 412 |
+
}
|
| 413 |
+
\newcommand{\f@nch@footinit}{}
|
| 414 |
+
\newcommand{\fancyfootinit}[1]{%
|
| 415 |
+
\def\f@nch@footinit{#1}%
|
| 416 |
+
}
|
| 417 |
+
\newcommand{\fancyhfinit}[1]{%
|
| 418 |
+
\def\f@nch@headinit{#1}%
|
| 419 |
+
\def\f@nch@footinit{#1}%
|
| 420 |
+
}
|
| 421 |
+
\ifdefined\NewMirroredHookPair
|
| 422 |
+
\NewMirroredHookPair{fancyhdr/before}{fancyhdr/after}
|
| 423 |
+
\NewMirroredHookPair{fancyhdr/head/begin}{fancyhdr/head/end}
|
| 424 |
+
\NewMirroredHookPair{fancyhdr/foot/begin}{fancyhdr/foot/end}
|
| 425 |
+
\fi
|
| 426 |
+
\newlength\f@nch@height
|
| 427 |
+
\newlength\f@nch@footalignment
|
| 428 |
+
\newif\iff@nch@footalign\f@nch@footalignfalse
|
| 429 |
+
\newcommand{\fancyfootalign}[1]{%
|
| 430 |
+
\def\temp@a{#1}%
|
| 431 |
+
\ifx\temp@a\@empty
|
| 432 |
+
\f@nch@footalignfalse
|
| 433 |
+
\else
|
| 434 |
+
\f@nch@footaligntrue
|
| 435 |
+
\setlength\f@nch@footalignment{#1}%
|
| 436 |
+
\fi
|
| 437 |
+
}
|
| 438 |
+
\newcommand\fancyhdrsettoheight[2]{%
|
| 439 |
+
\expandafter\ifx\csname f@nch@#2\endcsname\fancyhdrsettoheight
|
| 440 |
+
\else\PackageError{fancyhdr}{Unknown parameter #2 in \string\fancyhdrsettoheight}{}\fi
|
| 441 |
+
\setbox\@tempboxa\hbox{{\f@nch@checkfalse\csname @#2\endcsname}}%
|
| 442 |
+
\setlength{#1}\f@nch@height
|
| 443 |
+
\setbox\@tempboxa\box\voidb@x
|
| 444 |
+
}
|
| 445 |
+
\let\f@nch@oddhead\fancyhdrsettoheight
|
| 446 |
+
\let\f@nch@evenhead\fancyhdrsettoheight
|
| 447 |
+
\let\f@nch@oddfoot\fancyhdrsettoheight
|
| 448 |
+
\let\f@nch@evenfoot\fancyhdrsettoheight
|
| 449 |
+
\newcommand\f@nch@vbox[2]{%
|
| 450 |
+
\setbox0\vbox{#2}%
|
| 451 |
+
\global\f@nch@height=\ht0
|
| 452 |
+
\ifdim\ht0>#1\relax
|
| 453 |
+
\iff@nch@check
|
| 454 |
+
\dimen0=#1\advance\dimen0-\ht0
|
| 455 |
+
\PackageWarning{fancyhdr}{%
|
| 456 |
+
\string#1 is too small (\the#1): \MessageBreak
|
| 457 |
+
Make it at least \the\ht0, for example:\MessageBreak
|
| 458 |
+
\string\setlength{\string#1}{\the\ht0}%
|
| 459 |
+
\iff@nch@compatViii .\MessageBreak
|
| 460 |
+
We now make it that large for the rest of the document.\MessageBreak
|
| 461 |
+
This may cause the page layout to be inconsistent, however
|
| 462 |
+
\fi
|
| 463 |
+
\ifx#1\headheight .\MessageBreak
|
| 464 |
+
You might also make \topmargin smaller:\MessageBreak
|
| 465 |
+
\string\addtolength{\string\topmargin}{\the\dimen0}%
|
| 466 |
+
\fi
|
| 467 |
+
\@gobble
|
| 468 |
+
}%
|
| 469 |
+
\iff@nch@compatViii
|
| 470 |
+
\dimen0=#1\relax
|
| 471 |
+
\global#1=\ht0\relax
|
| 472 |
+
\ht0=\dimen0 %
|
| 473 |
+
\else
|
| 474 |
+
\ht0=#1\relax
|
| 475 |
+
\fi
|
| 476 |
+
\else
|
| 477 |
+
\ht0=#1\relax
|
| 478 |
+
\fi
|
| 479 |
+
\fi
|
| 480 |
+
\box0}
|
| 481 |
+
\newcommand\f@nch@head[6]{%
|
| 482 |
+
\f@nch@reset
|
| 483 |
+
\ifdefined\UseHook\UseHook{fancyhdr/before}\UseHook{fancyhdr/head/begin}\fi
|
| 484 |
+
\f@nch@headinit\relax
|
| 485 |
+
#1%
|
| 486 |
+
\hbox to\headwidth{%
|
| 487 |
+
\f@nch@vbox\headheight{%
|
| 488 |
+
\f@nch@hfbox{#2}{#3}{#4}{#6}{h}%
|
| 489 |
+
\vskip\headruleskip\relax
|
| 490 |
+
\headrule
|
| 491 |
+
}%
|
| 492 |
+
}%
|
| 493 |
+
#5%
|
| 494 |
+
\ifdefined\UseHook\UseHook{fancyhdr/head/end}\UseHook{fancyhdr/after}\fi
|
| 495 |
+
\f@nch@restorepar
|
| 496 |
+
}
|
| 497 |
+
\newcommand\f@nch@foot[6]{%
|
| 498 |
+
\f@nch@reset
|
| 499 |
+
\ifdefined\UseHook\UseHook{fancyhdr/before}\UseHook{fancyhdr/foot/begin}\fi
|
| 500 |
+
\f@nch@footinit\relax
|
| 501 |
+
#1%
|
| 502 |
+
\hbox to\headwidth{%
|
| 503 |
+
\f@nch@vbox\footskip{%
|
| 504 |
+
\setbox0=\vbox{\footrule}\unvbox0
|
| 505 |
+
\vskip\footruleskip
|
| 506 |
+
\f@nch@hfbox{#2}{#3}{#4}{#6}{f}%
|
| 507 |
+
\iff@nch@footalign \vskip\f@nch@footalignment \fi
|
| 508 |
+
}%
|
| 509 |
+
}%
|
| 510 |
+
#5%
|
| 511 |
+
\ifdefined\UseHook\UseHook{fancyhdr/foot/end}\UseHook{fancyhdr/after}\fi
|
| 512 |
+
\f@nch@restorepar
|
| 513 |
+
}
|
| 514 |
+
\newlength\f@nch@widthL
|
| 515 |
+
\newlength\f@nch@widthC
|
| 516 |
+
\newlength\f@nch@widthR
|
| 517 |
+
\newcommand\f@nch@hfbox[5]{%
|
| 518 |
+
\setlength\f@nch@widthL{\csname f@nch@width@#4l#5\endcsname}%
|
| 519 |
+
\setlength\f@nch@widthC{\csname f@nch@width@#4c#5\endcsname}%
|
| 520 |
+
\setlength\f@nch@widthR{\csname f@nch@width@#4r#5\endcsname}%
|
| 521 |
+
\let\@tempa\f@nch@hfbox@center
|
| 522 |
+
\ifdim \dimexpr \f@nch@widthL+\f@nch@widthC+\f@nch@widthR>\headwidth
|
| 523 |
+
\else
|
| 524 |
+
\ifdim \dimexpr \f@nch@widthL+0.5\f@nch@widthC>0.5\headwidth
|
| 525 |
+
\let \@tempa\f@nch@hfbox@fit
|
| 526 |
+
\fi
|
| 527 |
+
\ifdim \dimexpr \f@nch@widthR+0.5\f@nch@widthC>0.5\headwidth
|
| 528 |
+
\let \@tempa\f@nch@hfbox@fit
|
| 529 |
+
\fi
|
| 530 |
+
\fi
|
| 531 |
+
\@tempa{#1}{#2}{#3}#4#5%
|
| 532 |
+
}
|
| 533 |
+
\newcommand\f@nch@hfbox@center[5]{%
|
| 534 |
+
\hbox to \headwidth{%
|
| 535 |
+
\rlap{\f@nch@parbox{#1}\f@nch@widthL{#4}l{#5}}%
|
| 536 |
+
\hfill
|
| 537 |
+
\f@nch@parbox{#2}\f@nch@widthC{#4}c{#5}%
|
| 538 |
+
\hfill
|
| 539 |
+
\llap{\f@nch@parbox{#3}\f@nch@widthR{#4}r{#5}}%
|
| 540 |
+
}%
|
| 541 |
+
}
|
| 542 |
+
\newcommand\f@nch@hfbox@fit[5]{%
|
| 543 |
+
\hbox to \headwidth{%
|
| 544 |
+
\f@nch@parbox{#1}\f@nch@widthL{#4}l{#5}%
|
| 545 |
+
\hfill
|
| 546 |
+
\f@nch@parbox{#2}\f@nch@widthC{#4}c{#5}%
|
| 547 |
+
\hfill
|
| 548 |
+
\f@nch@parbox{#3}\f@nch@widthR{#4}r{#5}%
|
| 549 |
+
}%
|
| 550 |
+
}%
|
| 551 |
+
\newcommand\f@nch@parbox[5]{%
|
| 552 |
+
\expandafter\expandafter\expandafter\f@nch@parbox@align
|
| 553 |
+
\csname f@nch@align@#3#4#5\endcsname
|
| 554 |
+
\parbox[\f@nch@align@@v]{#2}%
|
| 555 |
+
{%
|
| 556 |
+
\f@nch@align@@pre
|
| 557 |
+
\f@nch@align@@h\leavevmode\ignorespaces#1%
|
| 558 |
+
\f@nch@align@@post
|
| 559 |
+
}%
|
| 560 |
+
}
|
| 561 |
+
\newcommand\f@nch@parbox@align[2]{%
|
| 562 |
+
\def\f@nch@align@@pre{}%
|
| 563 |
+
\def\f@nch@align@@post{}%
|
| 564 |
+
\csname f@nch@parbox@align@v#1\endcsname
|
| 565 |
+
\csname f@nch@parbox@align@h#2\endcsname
|
| 566 |
+
}
|
| 567 |
+
\def\f@nch@parbox@align@vT{\def\f@nch@align@@v{t}\def\f@nch@align@@pre{\vspace{0pt}}}
|
| 568 |
+
\def\f@nch@parbox@align@vt{\def\f@nch@align@@v{t}}
|
| 569 |
+
\def\f@nch@parbox@align@vc{\def\f@nch@align@@v{c}}
|
| 570 |
+
\def\f@nch@parbox@align@vb{\def\f@nch@align@@v{b}}
|
| 571 |
+
\def\f@nch@parbox@align@vB{\def\f@nch@align@@v{b}\def\f@nch@align@@post{\vspace{0pt}}}
|
| 572 |
+
\def\f@nch@parbox@align@hl{\def\f@nch@align@@h{\raggedright}}
|
| 573 |
+
\def\f@nch@parbox@align@hc{\def\f@nch@align@@h{\centering}}
|
| 574 |
+
\def\f@nch@parbox@align@hr{\def\f@nch@align@@h{\raggedleft}}
|
| 575 |
+
\def\f@nch@parbox@align@hj{\def\f@nch@align@@h{}}
|
| 576 |
+
\@ifundefined{@chapapp}{\let\@chapapp\chaptername}{}%
|
| 577 |
+
\def\f@nch@initialise{%
|
| 578 |
+
\@ifundefined{chapter}%
|
| 579 |
+
{\def\sectionmark##1{\markboth{\MakeUppercase{\ifnum \c@secnumdepth>\z@
|
| 580 |
+
\thesection\hskip 1em\relax
|
| 581 |
+
\fi ##1}}{}}%
|
| 582 |
+
\def\subsectionmark##1{\markright {\ifnum \c@secnumdepth >\@ne
|
| 583 |
+
\thesubsection\hskip 1em\relax \fi ##1}}}%
|
| 584 |
+
{\def\chaptermark##1{\markboth {\MakeUppercase{\ifnum
|
| 585 |
+
\c@secnumdepth>\m@ne \@chapapp\ \thechapter. \ \fi ##1}}{}}%
|
| 586 |
+
\def\sectionmark##1{\markright{\MakeUppercase{\ifnum \c@secnumdepth >\z@
|
| 587 |
+
\thesection. \ \fi ##1}}}%
|
| 588 |
+
}%
|
| 589 |
+
\def\headrule{{\if@fancyplain\let\headrulewidth\plainheadrulewidth\fi
|
| 590 |
+
\hrule\@height\headrulewidth\@width\headwidth
|
| 591 |
+
\vskip-\headrulewidth}}%
|
| 592 |
+
\def\footrule{{\if@fancyplain\let\footrulewidth\plainfootrulewidth\fi
|
| 593 |
+
\hrule\@width\headwidth\@height\footrulewidth}}%
|
| 594 |
+
\def\headrulewidth{0.4pt}%
|
| 595 |
+
\def\footrulewidth{0pt}%
|
| 596 |
+
\def\headruleskip{0pt}%
|
| 597 |
+
\def\footruleskip{0.3\normalbaselineskip}%
|
| 598 |
+
\fancyhf{}%
|
| 599 |
+
\if@twoside
|
| 600 |
+
\fancyhead[el,or]{\fancyplain{}{\slshape\rightmark}}%
|
| 601 |
+
\fancyhead[er,ol]{\fancyplain{}{\slshape\leftmark}}%
|
| 602 |
+
\else
|
| 603 |
+
\fancyhead[l]{\fancyplain{}{\slshape\rightmark}}%
|
| 604 |
+
\fancyhead[r]{\fancyplain{}{\slshape\leftmark}}%
|
| 605 |
+
\fi
|
| 606 |
+
\fancyfoot[c]{\rmfamily\thepage}% page number
|
| 607 |
+
}
|
| 608 |
+
\f@nch@initialise
|
| 609 |
+
\def\ps@f@nch@fancyproto{%
|
| 610 |
+
\ifdim\headwidth<0sp
|
| 611 |
+
\global\advance\headwidth123456789sp\global\advance\headwidth\textwidth
|
| 612 |
+
\fi
|
| 613 |
+
\gdef\ps@f@nch@fancyproto{\@fancyplainfalse\ps@f@nch@fancycore}%
|
| 614 |
+
\@fancyplainfalse\ps@f@nch@fancycore
|
| 615 |
+
}%
|
| 616 |
+
\@namedef{f@nch@ps@f@nch@fancyproto-is-fancyhdr}{}
|
| 617 |
+
\def\ps@fancy{\ps@f@nch@fancyproto}
|
| 618 |
+
\@namedef{f@nch@ps@fancy-is-fancyhdr}{}
|
| 619 |
+
\def\ps@fancyplain{\ps@f@nch@fancyproto \let\ps@plain\ps@plain@fancy}
|
| 620 |
+
\def\ps@plain@fancy{\@fancyplaintrue\ps@f@nch@fancycore}
|
| 621 |
+
\let\f@nch@ps@empty\ps@empty
|
| 622 |
+
\def\ps@f@nch@fancycore{%
|
| 623 |
+
\f@nch@ps@empty
|
| 624 |
+
\def\@mkboth{\protect\markboth}%
|
| 625 |
+
\def\f@nch@oddhead{\f@nch@head\f@nch@Oolh\f@nch@olh\f@nch@och\f@nch@orh\f@nch@Oorh{o}}%
|
| 626 |
+
\def\@oddhead{%
|
| 627 |
+
\iff@nch@twoside
|
| 628 |
+
\ifodd\c@page
|
| 629 |
+
\f@nch@oddhead
|
| 630 |
+
\else
|
| 631 |
+
\@evenhead
|
| 632 |
+
\fi
|
| 633 |
+
\else
|
| 634 |
+
\f@nch@oddhead
|
| 635 |
+
\fi
|
| 636 |
+
}
|
| 637 |
+
\def\f@nch@oddfoot{\f@nch@foot\f@nch@Oolf\f@nch@olf\f@nch@ocf\f@nch@orf\f@nch@Oorf{o}}%
|
| 638 |
+
\def\@oddfoot{%
|
| 639 |
+
\iff@nch@twoside
|
| 640 |
+
\ifodd\c@page
|
| 641 |
+
\f@nch@oddfoot
|
| 642 |
+
\else
|
| 643 |
+
\@evenfoot
|
| 644 |
+
\fi
|
| 645 |
+
\else
|
| 646 |
+
\f@nch@oddfoot
|
| 647 |
+
\fi
|
| 648 |
+
}
|
| 649 |
+
\def\@evenhead{\f@nch@head\f@nch@Oelh\f@nch@elh\f@nch@ech\f@nch@erh\f@nch@Oerh{e}}%
|
| 650 |
+
\def\@evenfoot{\f@nch@foot\f@nch@Oelf\f@nch@elf\f@nch@ecf\f@nch@erf\f@nch@Oerf{e}}%
|
| 651 |
+
}
|
| 652 |
+
\def\f@nch@Oolh{\if@reversemargin\hss\else\relax\fi}
|
| 653 |
+
\def\f@nch@Oorh{\if@reversemargin\relax\else\hss\fi}
|
| 654 |
+
\let\f@nch@Oelh\f@nch@Oorh
|
| 655 |
+
\let\f@nch@Oerh\f@nch@Oolh
|
| 656 |
+
\let\f@nch@Oolf\f@nch@Oolh
|
| 657 |
+
\let\f@nch@Oorf\f@nch@Oorh
|
| 658 |
+
\let\f@nch@Oelf\f@nch@Oelh
|
| 659 |
+
\let\f@nch@Oerf\f@nch@Oerh
|
| 660 |
+
\def\f@nch@offsolh{\headwidth=\textwidth\advance\headwidth\f@nch@offset@olh
|
| 661 |
+
\advance\headwidth\f@nch@offset@orh\hskip-\f@nch@offset@olh}
|
| 662 |
+
\def\f@nch@offselh{\headwidth=\textwidth\advance\headwidth\f@nch@offset@elh
|
| 663 |
+
\advance\headwidth\f@nch@offset@erh\hskip-\f@nch@offset@elh}
|
| 664 |
+
\def\f@nch@offsolf{\headwidth=\textwidth\advance\headwidth\f@nch@offset@olf
|
| 665 |
+
\advance\headwidth\f@nch@offset@orf\hskip-\f@nch@offset@olf}
|
| 666 |
+
\def\f@nch@offself{\headwidth=\textwidth\advance\headwidth\f@nch@offset@elf
|
| 667 |
+
\advance\headwidth\f@nch@offset@erf\hskip-\f@nch@offset@elf}
|
| 668 |
+
\def\f@nch@setoffs{%
|
| 669 |
+
\f@nch@gbl\let\headwidth\f@nch@headwidth
|
| 670 |
+
\f@nch@gbl\def\f@nch@Oolh{\f@nch@offsolh}%
|
| 671 |
+
\f@nch@gbl\def\f@nch@Oelh{\f@nch@offselh}%
|
| 672 |
+
\f@nch@gbl\def\f@nch@Oorh{\hss}%
|
| 673 |
+
\f@nch@gbl\def\f@nch@Oerh{\hss}%
|
| 674 |
+
\f@nch@gbl\def\f@nch@Oolf{\f@nch@offsolf}%
|
| 675 |
+
\f@nch@gbl\def\f@nch@Oelf{\f@nch@offself}%
|
| 676 |
+
\f@nch@gbl\def\f@nch@Oorf{\hss}%
|
| 677 |
+
\f@nch@gbl\def\f@nch@Oerf{\hss}%
|
| 678 |
+
}
|
| 679 |
+
\newif\iff@nch@footnote
|
| 680 |
+
\AtBeginDocument{%
|
| 681 |
+
\let\latex@makecol\@makecol
|
| 682 |
+
\def\@makecol{\ifvoid\footins\f@nch@footnotefalse\else\f@nch@footnotetrue\fi
|
| 683 |
+
\let\f@nch@topfloat\@toplist\let\f@nch@botfloat\@botlist\latex@makecol}%
|
| 684 |
+
}
|
| 685 |
+
\newcommand\iftopfloat[2]{\ifx\f@nch@topfloat\@empty #2\else #1\fi}%
|
| 686 |
+
\newcommand\ifbotfloat[2]{\ifx\f@nch@botfloat\@empty #2\else #1\fi}%
|
| 687 |
+
\newcommand\iffloatpage[2]{\if@fcolmade #1\else #2\fi}%
|
| 688 |
+
\newcommand\iffootnote[2]{\iff@nch@footnote #1\else #2\fi}%
|
| 689 |
+
\ifx\@temptokenb\undefined \csname newtoks\endcsname\@temptokenb\fi
|
| 690 |
+
\newif\iff@nch@pagestyle@star
|
| 691 |
+
\newcommand\fancypagestyle{%
|
| 692 |
+
\@ifstar{\f@nch@pagestyle@startrue\f@nch@pagestyle}%
|
| 693 |
+
{\f@nch@pagestyle@starfalse\f@nch@pagestyle}%
|
| 694 |
+
}
|
| 695 |
+
\newcommand\f@nch@pagestyle[1]{%
|
| 696 |
+
\@ifnextchar[{\f@nch@@pagestyle{#1}}{\f@nch@@pagestyle{#1}[f@nch@fancyproto]}%
|
| 697 |
+
}
|
| 698 |
+
\long\def\f@nch@@pagestyle#1[#2]#3{%
|
| 699 |
+
\@ifundefined{ps@#2}{%
|
| 700 |
+
\PackageError{fancyhdr}{\string\fancypagestyle: Unknown base page style `#2'}{}%
|
| 701 |
+
}{%
|
| 702 |
+
\@ifundefined{f@nch@ps@#2-is-fancyhdr}{%
|
| 703 |
+
\PackageError{fancyhdr}{\string\fancypagestyle: Base page style `#2' is not fancyhdr-based}{}%
|
| 704 |
+
}%
|
| 705 |
+
{%
|
| 706 |
+
\f@nch@pagestyle@setup
|
| 707 |
+
\def\temp@b{\@namedef{ps@#1}}%
|
| 708 |
+
\expandafter\temp@b\expandafter{\the\@temptokenb
|
| 709 |
+
\let\f@nch@gbl\relax\@nameuse{ps@#2}#3\relax}%
|
| 710 |
+
\@namedef{f@nch@ps@#1-is-fancyhdr}{}%
|
| 711 |
+
}%
|
| 712 |
+
}%
|
| 713 |
+
}
|
| 714 |
+
\newcommand\f@nch@pagestyle@setup{%
|
| 715 |
+
\iff@nch@pagestyle@star
|
| 716 |
+
\iff@nch@check\@temptokenb={\f@nch@checktrue}\else\@temptokenb={\f@nch@checkfalse}\fi
|
| 717 |
+
\@tfor\temp@a:=
|
| 718 |
+
\f@nch@olh\f@nch@och\f@nch@orh\f@nch@elh\f@nch@ech\f@nch@erh
|
| 719 |
+
\f@nch@olf\f@nch@ocf\f@nch@orf\f@nch@elf\f@nch@ecf\f@nch@erf
|
| 720 |
+
\f@nch@width@elh\f@nch@width@ech\f@nch@width@erh\f@nch@width@olh
|
| 721 |
+
\f@nch@width@och\f@nch@width@orh\f@nch@width@elf\f@nch@width@ecf
|
| 722 |
+
\f@nch@width@erf\f@nch@width@olf\f@nch@width@ocf\f@nch@width@orf
|
| 723 |
+
\f@nch@align@elh\f@nch@align@ech\f@nch@align@erh\f@nch@align@olh
|
| 724 |
+
\f@nch@align@och\f@nch@align@orh\f@nch@align@elf\f@nch@align@ecf
|
| 725 |
+
\f@nch@align@erf\f@nch@align@olf\f@nch@align@ocf\f@nch@align@orf
|
| 726 |
+
\f@nch@Oolh\f@nch@Oorh\f@nch@Oelh\f@nch@Oerh
|
| 727 |
+
\f@nch@Oolf\f@nch@Oorf\f@nch@Oelf\f@nch@Oerf
|
| 728 |
+
\f@nch@headinit\f@nch@footinit
|
| 729 |
+
\headrule\headrulewidth\footrule\footrulewidth
|
| 730 |
+
\do {%
|
| 731 |
+
\toks@=\expandafter\expandafter\expandafter{\temp@a}%
|
| 732 |
+
\toks@=\expandafter\expandafter\expandafter{%
|
| 733 |
+
\expandafter\expandafter\expandafter\def
|
| 734 |
+
\expandafter\expandafter\temp@a\expandafter{\the\toks@}}%
|
| 735 |
+
\edef\temp@b{\@temptokenb={\the\@temptokenb\the\toks@}}%
|
| 736 |
+
\temp@b
|
| 737 |
+
}%
|
| 738 |
+
\@tfor\temp@a:=
|
| 739 |
+
\f@nch@offset@olh\f@nch@offset@orh\f@nch@offset@elh\f@nch@offset@erh
|
| 740 |
+
\f@nch@offset@olf\f@nch@offset@orf\f@nch@offset@elf\f@nch@offset@erf
|
| 741 |
+
\do {%
|
| 742 |
+
\toks@=\expandafter\expandafter\expandafter{\expandafter\the\temp@a}%
|
| 743 |
+
\toks@=\expandafter\expandafter\expandafter{%
|
| 744 |
+
\expandafter\expandafter\expandafter\setlength
|
| 745 |
+
\expandafter\expandafter\temp@a\expandafter{\the\toks@}}%
|
| 746 |
+
\edef\temp@b{\@temptokenb={\the\@temptokenb\the\toks@}}%
|
| 747 |
+
\temp@b
|
| 748 |
+
}%
|
| 749 |
+
\else
|
| 750 |
+
\@temptokenb={}%
|
| 751 |
+
\fi
|
| 752 |
+
}
|
| 753 |
+
\newcommand\fancypagestyleassign[2]{%
|
| 754 |
+
\@ifundefined{ps@#2}{%
|
| 755 |
+
\PackageError{fancyhdr}{\string\fancypagestyleassign: Unknown page style `#2'}{}%
|
| 756 |
+
}{%
|
| 757 |
+
\expandafter\let
|
| 758 |
+
\csname ps@#1\expandafter\endcsname
|
| 759 |
+
\csname ps@#2\endcsname
|
| 760 |
+
\@ifundefined{f@nch@ps@#2-is-fancyhdr}{%
|
| 761 |
+
\expandafter\let\csname f@nch@ps@#1-is-fancyhdr\endcsname\@undefined
|
| 762 |
+
}{%
|
| 763 |
+
\@namedef{f@nch@ps@#1-is-fancyhdr}{}%
|
| 764 |
+
}%
|
| 765 |
+
}%
|
| 766 |
+
}
|
| 767 |
+
\fancypagestyle*{fancydefault}{\f@nch@initialise}
|
| 768 |
+
\def\f@nchdrbox@topstrut{\vrule height\ht\strutbox width\z@}
|
| 769 |
+
\def\f@nchdrbox@botstrut{\vrule depth\dp\strutbox width\z@}
|
| 770 |
+
\def\f@nchdrbox@nostrut{\noalign{\vspace{0pt}}\let\f@nchdrbox@@crstrut\f@nchdrbox@botstrut}
|
| 771 |
+
\NewDocumentCommand{\fancyhdrbox}{ O{cl} o m }{%
|
| 772 |
+
\begingroup
|
| 773 |
+
\let\f@nchdrbox@@pre\f@nchdrbox@topstrut
|
| 774 |
+
\let\f@nchdrbox@@postx\f@nchdrbox@botstrut
|
| 775 |
+
\let\f@nchdrbox@@posty\relax
|
| 776 |
+
\let\f@nchdrbox@@crstrut\strut
|
| 777 |
+
\IfNoValueTF{#2}%
|
| 778 |
+
{\let\f@nchdrbox@@halignto\@empty}%
|
| 779 |
+
{\setlength\@tempdima{#2}%
|
| 780 |
+
\def\f@nchdrbox@@halignto{to\@tempdima}}%
|
| 781 |
+
\def\@tempa{#1}%
|
| 782 |
+
\ifx\@tempa\@empty
|
| 783 |
+
\f@nchdrbox@align cl\@nil{#3}%
|
| 784 |
+
\else
|
| 785 |
+
\f@nchdrbox@align #1\@nil{#3}%
|
| 786 |
+
\fi
|
| 787 |
+
\endgroup
|
| 788 |
+
}
|
| 789 |
+
\protected\def\f@nchdrbox@cr{%
|
| 790 |
+
{\ifnum0=`}\fi\@ifstar\@f@nchdrbox@xcr\@f@nchdrbox@xcr}
|
| 791 |
+
|
| 792 |
+
\def\@f@nchdrbox@xcr{%
|
| 793 |
+
\unskip\f@nchdrbox@@crstrut
|
| 794 |
+
\@ifnextchar[\@f@nchdrbox@argc{\ifnum0=`{\fi}\cr}%
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
\def\@f@nchdrbox@argc[#1]{%
|
| 798 |
+
\ifnum0=`{\fi}%
|
| 799 |
+
\ifdim #1>\z@
|
| 800 |
+
\unskip\@f@nchdrbox@xargc{#1}%
|
| 801 |
+
\else
|
| 802 |
+
\@f@nchdrbox@yargc{#1}%
|
| 803 |
+
\fi}
|
| 804 |
+
|
| 805 |
+
\def\@f@nchdrbox@xargc#1{\@tempdima #1\advance\@tempdima \dp \strutbox
|
| 806 |
+
\vrule \@height\z@ \@depth\@tempdima \@width\z@ \cr}
|
| 807 |
+
|
| 808 |
+
\def\@f@nchdrbox@yargc#1{\cr\noalign{\setlength\@tempdima{#1}\vskip\@tempdima}}
|
| 809 |
+
\def\f@nchdrbox@T{\let\f@nchdrbox@@pre\f@nchdrbox@nostrut
|
| 810 |
+
\f@nchdrbox@t}
|
| 811 |
+
\def\f@nchdrbox@t{\def\f@nchdrbox@@v{t}\def\f@nchdrbox@@h{l}}
|
| 812 |
+
\def\f@nchdrbox@c{\def\f@nchdrbox@@v{c}\def\f@nchdrbox@@h{c}}
|
| 813 |
+
\def\f@nchdrbox@b{\def\f@nchdrbox@@v{b}\def\f@nchdrbox@@h{l}}
|
| 814 |
+
\def\f@nchdrbox@B{\let\f@nchdrbox@@postx\relax
|
| 815 |
+
\def\f@nchdrbox@@posty{\vspace{0pt}}%
|
| 816 |
+
\f@nchdrbox@b}
|
| 817 |
+
\long\def\f@nchdrbox@align#1#2\@nil#3{%
|
| 818 |
+
\f@nch@ifin{#1}{TtcbB}{%
|
| 819 |
+
\@nameuse{f@nchdrbox@#1}%
|
| 820 |
+
\def\@tempa{#2}%
|
| 821 |
+
\ifx\@tempa\@empty\else \def\f@nchdrbox@@h{#2}\fi
|
| 822 |
+
}%
|
| 823 |
+
{\def\f@nchdrbox@@v{c}\def\f@nchdrbox@@h{#1}}%
|
| 824 |
+
\expandafter\f@nch@ifin\expandafter{\f@nchdrbox@@h}{lcr}{}%
|
| 825 |
+
{\PackageError{fancyhdr}{\string\fancyhdrbox: Illegal char `\f@nchdrbox@@h'\MessageBreak
|
| 826 |
+
in alignment argument}{}}%
|
| 827 |
+
\let\\\f@nchdrbox@cr
|
| 828 |
+
\setbox0=\if \f@nchdrbox@@v t\vtop
|
| 829 |
+
\else \vbox
|
| 830 |
+
\fi
|
| 831 |
+
{%
|
| 832 |
+
\ialign \f@nchdrbox@@halignto
|
| 833 |
+
\bgroup \relax
|
| 834 |
+
{\if \f@nchdrbox@@h l\hskip 1sp\else \hfil \fi
|
| 835 |
+
\ignorespaces ##\unskip
|
| 836 |
+
\if\f@nchdrbox@@h r\else \hfil \fi
|
| 837 |
+
}%
|
| 838 |
+
\tabskip\z@skip \cr
|
| 839 |
+
\f@nchdrbox@@pre
|
| 840 |
+
#3\unskip \f@nchdrbox@@postx
|
| 841 |
+
\crcr
|
| 842 |
+
\egroup
|
| 843 |
+
\f@nchdrbox@@posty
|
| 844 |
+
}%
|
| 845 |
+
\if\f@nchdrbox@@v c\@tempdima=\ht0\advance\@tempdima\dp0%
|
| 846 |
+
\ht0=0.5\@tempdima\dp0=0.5\@tempdima\fi
|
| 847 |
+
\leavevmode \box0
|
| 848 |
+
}
|
| 849 |
+
\@ifclassloaded{newlfm}
|
| 850 |
+
{
|
| 851 |
+
\let\ps@@empty\f@nch@ps@empty
|
| 852 |
+
\AtBeginDocument{%
|
| 853 |
+
\renewcommand{\@zfancyhead}[5]{\relax\hbox to\headwidth{\f@nch@reset
|
| 854 |
+
\@zfancyvbox\headheight{\hbox
|
| 855 |
+
{\rlap{\parbox[b]{\headwidth}{\raggedright\f@nch@olh}}\hfill
|
| 856 |
+
\parbox[b]{\headwidth}{\centering\f@nch@olh}\hfill
|
| 857 |
+
\llap{\parbox[b]{\headwidth}{\raggedleft\f@nch@orh}}}%
|
| 858 |
+
\zheadrule}}\relax}%
|
| 859 |
+
}
|
| 860 |
+
}
|
| 861 |
+
{}
|
| 862 |
+
\endinput
|
| 863 |
+
%%
|
| 864 |
+
%% End of file `fancyhdr.sty'.
|
icml2026.bst
ADDED
|
@@ -0,0 +1,1443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%% File: `icml2025.bst'
|
| 2 |
+
%% A modification of `plainnl.bst' for use with natbib package
|
| 3 |
+
%%
|
| 4 |
+
%% Copyright 2010 Hal Daum\'e III
|
| 5 |
+
%% Modified by J. Fürnkranz
|
| 6 |
+
%% - Changed labels from (X and Y, 2000) to (X & Y, 2000)
|
| 7 |
+
%% - Changed References to last name first and abbreviated first names.
|
| 8 |
+
%% Modified by Iain Murray 2018 (who suggests adopting a standard .bst in future...)
|
| 9 |
+
%% - Made it actually use abbreviated first names
|
| 10 |
+
%%
|
| 11 |
+
%% Copyright 1993-2007 Patrick W Daly
|
| 12 |
+
%% Max-Planck-Institut f\"ur Sonnensystemforschung
|
| 13 |
+
%% Max-Planck-Str. 2
|
| 14 |
+
%% D-37191 Katlenburg-Lindau
|
| 15 |
+
%% Germany
|
| 16 |
+
%% E-mail: daly@mps.mpg.de
|
| 17 |
+
%%
|
| 18 |
+
%% This program can be redistributed and/or modified under the terms
|
| 19 |
+
%% of the LaTeX Project Public License Distributed from CTAN
|
| 20 |
+
%% archives in directory macros/latex/base/lppl.txt; either
|
| 21 |
+
%% version 1 of the License, or any later version.
|
| 22 |
+
%%
|
| 23 |
+
% Version and source file information:
|
| 24 |
+
% \ProvidesFile{icml2010.mbs}[2007/11/26 1.93 (PWD)]
|
| 25 |
+
%
|
| 26 |
+
% BibTeX `plainnat' family
|
| 27 |
+
% version 0.99b for BibTeX versions 0.99a or later,
|
| 28 |
+
% for LaTeX versions 2.09 and 2e.
|
| 29 |
+
%
|
| 30 |
+
% For use with the `natbib.sty' package; emulates the corresponding
|
| 31 |
+
% member of the `plain' family, but with author-year citations.
|
| 32 |
+
%
|
| 33 |
+
% With version 6.0 of `natbib.sty', it may also be used for numerical
|
| 34 |
+
% citations, while retaining the commands \citeauthor, \citefullauthor,
|
| 35 |
+
% and \citeyear to print the corresponding information.
|
| 36 |
+
%
|
| 37 |
+
% For version 7.0 of `natbib.sty', the KEY field replaces missing
|
| 38 |
+
% authors/editors, and the date is left blank in \bibitem.
|
| 39 |
+
%
|
| 40 |
+
% Includes field EID for the sequence/citation number of electronic journals
|
| 41 |
+
% which is used instead of page numbers.
|
| 42 |
+
%
|
| 43 |
+
% Includes fields ISBN and ISSN.
|
| 44 |
+
%
|
| 45 |
+
% Includes field URL for Internet addresses.
|
| 46 |
+
%
|
| 47 |
+
% Includes field DOI for Digital Object Idenfifiers.
|
| 48 |
+
%
|
| 49 |
+
% Works best with the url.sty package of Donald Arseneau.
|
| 50 |
+
%
|
| 51 |
+
% Works with identical authors and year are further sorted by
|
| 52 |
+
% citation key, to preserve any natural sequence.
|
| 53 |
+
%
|
| 54 |
+
ENTRY
|
| 55 |
+
{ address
|
| 56 |
+
author
|
| 57 |
+
booktitle
|
| 58 |
+
chapter
|
| 59 |
+
doi
|
| 60 |
+
eid
|
| 61 |
+
edition
|
| 62 |
+
editor
|
| 63 |
+
howpublished
|
| 64 |
+
institution
|
| 65 |
+
isbn
|
| 66 |
+
issn
|
| 67 |
+
journal
|
| 68 |
+
key
|
| 69 |
+
month
|
| 70 |
+
note
|
| 71 |
+
number
|
| 72 |
+
organization
|
| 73 |
+
pages
|
| 74 |
+
publisher
|
| 75 |
+
school
|
| 76 |
+
series
|
| 77 |
+
title
|
| 78 |
+
type
|
| 79 |
+
url
|
| 80 |
+
volume
|
| 81 |
+
year
|
| 82 |
+
}
|
| 83 |
+
{}
|
| 84 |
+
{ label extra.label sort.label short.list }
|
| 85 |
+
|
| 86 |
+
INTEGERS { output.state before.all mid.sentence after.sentence after.block }
|
| 87 |
+
|
| 88 |
+
FUNCTION {init.state.consts}
|
| 89 |
+
{ #0 'before.all :=
|
| 90 |
+
#1 'mid.sentence :=
|
| 91 |
+
#2 'after.sentence :=
|
| 92 |
+
#3 'after.block :=
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
STRINGS { s t }
|
| 96 |
+
|
| 97 |
+
FUNCTION {output.nonnull}
|
| 98 |
+
{ 's :=
|
| 99 |
+
output.state mid.sentence =
|
| 100 |
+
{ ", " * write$ }
|
| 101 |
+
{ output.state after.block =
|
| 102 |
+
{ add.period$ write$
|
| 103 |
+
newline$
|
| 104 |
+
"\newblock " write$
|
| 105 |
+
}
|
| 106 |
+
{ output.state before.all =
|
| 107 |
+
'write$
|
| 108 |
+
{ add.period$ " " * write$ }
|
| 109 |
+
if$
|
| 110 |
+
}
|
| 111 |
+
if$
|
| 112 |
+
mid.sentence 'output.state :=
|
| 113 |
+
}
|
| 114 |
+
if$
|
| 115 |
+
s
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
FUNCTION {output}
|
| 119 |
+
{ duplicate$ empty$
|
| 120 |
+
'pop$
|
| 121 |
+
'output.nonnull
|
| 122 |
+
if$
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
FUNCTION {output.check}
|
| 126 |
+
{ 't :=
|
| 127 |
+
duplicate$ empty$
|
| 128 |
+
{ pop$ "empty " t * " in " * cite$ * warning$ }
|
| 129 |
+
'output.nonnull
|
| 130 |
+
if$
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
FUNCTION {fin.entry}
|
| 134 |
+
{ add.period$
|
| 135 |
+
write$
|
| 136 |
+
newline$
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
FUNCTION {new.block}
|
| 140 |
+
{ output.state before.all =
|
| 141 |
+
'skip$
|
| 142 |
+
{ after.block 'output.state := }
|
| 143 |
+
if$
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
FUNCTION {new.sentence}
|
| 147 |
+
{ output.state after.block =
|
| 148 |
+
'skip$
|
| 149 |
+
{ output.state before.all =
|
| 150 |
+
'skip$
|
| 151 |
+
{ after.sentence 'output.state := }
|
| 152 |
+
if$
|
| 153 |
+
}
|
| 154 |
+
if$
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
FUNCTION {not}
|
| 158 |
+
{ { #0 }
|
| 159 |
+
{ #1 }
|
| 160 |
+
if$
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
FUNCTION {and}
|
| 164 |
+
{ 'skip$
|
| 165 |
+
{ pop$ #0 }
|
| 166 |
+
if$
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
FUNCTION {or}
|
| 170 |
+
{ { pop$ #1 }
|
| 171 |
+
'skip$
|
| 172 |
+
if$
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
FUNCTION {new.block.checka}
|
| 176 |
+
{ empty$
|
| 177 |
+
'skip$
|
| 178 |
+
'new.block
|
| 179 |
+
if$
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
FUNCTION {new.block.checkb}
|
| 183 |
+
{ empty$
|
| 184 |
+
swap$ empty$
|
| 185 |
+
and
|
| 186 |
+
'skip$
|
| 187 |
+
'new.block
|
| 188 |
+
if$
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
FUNCTION {new.sentence.checka}
|
| 192 |
+
{ empty$
|
| 193 |
+
'skip$
|
| 194 |
+
'new.sentence
|
| 195 |
+
if$
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
FUNCTION {new.sentence.checkb}
|
| 199 |
+
{ empty$
|
| 200 |
+
swap$ empty$
|
| 201 |
+
and
|
| 202 |
+
'skip$
|
| 203 |
+
'new.sentence
|
| 204 |
+
if$
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
FUNCTION {field.or.null}
|
| 208 |
+
{ duplicate$ empty$
|
| 209 |
+
{ pop$ "" }
|
| 210 |
+
'skip$
|
| 211 |
+
if$
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
FUNCTION {emphasize}
|
| 215 |
+
{ duplicate$ empty$
|
| 216 |
+
{ pop$ "" }
|
| 217 |
+
{ "\emph{" swap$ * "}" * }
|
| 218 |
+
if$
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
INTEGERS { nameptr namesleft numnames }
|
| 222 |
+
|
| 223 |
+
FUNCTION {format.names}
|
| 224 |
+
{ 's :=
|
| 225 |
+
#1 'nameptr :=
|
| 226 |
+
s num.names$ 'numnames :=
|
| 227 |
+
numnames 'namesleft :=
|
| 228 |
+
{ namesleft #0 > }
|
| 229 |
+
{ s nameptr "{vv~}{ll}{, jj}{, f.}" format.name$ 't :=
|
| 230 |
+
nameptr #1 >
|
| 231 |
+
{ namesleft #1 >
|
| 232 |
+
{ ", " * t * }
|
| 233 |
+
{ numnames #2 >
|
| 234 |
+
{ "," * }
|
| 235 |
+
'skip$
|
| 236 |
+
if$
|
| 237 |
+
t "others" =
|
| 238 |
+
{ " et~al." * }
|
| 239 |
+
{ " and " * t * }
|
| 240 |
+
if$
|
| 241 |
+
}
|
| 242 |
+
if$
|
| 243 |
+
}
|
| 244 |
+
't
|
| 245 |
+
if$
|
| 246 |
+
nameptr #1 + 'nameptr :=
|
| 247 |
+
namesleft #1 - 'namesleft :=
|
| 248 |
+
}
|
| 249 |
+
while$
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
FUNCTION {format.key}
|
| 253 |
+
{ empty$
|
| 254 |
+
{ key field.or.null }
|
| 255 |
+
{ "" }
|
| 256 |
+
if$
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
FUNCTION {format.authors}
|
| 260 |
+
{ author empty$
|
| 261 |
+
{ "" }
|
| 262 |
+
{ author format.names }
|
| 263 |
+
if$
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
FUNCTION {format.editors}
|
| 267 |
+
{ editor empty$
|
| 268 |
+
{ "" }
|
| 269 |
+
{ editor format.names
|
| 270 |
+
editor num.names$ #1 >
|
| 271 |
+
{ " (eds.)" * }
|
| 272 |
+
{ " (ed.)" * }
|
| 273 |
+
if$
|
| 274 |
+
}
|
| 275 |
+
if$
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
FUNCTION {format.isbn}
|
| 279 |
+
{ isbn empty$
|
| 280 |
+
{ "" }
|
| 281 |
+
{ new.block "ISBN " isbn * }
|
| 282 |
+
if$
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
FUNCTION {format.issn}
|
| 286 |
+
{ issn empty$
|
| 287 |
+
{ "" }
|
| 288 |
+
{ new.block "ISSN " issn * }
|
| 289 |
+
if$
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
FUNCTION {format.url}
|
| 293 |
+
{ url empty$
|
| 294 |
+
{ "" }
|
| 295 |
+
{ new.block "URL \url{" url * "}" * }
|
| 296 |
+
if$
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
FUNCTION {format.doi}
|
| 300 |
+
{ doi empty$
|
| 301 |
+
{ "" }
|
| 302 |
+
{ new.block "\doi{" doi * "}" * }
|
| 303 |
+
if$
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
FUNCTION {format.title}
|
| 307 |
+
{ title empty$
|
| 308 |
+
{ "" }
|
| 309 |
+
{ title "t" change.case$ }
|
| 310 |
+
if$
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
FUNCTION {format.full.names}
|
| 314 |
+
{'s :=
|
| 315 |
+
#1 'nameptr :=
|
| 316 |
+
s num.names$ 'numnames :=
|
| 317 |
+
numnames 'namesleft :=
|
| 318 |
+
{ namesleft #0 > }
|
| 319 |
+
{ s nameptr
|
| 320 |
+
"{vv~}{ll}" format.name$ 't :=
|
| 321 |
+
nameptr #1 >
|
| 322 |
+
{
|
| 323 |
+
namesleft #1 >
|
| 324 |
+
{ ", " * t * }
|
| 325 |
+
{
|
| 326 |
+
numnames #2 >
|
| 327 |
+
{ "," * }
|
| 328 |
+
'skip$
|
| 329 |
+
if$
|
| 330 |
+
t "others" =
|
| 331 |
+
{ " et~al." * }
|
| 332 |
+
{ " and " * t * }
|
| 333 |
+
if$
|
| 334 |
+
}
|
| 335 |
+
if$
|
| 336 |
+
}
|
| 337 |
+
't
|
| 338 |
+
if$
|
| 339 |
+
nameptr #1 + 'nameptr :=
|
| 340 |
+
namesleft #1 - 'namesleft :=
|
| 341 |
+
}
|
| 342 |
+
while$
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
FUNCTION {author.editor.full}
|
| 346 |
+
{ author empty$
|
| 347 |
+
{ editor empty$
|
| 348 |
+
{ "" }
|
| 349 |
+
{ editor format.full.names }
|
| 350 |
+
if$
|
| 351 |
+
}
|
| 352 |
+
{ author format.full.names }
|
| 353 |
+
if$
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
FUNCTION {author.full}
|
| 357 |
+
{ author empty$
|
| 358 |
+
{ "" }
|
| 359 |
+
{ author format.full.names }
|
| 360 |
+
if$
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
FUNCTION {editor.full}
|
| 364 |
+
{ editor empty$
|
| 365 |
+
{ "" }
|
| 366 |
+
{ editor format.full.names }
|
| 367 |
+
if$
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
FUNCTION {make.full.names}
|
| 371 |
+
{ type$ "book" =
|
| 372 |
+
type$ "inbook" =
|
| 373 |
+
or
|
| 374 |
+
'author.editor.full
|
| 375 |
+
{ type$ "proceedings" =
|
| 376 |
+
'editor.full
|
| 377 |
+
'author.full
|
| 378 |
+
if$
|
| 379 |
+
}
|
| 380 |
+
if$
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
FUNCTION {output.bibitem}
|
| 384 |
+
{ newline$
|
| 385 |
+
"\bibitem[" write$
|
| 386 |
+
label write$
|
| 387 |
+
")" make.full.names duplicate$ short.list =
|
| 388 |
+
{ pop$ }
|
| 389 |
+
{ * }
|
| 390 |
+
if$
|
| 391 |
+
"]{" * write$
|
| 392 |
+
cite$ write$
|
| 393 |
+
"}" write$
|
| 394 |
+
newline$
|
| 395 |
+
""
|
| 396 |
+
before.all 'output.state :=
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
FUNCTION {n.dashify}
|
| 400 |
+
{ 't :=
|
| 401 |
+
""
|
| 402 |
+
{ t empty$ not }
|
| 403 |
+
{ t #1 #1 substring$ "-" =
|
| 404 |
+
{ t #1 #2 substring$ "--" = not
|
| 405 |
+
{ "--" *
|
| 406 |
+
t #2 global.max$ substring$ 't :=
|
| 407 |
+
}
|
| 408 |
+
{ { t #1 #1 substring$ "-" = }
|
| 409 |
+
{ "-" *
|
| 410 |
+
t #2 global.max$ substring$ 't :=
|
| 411 |
+
}
|
| 412 |
+
while$
|
| 413 |
+
}
|
| 414 |
+
if$
|
| 415 |
+
}
|
| 416 |
+
{ t #1 #1 substring$ *
|
| 417 |
+
t #2 global.max$ substring$ 't :=
|
| 418 |
+
}
|
| 419 |
+
if$
|
| 420 |
+
}
|
| 421 |
+
while$
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
FUNCTION {format.date}
|
| 425 |
+
{ year duplicate$ empty$
|
| 426 |
+
{ "empty year in " cite$ * warning$
|
| 427 |
+
pop$ "" }
|
| 428 |
+
'skip$
|
| 429 |
+
if$
|
| 430 |
+
month empty$
|
| 431 |
+
'skip$
|
| 432 |
+
{ month
|
| 433 |
+
" " * swap$ *
|
| 434 |
+
}
|
| 435 |
+
if$
|
| 436 |
+
extra.label *
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
FUNCTION {format.btitle}
|
| 440 |
+
{ title emphasize
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
FUNCTION {tie.or.space.connect}
|
| 444 |
+
{ duplicate$ text.length$ #3 <
|
| 445 |
+
{ "~" }
|
| 446 |
+
{ " " }
|
| 447 |
+
if$
|
| 448 |
+
swap$ * *
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
FUNCTION {either.or.check}
|
| 452 |
+
{ empty$
|
| 453 |
+
'pop$
|
| 454 |
+
{ "can't use both " swap$ * " fields in " * cite$ * warning$ }
|
| 455 |
+
if$
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
FUNCTION {format.bvolume}
|
| 459 |
+
{ volume empty$
|
| 460 |
+
{ "" }
|
| 461 |
+
{ "volume" volume tie.or.space.connect
|
| 462 |
+
series empty$
|
| 463 |
+
'skip$
|
| 464 |
+
{ " of " * series emphasize * }
|
| 465 |
+
if$
|
| 466 |
+
"volume and number" number either.or.check
|
| 467 |
+
}
|
| 468 |
+
if$
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
FUNCTION {format.number.series}
|
| 472 |
+
{ volume empty$
|
| 473 |
+
{ number empty$
|
| 474 |
+
{ series field.or.null }
|
| 475 |
+
{ output.state mid.sentence =
|
| 476 |
+
{ "number" }
|
| 477 |
+
{ "Number" }
|
| 478 |
+
if$
|
| 479 |
+
number tie.or.space.connect
|
| 480 |
+
series empty$
|
| 481 |
+
{ "there's a number but no series in " cite$ * warning$ }
|
| 482 |
+
{ " in " * series * }
|
| 483 |
+
if$
|
| 484 |
+
}
|
| 485 |
+
if$
|
| 486 |
+
}
|
| 487 |
+
{ "" }
|
| 488 |
+
if$
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
FUNCTION {format.edition}
|
| 492 |
+
{ edition empty$
|
| 493 |
+
{ "" }
|
| 494 |
+
{ output.state mid.sentence =
|
| 495 |
+
{ edition "l" change.case$ " edition" * }
|
| 496 |
+
{ edition "t" change.case$ " edition" * }
|
| 497 |
+
if$
|
| 498 |
+
}
|
| 499 |
+
if$
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
INTEGERS { multiresult }
|
| 503 |
+
|
| 504 |
+
FUNCTION {multi.page.check}
|
| 505 |
+
{ 't :=
|
| 506 |
+
#0 'multiresult :=
|
| 507 |
+
{ multiresult not
|
| 508 |
+
t empty$ not
|
| 509 |
+
and
|
| 510 |
+
}
|
| 511 |
+
{ t #1 #1 substring$
|
| 512 |
+
duplicate$ "-" =
|
| 513 |
+
swap$ duplicate$ "," =
|
| 514 |
+
swap$ "+" =
|
| 515 |
+
or or
|
| 516 |
+
{ #1 'multiresult := }
|
| 517 |
+
{ t #2 global.max$ substring$ 't := }
|
| 518 |
+
if$
|
| 519 |
+
}
|
| 520 |
+
while$
|
| 521 |
+
multiresult
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
FUNCTION {format.pages}
|
| 525 |
+
{ pages empty$
|
| 526 |
+
{ "" }
|
| 527 |
+
{ pages multi.page.check
|
| 528 |
+
{ "pp.\ " pages n.dashify tie.or.space.connect }
|
| 529 |
+
{ "pp.\ " pages tie.or.space.connect }
|
| 530 |
+
if$
|
| 531 |
+
}
|
| 532 |
+
if$
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
FUNCTION {format.eid}
|
| 536 |
+
{ eid empty$
|
| 537 |
+
{ "" }
|
| 538 |
+
{ "art." eid tie.or.space.connect }
|
| 539 |
+
if$
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
FUNCTION {format.vol.num.pages}
|
| 543 |
+
{ volume field.or.null
|
| 544 |
+
number empty$
|
| 545 |
+
'skip$
|
| 546 |
+
{ "\penalty0 (" number * ")" * *
|
| 547 |
+
volume empty$
|
| 548 |
+
{ "there's a number but no volume in " cite$ * warning$ }
|
| 549 |
+
'skip$
|
| 550 |
+
if$
|
| 551 |
+
}
|
| 552 |
+
if$
|
| 553 |
+
pages empty$
|
| 554 |
+
'skip$
|
| 555 |
+
{ duplicate$ empty$
|
| 556 |
+
{ pop$ format.pages }
|
| 557 |
+
{ ":\penalty0 " * pages n.dashify * }
|
| 558 |
+
if$
|
| 559 |
+
}
|
| 560 |
+
if$
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
FUNCTION {format.vol.num.eid}
|
| 564 |
+
{ volume field.or.null
|
| 565 |
+
number empty$
|
| 566 |
+
'skip$
|
| 567 |
+
{ "\penalty0 (" number * ")" * *
|
| 568 |
+
volume empty$
|
| 569 |
+
{ "there's a number but no volume in " cite$ * warning$ }
|
| 570 |
+
'skip$
|
| 571 |
+
if$
|
| 572 |
+
}
|
| 573 |
+
if$
|
| 574 |
+
eid empty$
|
| 575 |
+
'skip$
|
| 576 |
+
{ duplicate$ empty$
|
| 577 |
+
{ pop$ format.eid }
|
| 578 |
+
{ ":\penalty0 " * eid * }
|
| 579 |
+
if$
|
| 580 |
+
}
|
| 581 |
+
if$
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
FUNCTION {format.chapter.pages}
|
| 585 |
+
{ chapter empty$
|
| 586 |
+
'format.pages
|
| 587 |
+
{ type empty$
|
| 588 |
+
{ "chapter" }
|
| 589 |
+
{ type "l" change.case$ }
|
| 590 |
+
if$
|
| 591 |
+
chapter tie.or.space.connect
|
| 592 |
+
pages empty$
|
| 593 |
+
'skip$
|
| 594 |
+
{ ", " * format.pages * }
|
| 595 |
+
if$
|
| 596 |
+
}
|
| 597 |
+
if$
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
FUNCTION {format.in.ed.booktitle}
|
| 601 |
+
{ booktitle empty$
|
| 602 |
+
{ "" }
|
| 603 |
+
{ editor empty$
|
| 604 |
+
{ "In " booktitle emphasize * }
|
| 605 |
+
{ "In " format.editors * ", " * booktitle emphasize * }
|
| 606 |
+
if$
|
| 607 |
+
}
|
| 608 |
+
if$
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
FUNCTION {empty.misc.check}
|
| 612 |
+
{ author empty$ title empty$ howpublished empty$
|
| 613 |
+
month empty$ year empty$ note empty$
|
| 614 |
+
and and and and and
|
| 615 |
+
key empty$ not and
|
| 616 |
+
{ "all relevant fields are empty in " cite$ * warning$ }
|
| 617 |
+
'skip$
|
| 618 |
+
if$
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
FUNCTION {format.thesis.type}
|
| 622 |
+
{ type empty$
|
| 623 |
+
'skip$
|
| 624 |
+
{ pop$
|
| 625 |
+
type "t" change.case$
|
| 626 |
+
}
|
| 627 |
+
if$
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
FUNCTION {format.tr.number}
|
| 631 |
+
{ type empty$
|
| 632 |
+
{ "Technical Report" }
|
| 633 |
+
'type
|
| 634 |
+
if$
|
| 635 |
+
number empty$
|
| 636 |
+
{ "t" change.case$ }
|
| 637 |
+
{ number tie.or.space.connect }
|
| 638 |
+
if$
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
FUNCTION {format.article.crossref}
|
| 642 |
+
{ key empty$
|
| 643 |
+
{ journal empty$
|
| 644 |
+
{ "need key or journal for " cite$ * " to crossref " * crossref *
|
| 645 |
+
warning$
|
| 646 |
+
""
|
| 647 |
+
}
|
| 648 |
+
{ "In \emph{" journal * "}" * }
|
| 649 |
+
if$
|
| 650 |
+
}
|
| 651 |
+
{ "In " }
|
| 652 |
+
if$
|
| 653 |
+
" \citet{" * crossref * "}" *
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
FUNCTION {format.book.crossref}
|
| 657 |
+
{ volume empty$
|
| 658 |
+
{ "empty volume in " cite$ * "'s crossref of " * crossref * warning$
|
| 659 |
+
"In "
|
| 660 |
+
}
|
| 661 |
+
{ "Volume" volume tie.or.space.connect
|
| 662 |
+
" of " *
|
| 663 |
+
}
|
| 664 |
+
if$
|
| 665 |
+
editor empty$
|
| 666 |
+
editor field.or.null author field.or.null =
|
| 667 |
+
or
|
| 668 |
+
{ key empty$
|
| 669 |
+
{ series empty$
|
| 670 |
+
{ "need editor, key, or series for " cite$ * " to crossref " *
|
| 671 |
+
crossref * warning$
|
| 672 |
+
"" *
|
| 673 |
+
}
|
| 674 |
+
{ "\emph{" * series * "}" * }
|
| 675 |
+
if$
|
| 676 |
+
}
|
| 677 |
+
'skip$
|
| 678 |
+
if$
|
| 679 |
+
}
|
| 680 |
+
'skip$
|
| 681 |
+
if$
|
| 682 |
+
" \citet{" * crossref * "}" *
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
FUNCTION {format.incoll.inproc.crossref}
|
| 686 |
+
{ editor empty$
|
| 687 |
+
editor field.or.null author field.or.null =
|
| 688 |
+
or
|
| 689 |
+
{ key empty$
|
| 690 |
+
{ booktitle empty$
|
| 691 |
+
{ "need editor, key, or booktitle for " cite$ * " to crossref " *
|
| 692 |
+
crossref * warning$
|
| 693 |
+
""
|
| 694 |
+
}
|
| 695 |
+
{ "In \emph{" booktitle * "}" * }
|
| 696 |
+
if$
|
| 697 |
+
}
|
| 698 |
+
{ "In " }
|
| 699 |
+
if$
|
| 700 |
+
}
|
| 701 |
+
{ "In " }
|
| 702 |
+
if$
|
| 703 |
+
" \citet{" * crossref * "}" *
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
FUNCTION {article}
|
| 707 |
+
{ output.bibitem
|
| 708 |
+
format.authors "author" output.check
|
| 709 |
+
author format.key output
|
| 710 |
+
new.block
|
| 711 |
+
format.title "title" output.check
|
| 712 |
+
new.block
|
| 713 |
+
crossref missing$
|
| 714 |
+
{ journal emphasize "journal" output.check
|
| 715 |
+
eid empty$
|
| 716 |
+
{ format.vol.num.pages output }
|
| 717 |
+
{ format.vol.num.eid output }
|
| 718 |
+
if$
|
| 719 |
+
format.date "year" output.check
|
| 720 |
+
}
|
| 721 |
+
{ format.article.crossref output.nonnull
|
| 722 |
+
eid empty$
|
| 723 |
+
{ format.pages output }
|
| 724 |
+
{ format.eid output }
|
| 725 |
+
if$
|
| 726 |
+
}
|
| 727 |
+
if$
|
| 728 |
+
format.issn output
|
| 729 |
+
format.doi output
|
| 730 |
+
format.url output
|
| 731 |
+
new.block
|
| 732 |
+
note output
|
| 733 |
+
fin.entry
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
FUNCTION {book}
|
| 737 |
+
{ output.bibitem
|
| 738 |
+
author empty$
|
| 739 |
+
{ format.editors "author and editor" output.check
|
| 740 |
+
editor format.key output
|
| 741 |
+
}
|
| 742 |
+
{ format.authors output.nonnull
|
| 743 |
+
crossref missing$
|
| 744 |
+
{ "author and editor" editor either.or.check }
|
| 745 |
+
'skip$
|
| 746 |
+
if$
|
| 747 |
+
}
|
| 748 |
+
if$
|
| 749 |
+
new.block
|
| 750 |
+
format.btitle "title" output.check
|
| 751 |
+
crossref missing$
|
| 752 |
+
{ format.bvolume output
|
| 753 |
+
new.block
|
| 754 |
+
format.number.series output
|
| 755 |
+
new.sentence
|
| 756 |
+
publisher "publisher" output.check
|
| 757 |
+
address output
|
| 758 |
+
}
|
| 759 |
+
{ new.block
|
| 760 |
+
format.book.crossref output.nonnull
|
| 761 |
+
}
|
| 762 |
+
if$
|
| 763 |
+
format.edition output
|
| 764 |
+
format.date "year" output.check
|
| 765 |
+
format.isbn output
|
| 766 |
+
format.doi output
|
| 767 |
+
format.url output
|
| 768 |
+
new.block
|
| 769 |
+
note output
|
| 770 |
+
fin.entry
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
FUNCTION {booklet}
|
| 774 |
+
{ output.bibitem
|
| 775 |
+
format.authors output
|
| 776 |
+
author format.key output
|
| 777 |
+
new.block
|
| 778 |
+
format.title "title" output.check
|
| 779 |
+
howpublished address new.block.checkb
|
| 780 |
+
howpublished output
|
| 781 |
+
address output
|
| 782 |
+
format.date output
|
| 783 |
+
format.isbn output
|
| 784 |
+
format.doi output
|
| 785 |
+
format.url output
|
| 786 |
+
new.block
|
| 787 |
+
note output
|
| 788 |
+
fin.entry
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
FUNCTION {inbook}
|
| 792 |
+
{ output.bibitem
|
| 793 |
+
author empty$
|
| 794 |
+
{ format.editors "author and editor" output.check
|
| 795 |
+
editor format.key output
|
| 796 |
+
}
|
| 797 |
+
{ format.authors output.nonnull
|
| 798 |
+
crossref missing$
|
| 799 |
+
{ "author and editor" editor either.or.check }
|
| 800 |
+
'skip$
|
| 801 |
+
if$
|
| 802 |
+
}
|
| 803 |
+
if$
|
| 804 |
+
new.block
|
| 805 |
+
format.btitle "title" output.check
|
| 806 |
+
crossref missing$
|
| 807 |
+
{ format.bvolume output
|
| 808 |
+
format.chapter.pages "chapter and pages" output.check
|
| 809 |
+
new.block
|
| 810 |
+
format.number.series output
|
| 811 |
+
new.sentence
|
| 812 |
+
publisher "publisher" output.check
|
| 813 |
+
address output
|
| 814 |
+
}
|
| 815 |
+
{ format.chapter.pages "chapter and pages" output.check
|
| 816 |
+
new.block
|
| 817 |
+
format.book.crossref output.nonnull
|
| 818 |
+
}
|
| 819 |
+
if$
|
| 820 |
+
format.edition output
|
| 821 |
+
format.date "year" output.check
|
| 822 |
+
format.isbn output
|
| 823 |
+
format.doi output
|
| 824 |
+
format.url output
|
| 825 |
+
new.block
|
| 826 |
+
note output
|
| 827 |
+
fin.entry
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
FUNCTION {incollection}
|
| 831 |
+
{ output.bibitem
|
| 832 |
+
format.authors "author" output.check
|
| 833 |
+
author format.key output
|
| 834 |
+
new.block
|
| 835 |
+
format.title "title" output.check
|
| 836 |
+
new.block
|
| 837 |
+
crossref missing$
|
| 838 |
+
{ format.in.ed.booktitle "booktitle" output.check
|
| 839 |
+
format.bvolume output
|
| 840 |
+
format.number.series output
|
| 841 |
+
format.chapter.pages output
|
| 842 |
+
new.sentence
|
| 843 |
+
publisher "publisher" output.check
|
| 844 |
+
address output
|
| 845 |
+
format.edition output
|
| 846 |
+
format.date "year" output.check
|
| 847 |
+
}
|
| 848 |
+
{ format.incoll.inproc.crossref output.nonnull
|
| 849 |
+
format.chapter.pages output
|
| 850 |
+
}
|
| 851 |
+
if$
|
| 852 |
+
format.isbn output
|
| 853 |
+
format.doi output
|
| 854 |
+
format.url output
|
| 855 |
+
new.block
|
| 856 |
+
note output
|
| 857 |
+
fin.entry
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
FUNCTION {inproceedings}
|
| 861 |
+
{ output.bibitem
|
| 862 |
+
format.authors "author" output.check
|
| 863 |
+
author format.key output
|
| 864 |
+
new.block
|
| 865 |
+
format.title "title" output.check
|
| 866 |
+
new.block
|
| 867 |
+
crossref missing$
|
| 868 |
+
{ format.in.ed.booktitle "booktitle" output.check
|
| 869 |
+
format.bvolume output
|
| 870 |
+
format.number.series output
|
| 871 |
+
format.pages output
|
| 872 |
+
address empty$
|
| 873 |
+
{ organization publisher new.sentence.checkb
|
| 874 |
+
organization output
|
| 875 |
+
publisher output
|
| 876 |
+
format.date "year" output.check
|
| 877 |
+
}
|
| 878 |
+
{ address output.nonnull
|
| 879 |
+
format.date "year" output.check
|
| 880 |
+
new.sentence
|
| 881 |
+
organization output
|
| 882 |
+
publisher output
|
| 883 |
+
}
|
| 884 |
+
if$
|
| 885 |
+
}
|
| 886 |
+
{ format.incoll.inproc.crossref output.nonnull
|
| 887 |
+
format.pages output
|
| 888 |
+
}
|
| 889 |
+
if$
|
| 890 |
+
format.isbn output
|
| 891 |
+
format.doi output
|
| 892 |
+
format.url output
|
| 893 |
+
new.block
|
| 894 |
+
note output
|
| 895 |
+
fin.entry
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
FUNCTION {conference} { inproceedings }
|
| 899 |
+
|
| 900 |
+
FUNCTION {manual}
|
| 901 |
+
{ output.bibitem
|
| 902 |
+
format.authors output
|
| 903 |
+
author format.key output
|
| 904 |
+
new.block
|
| 905 |
+
format.btitle "title" output.check
|
| 906 |
+
organization address new.block.checkb
|
| 907 |
+
organization output
|
| 908 |
+
address output
|
| 909 |
+
format.edition output
|
| 910 |
+
format.date output
|
| 911 |
+
format.url output
|
| 912 |
+
new.block
|
| 913 |
+
note output
|
| 914 |
+
fin.entry
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
FUNCTION {mastersthesis}
|
| 918 |
+
{ output.bibitem
|
| 919 |
+
format.authors "author" output.check
|
| 920 |
+
author format.key output
|
| 921 |
+
new.block
|
| 922 |
+
format.title "title" output.check
|
| 923 |
+
new.block
|
| 924 |
+
"Master's thesis" format.thesis.type output.nonnull
|
| 925 |
+
school "school" output.check
|
| 926 |
+
address output
|
| 927 |
+
format.date "year" output.check
|
| 928 |
+
format.url output
|
| 929 |
+
new.block
|
| 930 |
+
note output
|
| 931 |
+
fin.entry
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
FUNCTION {misc}
|
| 935 |
+
{ output.bibitem
|
| 936 |
+
format.authors output
|
| 937 |
+
author format.key output
|
| 938 |
+
title howpublished new.block.checkb
|
| 939 |
+
format.title output
|
| 940 |
+
howpublished new.block.checka
|
| 941 |
+
howpublished output
|
| 942 |
+
format.date output
|
| 943 |
+
format.issn output
|
| 944 |
+
format.url output
|
| 945 |
+
new.block
|
| 946 |
+
note output
|
| 947 |
+
fin.entry
|
| 948 |
+
empty.misc.check
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
FUNCTION {phdthesis}
|
| 952 |
+
{ output.bibitem
|
| 953 |
+
format.authors "author" output.check
|
| 954 |
+
author format.key output
|
| 955 |
+
new.block
|
| 956 |
+
format.btitle "title" output.check
|
| 957 |
+
new.block
|
| 958 |
+
"PhD thesis" format.thesis.type output.nonnull
|
| 959 |
+
school "school" output.check
|
| 960 |
+
address output
|
| 961 |
+
format.date "year" output.check
|
| 962 |
+
format.url output
|
| 963 |
+
new.block
|
| 964 |
+
note output
|
| 965 |
+
fin.entry
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
FUNCTION {proceedings}
|
| 969 |
+
{ output.bibitem
|
| 970 |
+
format.editors output
|
| 971 |
+
editor format.key output
|
| 972 |
+
new.block
|
| 973 |
+
format.btitle "title" output.check
|
| 974 |
+
format.bvolume output
|
| 975 |
+
format.number.series output
|
| 976 |
+
address output
|
| 977 |
+
format.date "year" output.check
|
| 978 |
+
new.sentence
|
| 979 |
+
organization output
|
| 980 |
+
publisher output
|
| 981 |
+
format.isbn output
|
| 982 |
+
format.doi output
|
| 983 |
+
format.url output
|
| 984 |
+
new.block
|
| 985 |
+
note output
|
| 986 |
+
fin.entry
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
FUNCTION {techreport}
|
| 990 |
+
{ output.bibitem
|
| 991 |
+
format.authors "author" output.check
|
| 992 |
+
author format.key output
|
| 993 |
+
new.block
|
| 994 |
+
format.title "title" output.check
|
| 995 |
+
new.block
|
| 996 |
+
format.tr.number output.nonnull
|
| 997 |
+
institution "institution" output.check
|
| 998 |
+
address output
|
| 999 |
+
format.date "year" output.check
|
| 1000 |
+
format.url output
|
| 1001 |
+
new.block
|
| 1002 |
+
note output
|
| 1003 |
+
fin.entry
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
FUNCTION {unpublished}
|
| 1007 |
+
{ output.bibitem
|
| 1008 |
+
format.authors "author" output.check
|
| 1009 |
+
author format.key output
|
| 1010 |
+
new.block
|
| 1011 |
+
format.title "title" output.check
|
| 1012 |
+
new.block
|
| 1013 |
+
note "note" output.check
|
| 1014 |
+
format.date output
|
| 1015 |
+
format.url output
|
| 1016 |
+
fin.entry
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
FUNCTION {default.type} { misc }
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
MACRO {jan} {"January"}
|
| 1023 |
+
|
| 1024 |
+
MACRO {feb} {"February"}
|
| 1025 |
+
|
| 1026 |
+
MACRO {mar} {"March"}
|
| 1027 |
+
|
| 1028 |
+
MACRO {apr} {"April"}
|
| 1029 |
+
|
| 1030 |
+
MACRO {may} {"May"}
|
| 1031 |
+
|
| 1032 |
+
MACRO {jun} {"June"}
|
| 1033 |
+
|
| 1034 |
+
MACRO {jul} {"July"}
|
| 1035 |
+
|
| 1036 |
+
MACRO {aug} {"August"}
|
| 1037 |
+
|
| 1038 |
+
MACRO {sep} {"September"}
|
| 1039 |
+
|
| 1040 |
+
MACRO {oct} {"October"}
|
| 1041 |
+
|
| 1042 |
+
MACRO {nov} {"November"}
|
| 1043 |
+
|
| 1044 |
+
MACRO {dec} {"December"}
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
|
| 1048 |
+
MACRO {acmcs} {"ACM Computing Surveys"}
|
| 1049 |
+
|
| 1050 |
+
MACRO {acta} {"Acta Informatica"}
|
| 1051 |
+
|
| 1052 |
+
MACRO {cacm} {"Communications of the ACM"}
|
| 1053 |
+
|
| 1054 |
+
MACRO {ibmjrd} {"IBM Journal of Research and Development"}
|
| 1055 |
+
|
| 1056 |
+
MACRO {ibmsj} {"IBM Systems Journal"}
|
| 1057 |
+
|
| 1058 |
+
MACRO {ieeese} {"IEEE Transactions on Software Engineering"}
|
| 1059 |
+
|
| 1060 |
+
MACRO {ieeetc} {"IEEE Transactions on Computers"}
|
| 1061 |
+
|
| 1062 |
+
MACRO {ieeetcad}
|
| 1063 |
+
{"IEEE Transactions on Computer-Aided Design of Integrated Circuits"}
|
| 1064 |
+
|
| 1065 |
+
MACRO {ipl} {"Information Processing Letters"}
|
| 1066 |
+
|
| 1067 |
+
MACRO {jacm} {"Journal of the ACM"}
|
| 1068 |
+
|
| 1069 |
+
MACRO {jcss} {"Journal of Computer and System Sciences"}
|
| 1070 |
+
|
| 1071 |
+
MACRO {scp} {"Science of Computer Programming"}
|
| 1072 |
+
|
| 1073 |
+
MACRO {sicomp} {"SIAM Journal on Computing"}
|
| 1074 |
+
|
| 1075 |
+
MACRO {tocs} {"ACM Transactions on Computer Systems"}
|
| 1076 |
+
|
| 1077 |
+
MACRO {tods} {"ACM Transactions on Database Systems"}
|
| 1078 |
+
|
| 1079 |
+
MACRO {tog} {"ACM Transactions on Graphics"}
|
| 1080 |
+
|
| 1081 |
+
MACRO {toms} {"ACM Transactions on Mathematical Software"}
|
| 1082 |
+
|
| 1083 |
+
MACRO {toois} {"ACM Transactions on Office Information Systems"}
|
| 1084 |
+
|
| 1085 |
+
MACRO {toplas} {"ACM Transactions on Programming Languages and Systems"}
|
| 1086 |
+
|
| 1087 |
+
MACRO {tcs} {"Theoretical Computer Science"}
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
READ
|
| 1091 |
+
|
| 1092 |
+
FUNCTION {sortify}
|
| 1093 |
+
{ purify$
|
| 1094 |
+
"l" change.case$
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
INTEGERS { len }
|
| 1098 |
+
|
| 1099 |
+
FUNCTION {chop.word}
|
| 1100 |
+
{ 's :=
|
| 1101 |
+
'len :=
|
| 1102 |
+
s #1 len substring$ =
|
| 1103 |
+
{ s len #1 + global.max$ substring$ }
|
| 1104 |
+
's
|
| 1105 |
+
if$
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
FUNCTION {format.lab.names}
|
| 1109 |
+
{ 's :=
|
| 1110 |
+
s #1 "{vv~}{ll}" format.name$
|
| 1111 |
+
s num.names$ duplicate$
|
| 1112 |
+
#2 >
|
| 1113 |
+
{ pop$ " et~al." * }
|
| 1114 |
+
{ #2 <
|
| 1115 |
+
'skip$
|
| 1116 |
+
{ s #2 "{ff }{vv }{ll}{ jj}" format.name$ "others" =
|
| 1117 |
+
{ " et~al." * }
|
| 1118 |
+
{ " \& " * s #2 "{vv~}{ll}" format.name$ * }
|
| 1119 |
+
if$
|
| 1120 |
+
}
|
| 1121 |
+
if$
|
| 1122 |
+
}
|
| 1123 |
+
if$
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
FUNCTION {author.key.label}
|
| 1127 |
+
{ author empty$
|
| 1128 |
+
{ key empty$
|
| 1129 |
+
{ cite$ #1 #3 substring$ }
|
| 1130 |
+
'key
|
| 1131 |
+
if$
|
| 1132 |
+
}
|
| 1133 |
+
{ author format.lab.names }
|
| 1134 |
+
if$
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
FUNCTION {author.editor.key.label}
|
| 1138 |
+
{ author empty$
|
| 1139 |
+
{ editor empty$
|
| 1140 |
+
{ key empty$
|
| 1141 |
+
{ cite$ #1 #3 substring$ }
|
| 1142 |
+
'key
|
| 1143 |
+
if$
|
| 1144 |
+
}
|
| 1145 |
+
{ editor format.lab.names }
|
| 1146 |
+
if$
|
| 1147 |
+
}
|
| 1148 |
+
{ author format.lab.names }
|
| 1149 |
+
if$
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
FUNCTION {author.key.organization.label}
|
| 1153 |
+
{ author empty$
|
| 1154 |
+
{ key empty$
|
| 1155 |
+
{ organization empty$
|
| 1156 |
+
{ cite$ #1 #3 substring$ }
|
| 1157 |
+
{ "The " #4 organization chop.word #3 text.prefix$ }
|
| 1158 |
+
if$
|
| 1159 |
+
}
|
| 1160 |
+
'key
|
| 1161 |
+
if$
|
| 1162 |
+
}
|
| 1163 |
+
{ author format.lab.names }
|
| 1164 |
+
if$
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
FUNCTION {editor.key.organization.label}
|
| 1168 |
+
{ editor empty$
|
| 1169 |
+
{ key empty$
|
| 1170 |
+
{ organization empty$
|
| 1171 |
+
{ cite$ #1 #3 substring$ }
|
| 1172 |
+
{ "The " #4 organization chop.word #3 text.prefix$ }
|
| 1173 |
+
if$
|
| 1174 |
+
}
|
| 1175 |
+
'key
|
| 1176 |
+
if$
|
| 1177 |
+
}
|
| 1178 |
+
{ editor format.lab.names }
|
| 1179 |
+
if$
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
FUNCTION {calc.short.authors}
|
| 1183 |
+
{ type$ "book" =
|
| 1184 |
+
type$ "inbook" =
|
| 1185 |
+
or
|
| 1186 |
+
'author.editor.key.label
|
| 1187 |
+
{ type$ "proceedings" =
|
| 1188 |
+
'editor.key.organization.label
|
| 1189 |
+
{ type$ "manual" =
|
| 1190 |
+
'author.key.organization.label
|
| 1191 |
+
'author.key.label
|
| 1192 |
+
if$
|
| 1193 |
+
}
|
| 1194 |
+
if$
|
| 1195 |
+
}
|
| 1196 |
+
if$
|
| 1197 |
+
'short.list :=
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
FUNCTION {calc.label}
|
| 1201 |
+
{ calc.short.authors
|
| 1202 |
+
short.list
|
| 1203 |
+
"("
|
| 1204 |
+
*
|
| 1205 |
+
year duplicate$ empty$
|
| 1206 |
+
short.list key field.or.null = or
|
| 1207 |
+
{ pop$ "" }
|
| 1208 |
+
'skip$
|
| 1209 |
+
if$
|
| 1210 |
+
*
|
| 1211 |
+
'label :=
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
FUNCTION {sort.format.names}
|
| 1215 |
+
{ 's :=
|
| 1216 |
+
#1 'nameptr :=
|
| 1217 |
+
""
|
| 1218 |
+
s num.names$ 'numnames :=
|
| 1219 |
+
numnames 'namesleft :=
|
| 1220 |
+
{ namesleft #0 > }
|
| 1221 |
+
{
|
| 1222 |
+
s nameptr "{vv{ } }{ll{ }}{ f{ }}{ jj{ }}" format.name$ 't :=
|
| 1223 |
+
nameptr #1 >
|
| 1224 |
+
{
|
| 1225 |
+
" " *
|
| 1226 |
+
namesleft #1 = t "others" = and
|
| 1227 |
+
{ "zzzzz" * }
|
| 1228 |
+
{ numnames #2 > nameptr #2 = and
|
| 1229 |
+
{ "zz" * year field.or.null * " " * }
|
| 1230 |
+
'skip$
|
| 1231 |
+
if$
|
| 1232 |
+
t sortify *
|
| 1233 |
+
}
|
| 1234 |
+
if$
|
| 1235 |
+
}
|
| 1236 |
+
{ t sortify * }
|
| 1237 |
+
if$
|
| 1238 |
+
nameptr #1 + 'nameptr :=
|
| 1239 |
+
namesleft #1 - 'namesleft :=
|
| 1240 |
+
}
|
| 1241 |
+
while$
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
FUNCTION {sort.format.title}
|
| 1245 |
+
{ 't :=
|
| 1246 |
+
"A " #2
|
| 1247 |
+
"An " #3
|
| 1248 |
+
"The " #4 t chop.word
|
| 1249 |
+
chop.word
|
| 1250 |
+
chop.word
|
| 1251 |
+
sortify
|
| 1252 |
+
#1 global.max$ substring$
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
FUNCTION {author.sort}
|
| 1256 |
+
{ author empty$
|
| 1257 |
+
{ key empty$
|
| 1258 |
+
{ "to sort, need author or key in " cite$ * warning$
|
| 1259 |
+
""
|
| 1260 |
+
}
|
| 1261 |
+
{ key sortify }
|
| 1262 |
+
if$
|
| 1263 |
+
}
|
| 1264 |
+
{ author sort.format.names }
|
| 1265 |
+
if$
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
FUNCTION {author.editor.sort}
|
| 1269 |
+
{ author empty$
|
| 1270 |
+
{ editor empty$
|
| 1271 |
+
{ key empty$
|
| 1272 |
+
{ "to sort, need author, editor, or key in " cite$ * warning$
|
| 1273 |
+
""
|
| 1274 |
+
}
|
| 1275 |
+
{ key sortify }
|
| 1276 |
+
if$
|
| 1277 |
+
}
|
| 1278 |
+
{ editor sort.format.names }
|
| 1279 |
+
if$
|
| 1280 |
+
}
|
| 1281 |
+
{ author sort.format.names }
|
| 1282 |
+
if$
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
FUNCTION {author.organization.sort}
|
| 1286 |
+
{ author empty$
|
| 1287 |
+
{ organization empty$
|
| 1288 |
+
{ key empty$
|
| 1289 |
+
{ "to sort, need author, organization, or key in " cite$ * warning$
|
| 1290 |
+
""
|
| 1291 |
+
}
|
| 1292 |
+
{ key sortify }
|
| 1293 |
+
if$
|
| 1294 |
+
}
|
| 1295 |
+
{ "The " #4 organization chop.word sortify }
|
| 1296 |
+
if$
|
| 1297 |
+
}
|
| 1298 |
+
{ author sort.format.names }
|
| 1299 |
+
if$
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
FUNCTION {editor.organization.sort}
|
| 1303 |
+
{ editor empty$
|
| 1304 |
+
{ organization empty$
|
| 1305 |
+
{ key empty$
|
| 1306 |
+
{ "to sort, need editor, organization, or key in " cite$ * warning$
|
| 1307 |
+
""
|
| 1308 |
+
}
|
| 1309 |
+
{ key sortify }
|
| 1310 |
+
if$
|
| 1311 |
+
}
|
| 1312 |
+
{ "The " #4 organization chop.word sortify }
|
| 1313 |
+
if$
|
| 1314 |
+
}
|
| 1315 |
+
{ editor sort.format.names }
|
| 1316 |
+
if$
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
|
| 1320 |
+
FUNCTION {presort}
|
| 1321 |
+
{ calc.label
|
| 1322 |
+
label sortify
|
| 1323 |
+
" "
|
| 1324 |
+
*
|
| 1325 |
+
type$ "book" =
|
| 1326 |
+
type$ "inbook" =
|
| 1327 |
+
or
|
| 1328 |
+
'author.editor.sort
|
| 1329 |
+
{ type$ "proceedings" =
|
| 1330 |
+
'editor.organization.sort
|
| 1331 |
+
{ type$ "manual" =
|
| 1332 |
+
'author.organization.sort
|
| 1333 |
+
'author.sort
|
| 1334 |
+
if$
|
| 1335 |
+
}
|
| 1336 |
+
if$
|
| 1337 |
+
}
|
| 1338 |
+
if$
|
| 1339 |
+
" "
|
| 1340 |
+
*
|
| 1341 |
+
year field.or.null sortify
|
| 1342 |
+
*
|
| 1343 |
+
" "
|
| 1344 |
+
*
|
| 1345 |
+
cite$
|
| 1346 |
+
*
|
| 1347 |
+
#1 entry.max$ substring$
|
| 1348 |
+
'sort.label :=
|
| 1349 |
+
sort.label *
|
| 1350 |
+
#1 entry.max$ substring$
|
| 1351 |
+
'sort.key$ :=
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
ITERATE {presort}
|
| 1355 |
+
|
| 1356 |
+
SORT
|
| 1357 |
+
|
| 1358 |
+
STRINGS { longest.label last.label next.extra }
|
| 1359 |
+
|
| 1360 |
+
INTEGERS { longest.label.width last.extra.num number.label }
|
| 1361 |
+
|
| 1362 |
+
FUNCTION {initialize.longest.label}
|
| 1363 |
+
{ "" 'longest.label :=
|
| 1364 |
+
#0 int.to.chr$ 'last.label :=
|
| 1365 |
+
"" 'next.extra :=
|
| 1366 |
+
#0 'longest.label.width :=
|
| 1367 |
+
#0 'last.extra.num :=
|
| 1368 |
+
#0 'number.label :=
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
FUNCTION {forward.pass}
|
| 1372 |
+
{ last.label label =
|
| 1373 |
+
{ last.extra.num #1 + 'last.extra.num :=
|
| 1374 |
+
last.extra.num int.to.chr$ 'extra.label :=
|
| 1375 |
+
}
|
| 1376 |
+
{ "a" chr.to.int$ 'last.extra.num :=
|
| 1377 |
+
"" 'extra.label :=
|
| 1378 |
+
label 'last.label :=
|
| 1379 |
+
}
|
| 1380 |
+
if$
|
| 1381 |
+
number.label #1 + 'number.label :=
|
| 1382 |
+
}
|
| 1383 |
+
|
| 1384 |
+
FUNCTION {reverse.pass}
|
| 1385 |
+
{ next.extra "b" =
|
| 1386 |
+
{ "a" 'extra.label := }
|
| 1387 |
+
'skip$
|
| 1388 |
+
if$
|
| 1389 |
+
extra.label 'next.extra :=
|
| 1390 |
+
extra.label
|
| 1391 |
+
duplicate$ empty$
|
| 1392 |
+
'skip$
|
| 1393 |
+
{ "{\natexlab{" swap$ * "}}" * }
|
| 1394 |
+
if$
|
| 1395 |
+
'extra.label :=
|
| 1396 |
+
label extra.label * 'label :=
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
EXECUTE {initialize.longest.label}
|
| 1400 |
+
|
| 1401 |
+
ITERATE {forward.pass}
|
| 1402 |
+
|
| 1403 |
+
REVERSE {reverse.pass}
|
| 1404 |
+
|
| 1405 |
+
FUNCTION {bib.sort.order}
|
| 1406 |
+
{ sort.label 'sort.key$ :=
|
| 1407 |
+
}
|
| 1408 |
+
|
| 1409 |
+
ITERATE {bib.sort.order}
|
| 1410 |
+
|
| 1411 |
+
SORT
|
| 1412 |
+
|
| 1413 |
+
FUNCTION {begin.bib}
|
| 1414 |
+
{ preamble$ empty$
|
| 1415 |
+
'skip$
|
| 1416 |
+
{ preamble$ write$ newline$ }
|
| 1417 |
+
if$
|
| 1418 |
+
"\begin{thebibliography}{" number.label int.to.str$ * "}" *
|
| 1419 |
+
write$ newline$
|
| 1420 |
+
"\providecommand{\natexlab}[1]{#1}"
|
| 1421 |
+
write$ newline$
|
| 1422 |
+
"\providecommand{\url}[1]{\texttt{#1}}"
|
| 1423 |
+
write$ newline$
|
| 1424 |
+
"\expandafter\ifx\csname urlstyle\endcsname\relax"
|
| 1425 |
+
write$ newline$
|
| 1426 |
+
" \providecommand{\doi}[1]{doi: #1}\else"
|
| 1427 |
+
write$ newline$
|
| 1428 |
+
" \providecommand{\doi}{doi: \begingroup \urlstyle{rm}\Url}\fi"
|
| 1429 |
+
write$ newline$
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
EXECUTE {begin.bib}
|
| 1433 |
+
|
| 1434 |
+
EXECUTE {init.state.consts}
|
| 1435 |
+
|
| 1436 |
+
ITERATE {call.type$}
|
| 1437 |
+
|
| 1438 |
+
FUNCTION {end.bib}
|
| 1439 |
+
{ newline$
|
| 1440 |
+
"\end{thebibliography}" write$ newline$
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
EXECUTE {end.bib}
|
icml2026.sty
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
% File: icml2026.sty (LaTeX style file for ICML-2026, version of 2025-10-29)
|
| 2 |
+
|
| 3 |
+
% This file contains the LaTeX formatting parameters for a two-column
|
| 4 |
+
% conference proceedings that is 8.5 inches wide by 11 inches high.
|
| 5 |
+
%
|
| 6 |
+
% Modified by Hanze Dong, Alberto Bietti, and Felix Berkenkamp, 2025
|
| 7 |
+
% - Revert to times for better compatibility
|
| 8 |
+
% - Updated years, volume, location
|
| 9 |
+
% - Added preprint version
|
| 10 |
+
% - Based on the suggestion from Johan Larsson:
|
| 11 |
+
% 1. Added an end-of-document safety check to ensure the affiliations or notice footnote is printed:
|
| 12 |
+
% (1) Introduces a flag \newif\ificml@noticeprinted and sets it false by default.
|
| 13 |
+
% (2) At end of document, emits a package warning if \printAffiliationsAndNotice{...} was never called.
|
| 14 |
+
% 2. \printAffiliationsAndNotice now sets the flag when called: Begins with \global\icml@noticeprintedtrue.
|
| 15 |
+
% - Migrated to more recent version of fancyhdr for running title in header
|
| 16 |
+
%
|
| 17 |
+
% Modified by Johan Larsson, 2025
|
| 18 |
+
% - Use newtx instead of times, aligning serif, sans-serif, typerwriter,
|
| 19 |
+
% and math fonts.
|
| 20 |
+
% - Use caption package to setup captions instead of manually defining themanually defining them.
|
| 21 |
+
% - Formatted icml2026.sty and example_paper.tex
|
| 22 |
+
% - Use title case for section title to 2.9
|
| 23 |
+
% - Replace subfigure package with subcaption in example, since it is
|
| 24 |
+
% designed to work together with the caption package (which is now required).
|
| 25 |
+
% - Remove unused label in example
|
| 26 |
+
%
|
| 27 |
+
% Modified by Tegan Maharaj and Felix Berkenkamp 2025: changed years, volume, location
|
| 28 |
+
%
|
| 29 |
+
% Modified by Jonathan Scarlett 2024: changed years, volume, location
|
| 30 |
+
%
|
| 31 |
+
% Modified by Sivan Sabato 2023: changed years and volume number.
|
| 32 |
+
% Modified by Jonathan Scarlett 2023: added page numbers to every page
|
| 33 |
+
%
|
| 34 |
+
% Modified by Csaba Szepesvari 2022: changed years, PMLR ref. Turned off checking marginparwidth
|
| 35 |
+
% as marginparwidth only controls the space available for margin notes and margin notes
|
| 36 |
+
% will NEVER be used anyways in submitted versions, so there is no reason one should
|
| 37 |
+
% check whether marginparwidth has been tampered with.
|
| 38 |
+
% Also removed pdfview=FitH from hypersetup as it did not do its job; the default choice is a bit better
|
| 39 |
+
% but of course the double-column format is not supported by this hyperlink preview functionality
|
| 40 |
+
% in a completely satisfactory fashion.
|
| 41 |
+
% Modified by Gang Niu 2022: Changed color to xcolor
|
| 42 |
+
%
|
| 43 |
+
% Modified by Iain Murray 2018: changed years, location. Remove affiliation notes when anonymous.
|
| 44 |
+
% Move times dependency from .tex to .sty so fewer people delete it.
|
| 45 |
+
%
|
| 46 |
+
% Modified by Daniel Roy 2017: changed byline to use footnotes for affiliations, and removed emails
|
| 47 |
+
%
|
| 48 |
+
% Modified by Percy Liang 12/2/2013: changed the year, location from the previous template for ICML 2014
|
| 49 |
+
|
| 50 |
+
% Modified by Fei Sha 9/2/2013: changed the year, location form the previous template for ICML 2013
|
| 51 |
+
%
|
| 52 |
+
% Modified by Fei Sha 4/24/2013: (1) remove the extra whitespace after the
|
| 53 |
+
% first author's email address (in %the camera-ready version) (2) change the
|
| 54 |
+
% Proceeding ... of ICML 2010 to 2014 so PDF's metadata will show up %
|
| 55 |
+
% correctly
|
| 56 |
+
%
|
| 57 |
+
% Modified by Sanjoy Dasgupta, 2013: changed years, location
|
| 58 |
+
%
|
| 59 |
+
% Modified by Francesco Figari, 2012: changed years, location
|
| 60 |
+
%
|
| 61 |
+
% Modified by Christoph Sawade and Tobias Scheffer, 2011: added line
|
| 62 |
+
% numbers, changed years
|
| 63 |
+
%
|
| 64 |
+
% Modified by Hal Daume III, 2010: changed years, added hyperlinks
|
| 65 |
+
%
|
| 66 |
+
% Modified by Kiri Wagstaff, 2009: changed years
|
| 67 |
+
%
|
| 68 |
+
% Modified by Sam Roweis, 2008: changed years
|
| 69 |
+
%
|
| 70 |
+
% Modified by Ricardo Silva, 2007: update of the ifpdf verification
|
| 71 |
+
%
|
| 72 |
+
% Modified by Prasad Tadepalli and Andrew Moore, merely changing years.
|
| 73 |
+
%
|
| 74 |
+
% Modified by Kristian Kersting, 2005, based on Jennifer Dy's 2004 version
|
| 75 |
+
% - running title. If the original title is to long or is breaking a line,
|
| 76 |
+
% use \icmltitlerunning{...} in the preamble to supply a shorter form.
|
| 77 |
+
% Added fancyhdr package to get a running head.
|
| 78 |
+
% - Updated to store the page size because pdflatex does compile the
|
| 79 |
+
% page size into the pdf.
|
| 80 |
+
%
|
| 81 |
+
% Hacked by Terran Lane, 2003:
|
| 82 |
+
% - Updated to use LaTeX2e style file conventions (ProvidesPackage,
|
| 83 |
+
% etc.)
|
| 84 |
+
% - Added an ``appearing in'' block at the base of the first column
|
| 85 |
+
% (thus keeping the ``appearing in'' note out of the bottom margin
|
| 86 |
+
% where the printer should strip in the page numbers).
|
| 87 |
+
% - Added a package option [accepted] that selects between the ``Under
|
| 88 |
+
% review'' notice (default, when no option is specified) and the
|
| 89 |
+
% ``Appearing in'' notice (for use when the paper has been accepted
|
| 90 |
+
% and will appear).
|
| 91 |
+
%
|
| 92 |
+
% Originally created as: ml2k.sty (LaTeX style file for ICML-2000)
|
| 93 |
+
% by P. Langley (12/23/99)
|
| 94 |
+
|
| 95 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 96 |
+
%% This version of the style file supports both a ``review'' version
|
| 97 |
+
%% and a ``final/accepted'' version. The difference is only in the
|
| 98 |
+
%% text that appears in the note at the bottom of the first column of
|
| 99 |
+
%% the first page. The default behavior is to print a note to the
|
| 100 |
+
%% effect that the paper is under review and don't distribute it. The
|
| 101 |
+
%% final/accepted version prints an ``Appearing in'' note. To get the
|
| 102 |
+
%% latter behavior, in the calling file change the ``usepackage'' line
|
| 103 |
+
%% from:
|
| 104 |
+
%% \usepackage{icml2025}
|
| 105 |
+
%% to
|
| 106 |
+
%% \usepackage[accepted]{icml2025}
|
| 107 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 108 |
+
|
| 109 |
+
\NeedsTeXFormat{LaTeX2e}
|
| 110 |
+
\ProvidesPackage{icml2026}[2025/10/29 v2.0 ICML Conference Style File]
|
| 111 |
+
|
| 112 |
+
% Before 2018, \usepackage{times} was in the example TeX, but inevitably
|
| 113 |
+
% not everybody did it.
|
| 114 |
+
% \RequirePackage[amsthm]{newtx}
|
| 115 |
+
% 2025.11.6 revert to times for better compatibility
|
| 116 |
+
\RequirePackage{times}
|
| 117 |
+
|
| 118 |
+
% Use fancyhdr package
|
| 119 |
+
\RequirePackage{fancyhdr}
|
| 120 |
+
\RequirePackage{xcolor} % changed from color to xcolor (2021/11/24)
|
| 121 |
+
\RequirePackage{algorithm}
|
| 122 |
+
\RequirePackage{algorithmic}
|
| 123 |
+
\RequirePackage{natbib}
|
| 124 |
+
\RequirePackage{eso-pic} % used by \AddToShipoutPicture
|
| 125 |
+
\RequirePackage{forloop}
|
| 126 |
+
\RequirePackage{url}
|
| 127 |
+
\RequirePackage{caption}
|
| 128 |
+
|
| 129 |
+
%%%%%%%% Options
|
| 130 |
+
\DeclareOption{accepted}{%
|
| 131 |
+
\renewcommand{\Notice@String}{\ICML@appearing}
|
| 132 |
+
\gdef\isaccepted{1}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
% === Preprint option ===
|
| 136 |
+
\DeclareOption{preprint}{%%
|
| 137 |
+
\renewcommand{\Notice@String}{\ICML@preprint}%%
|
| 138 |
+
\gdef\ispreprint{1}%%
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
% Distinct preprint footer text
|
| 142 |
+
\newcommand{\ICML@preprint}{%
|
| 143 |
+
\textit{Preprint. \today.}%
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
\DeclareOption{nohyperref}{%
|
| 147 |
+
\gdef\nohyperref{1}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
% Helper flag: show real authors for accepted or preprint
|
| 151 |
+
\newif\ificmlshowauthors
|
| 152 |
+
\icmlshowauthorsfalse
|
| 153 |
+
|
| 154 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 155 |
+
% This string is printed at the bottom of the page for the
|
| 156 |
+
% final/accepted version of the ``appearing in'' note. Modify it to
|
| 157 |
+
% change that text.
|
| 158 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 159 |
+
\newcommand{\ICML@appearing}{\textit{Proceedings of the
|
| 160 |
+
$\mathit{43}^{rd}$ International Conference on Machine Learning},
|
| 161 |
+
Seoul, South Korea. PMLR 306, 2026.
|
| 162 |
+
Copyright 2026 by the author(s).}
|
| 163 |
+
|
| 164 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 165 |
+
% This string is printed at the bottom of the page for the draft/under
|
| 166 |
+
% review version of the ``appearing in'' note. Modify it to change
|
| 167 |
+
% that text.
|
| 168 |
+
%%%%%%%%%%%%%%%%%%%%
|
| 169 |
+
\newcommand{\Notice@String}{Preliminary work. Under review by the
|
| 170 |
+
International Conference on Machine Learning (ICML)\@. Do not distribute.}
|
| 171 |
+
|
| 172 |
+
% Cause the declared options to actually be parsed and activated
|
| 173 |
+
\ProcessOptions\relax
|
| 174 |
+
|
| 175 |
+
% After options are processed, decide if authors should be visible
|
| 176 |
+
\ifdefined\isaccepted \icmlshowauthorstrue \fi
|
| 177 |
+
\ifdefined\ispreprint \icmlshowauthorstrue \fi
|
| 178 |
+
|
| 179 |
+
\ifdefined\isaccepted\else\ifdefined\ispreprint\else\ifdefined\hypersetup
|
| 180 |
+
\hypersetup{pdfauthor={Anonymous Authors}}
|
| 181 |
+
\fi\fi\fi
|
| 182 |
+
|
| 183 |
+
\ifdefined\nohyperref\else\ifdefined\hypersetup
|
| 184 |
+
\definecolor{mydarkblue}{rgb}{0,0.08,0.45}
|
| 185 |
+
\hypersetup{ %
|
| 186 |
+
pdftitle={},
|
| 187 |
+
pdfsubject={Proceedings of the International Conference on Machine Learning 2026},
|
| 188 |
+
pdfkeywords={},
|
| 189 |
+
pdfborder=0 0 0,
|
| 190 |
+
pdfpagemode=UseNone,
|
| 191 |
+
colorlinks=true,
|
| 192 |
+
linkcolor=mydarkblue,
|
| 193 |
+
citecolor=mydarkblue,
|
| 194 |
+
filecolor=mydarkblue,
|
| 195 |
+
urlcolor=mydarkblue,
|
| 196 |
+
}
|
| 197 |
+
\fi
|
| 198 |
+
\fi
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
% Uncomment the following for debugging. It will cause LaTeX to dump
|
| 203 |
+
% the version of the ``appearing in'' string that will actually appear
|
| 204 |
+
% in the document.
|
| 205 |
+
%\typeout{>> Notice string='\Notice@String'}
|
| 206 |
+
|
| 207 |
+
% Change citation commands to be more like old ICML styles
|
| 208 |
+
\newcommand{\yrcite}[1]{\citeyearpar{#1}}
|
| 209 |
+
\renewcommand{\cite}[1]{\citep{#1}}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 213 |
+
% to ensure the letter format is used. pdflatex does compile the
|
| 214 |
+
% page size into the pdf. This is done using \pdfpagewidth and
|
| 215 |
+
% \pdfpageheight. As Latex does not know this directives, we first
|
| 216 |
+
% check whether pdflatex or latex is used.
|
| 217 |
+
%
|
| 218 |
+
% Kristian Kersting 2005
|
| 219 |
+
%
|
| 220 |
+
% in order to account for the more recent use of pdfetex as the default
|
| 221 |
+
% compiler, I have changed the pdf verification.
|
| 222 |
+
%
|
| 223 |
+
% Ricardo Silva 2007
|
| 224 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 225 |
+
|
| 226 |
+
\paperwidth=8.5in
|
| 227 |
+
\paperheight=11in
|
| 228 |
+
|
| 229 |
+
% old PDFLaTex verification, circa 2005
|
| 230 |
+
%
|
| 231 |
+
%\newif\ifpdf\ifx\pdfoutput\undefined
|
| 232 |
+
% \pdffalse % we are not running PDFLaTeX
|
| 233 |
+
%\else
|
| 234 |
+
% \pdfoutput=1 % we are running PDFLaTeX
|
| 235 |
+
% \pdftrue
|
| 236 |
+
%\fi
|
| 237 |
+
|
| 238 |
+
\newif\ifpdf %adapted from ifpdf.sty
|
| 239 |
+
\ifx\pdfoutput\undefined
|
| 240 |
+
\else
|
| 241 |
+
\ifx\pdfoutput\relax
|
| 242 |
+
\else
|
| 243 |
+
\ifcase\pdfoutput
|
| 244 |
+
\else
|
| 245 |
+
\pdftrue
|
| 246 |
+
\fi
|
| 247 |
+
\fi
|
| 248 |
+
\fi
|
| 249 |
+
|
| 250 |
+
\ifpdf
|
| 251 |
+
% \pdfpagewidth=\paperwidth
|
| 252 |
+
% \pdfpageheight=\paperheight
|
| 253 |
+
\setlength{\pdfpagewidth}{8.5in}
|
| 254 |
+
\setlength{\pdfpageheight}{11in}
|
| 255 |
+
\fi
|
| 256 |
+
|
| 257 |
+
% Physical page layout
|
| 258 |
+
|
| 259 |
+
\evensidemargin -0.23in
|
| 260 |
+
\oddsidemargin -0.23in
|
| 261 |
+
\setlength\textheight{9.0in}
|
| 262 |
+
\setlength\textwidth{6.75in}
|
| 263 |
+
\setlength\columnsep{0.25in}
|
| 264 |
+
\setlength\headheight{10pt}
|
| 265 |
+
\setlength\headsep{10pt}
|
| 266 |
+
\addtolength{\topmargin}{-20pt}
|
| 267 |
+
\addtolength{\topmargin}{-0.29in}
|
| 268 |
+
|
| 269 |
+
% Historically many authors tried to include packages like geometry or fullpage,
|
| 270 |
+
% which change the page layout. It either makes the proceedings inconsistent, or
|
| 271 |
+
% wastes organizers' time chasing authors. So let's nip these problems in the
|
| 272 |
+
% bud here. -- Iain Murray 2018.
|
| 273 |
+
%\RequirePackage{printlen}
|
| 274 |
+
\AtBeginDocument{%
|
| 275 |
+
\newif\ifmarginsmessedwith
|
| 276 |
+
\marginsmessedwithfalse
|
| 277 |
+
\ifdim\oddsidemargin=-16.62178pt \else oddsidemargin has been altered.\\ \marginsmessedwithtrue\fi
|
| 278 |
+
\ifdim\headheight=10.0pt \else headheight has been altered.\\ \marginsmessedwithtrue\fi
|
| 279 |
+
\ifdim\textheight=650.43pt \else textheight has been altered.\\ \marginsmessedwithtrue\fi
|
| 280 |
+
\ifdim\marginparsep=11.0pt \else marginparsep has been altered.\\ \marginsmessedwithtrue\fi
|
| 281 |
+
\ifdim\footskip=25.0pt \else footskip has been altered.\\ \marginsmessedwithtrue\fi
|
| 282 |
+
\ifdim\hoffset=0.0pt \else hoffset has been altered.\\ \marginsmessedwithtrue\fi
|
| 283 |
+
\ifdim\paperwidth=614.295pt \else paperwidth has been altered.\\ \marginsmessedwithtrue\fi
|
| 284 |
+
\ifdim\topmargin=-24.95781pt \else topmargin has been altered.\\ \marginsmessedwithtrue\fi
|
| 285 |
+
\ifdim\headsep=10.0pt \else headsep has been altered.\\ \marginsmessedwithtrue\fi
|
| 286 |
+
\ifdim\textwidth=487.8225pt \else textwidth has been altered.\\ \marginsmessedwithtrue\fi
|
| 287 |
+
\ifdim\marginparpush=5.0pt \else marginparpush has been altered.\\ \marginsmessedwithtrue\fi
|
| 288 |
+
\ifdim\voffset=0.0pt \else voffset has been altered.\\ \marginsmessedwithtrue\fi
|
| 289 |
+
\ifdim\paperheight=794.96999pt \else paperheight has been altered.\\ \marginsmessedwithtrue\fi
|
| 290 |
+
\ifmarginsmessedwith
|
| 291 |
+
|
| 292 |
+
\textbf{\large \em The page layout violates the ICML style.}
|
| 293 |
+
|
| 294 |
+
Please do not change the page layout, or include packages like geometry,
|
| 295 |
+
savetrees, or fullpage, which change it for you.
|
| 296 |
+
|
| 297 |
+
We're not able to reliably undo arbitrary changes to the style. Please remove
|
| 298 |
+
the offending package(s), or layout-changing commands and try again.
|
| 299 |
+
|
| 300 |
+
\fi}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
%% The following is adapted from code in the acmconf.sty conference
|
| 304 |
+
%% style file. The constants in it are somewhat magical, and appear
|
| 305 |
+
%% to work well with the two-column format on US letter paper that
|
| 306 |
+
%% ICML uses, but will break if you change that layout, or if you use
|
| 307 |
+
%% a longer block of text for the copyright notice string. Fiddle with
|
| 308 |
+
%% them if necessary to get the block to fit/look right.
|
| 309 |
+
%%
|
| 310 |
+
%% -- Terran Lane, 2003
|
| 311 |
+
%%
|
| 312 |
+
%% The following comments are included verbatim from acmconf.sty:
|
| 313 |
+
%%
|
| 314 |
+
%%% This section (written by KBT) handles the 1" box in the lower left
|
| 315 |
+
%%% corner of the left column of the first page by creating a picture,
|
| 316 |
+
%%% and inserting the predefined string at the bottom (with a negative
|
| 317 |
+
%%% displacement to offset the space allocated for a non-existent
|
| 318 |
+
%%% caption).
|
| 319 |
+
%%%
|
| 320 |
+
\def\ftype@copyrightbox{8}
|
| 321 |
+
\def\@copyrightspace{
|
| 322 |
+
\@float{copyrightbox}[b]
|
| 323 |
+
\begin{center}
|
| 324 |
+
\setlength{\unitlength}{1pc}
|
| 325 |
+
\begin{picture}(20,1.5)
|
| 326 |
+
\put(0,2.5){\line(1,0){4.818}}
|
| 327 |
+
\put(0,0){\parbox[b]{19.75pc}{\small \Notice@String}}
|
| 328 |
+
\end{picture}
|
| 329 |
+
\end{center}
|
| 330 |
+
\end@float}
|
| 331 |
+
|
| 332 |
+
\setlength\footskip{25.0pt}
|
| 333 |
+
\flushbottom \twocolumn
|
| 334 |
+
\sloppy
|
| 335 |
+
|
| 336 |
+
% Clear out the addcontentsline command
|
| 337 |
+
\def\addcontentsline#1#2#3{}
|
| 338 |
+
|
| 339 |
+
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
| 340 |
+
%%% commands for formatting paper title, author names, and addresses.
|
| 341 |
+
|
| 342 |
+
% box to check the size of the running head
|
| 343 |
+
\newbox\titrun
|
| 344 |
+
|
| 345 |
+
% general page style
|
| 346 |
+
\pagestyle{fancy}
|
| 347 |
+
\fancyhf{}
|
| 348 |
+
\fancyfoot[C]{\thepage}
|
| 349 |
+
% set the width of the head rule to 1 point
|
| 350 |
+
\renewcommand{\headrulewidth}{1pt}
|
| 351 |
+
|
| 352 |
+
% definition to set the head as running head in the preamble
|
| 353 |
+
\def\icmltitlerunning#1{\gdef\@icmltitlerunning{#1}}
|
| 354 |
+
|
| 355 |
+
% main definition adapting \icmltitle from 2004
|
| 356 |
+
\long\def\icmltitle#1{%
|
| 357 |
+
|
| 358 |
+
%check whether @icmltitlerunning exists
|
| 359 |
+
% if not \icmltitle is used as running head
|
| 360 |
+
\ifx\undefined\@icmltitlerunning%
|
| 361 |
+
\gdef\@icmltitlerunning{#1}
|
| 362 |
+
\fi
|
| 363 |
+
|
| 364 |
+
%add it to pdf information
|
| 365 |
+
\ifdefined\nohyperref\else\ifdefined\hypersetup
|
| 366 |
+
\hypersetup{pdftitle={#1}}
|
| 367 |
+
\fi\fi
|
| 368 |
+
|
| 369 |
+
%get the dimension of the running title
|
| 370 |
+
\global\setbox\titrun=\vbox{\small\bf\@icmltitlerunning}
|
| 371 |
+
|
| 372 |
+
% error flag
|
| 373 |
+
\gdef\@runningtitleerror{0}
|
| 374 |
+
|
| 375 |
+
% running title too long
|
| 376 |
+
\ifdim\wd\titrun>\textwidth%
|
| 377 |
+
\gdef\@runningtitleerror{1}%
|
| 378 |
+
% running title breaks a line
|
| 379 |
+
\else \ifdim\ht\titrun>6.25pt
|
| 380 |
+
\gdef\@runningtitleerror{2}%
|
| 381 |
+
\fi
|
| 382 |
+
\fi
|
| 383 |
+
|
| 384 |
+
% if there is somthing wrong with the running title
|
| 385 |
+
\ifnum\@runningtitleerror>0
|
| 386 |
+
\typeout{}%
|
| 387 |
+
\typeout{}%
|
| 388 |
+
\typeout{*******************************************************}%
|
| 389 |
+
\typeout{Title exceeds size limitations for running head.}%
|
| 390 |
+
\typeout{Please supply a shorter form for the running head}
|
| 391 |
+
\typeout{with \string\icmltitlerunning{...}\space prior to \string\begin{document}}%
|
| 392 |
+
\typeout{*******************************************************}%
|
| 393 |
+
\typeout{}%
|
| 394 |
+
\typeout{}%
|
| 395 |
+
% set default running title
|
| 396 |
+
\gdef\@icmltitlerunning{Title Suppressed Due to Excessive Size}
|
| 397 |
+
\fi
|
| 398 |
+
|
| 399 |
+
% no running title on the first page of the paper
|
| 400 |
+
\thispagestyle{plain}
|
| 401 |
+
|
| 402 |
+
{\center\baselineskip 18pt
|
| 403 |
+
\toptitlebar{\Large\bf #1}\bottomtitlebar}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
% set running title header
|
| 407 |
+
\fancyhead[C]{\small\bf\@icmltitlerunning}
|
| 408 |
+
|
| 409 |
+
\gdef\icmlfullauthorlist{}
|
| 410 |
+
\newcommand\addstringtofullauthorlist{\g@addto@macro\icmlfullauthorlist}
|
| 411 |
+
\newcommand\addtofullauthorlist[1]{%
|
| 412 |
+
\ifdefined\icmlanyauthors%
|
| 413 |
+
\addstringtofullauthorlist{, #1}%
|
| 414 |
+
\else%
|
| 415 |
+
\addstringtofullauthorlist{#1}%
|
| 416 |
+
\gdef\icmlanyauthors{1}%
|
| 417 |
+
\fi%
|
| 418 |
+
\ifdefined\hypersetup%
|
| 419 |
+
\hypersetup{pdfauthor=\icmlfullauthorlist}%
|
| 420 |
+
\fi
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
\def\toptitlebar{\hrule height1pt \vskip .25in}
|
| 424 |
+
\def\bottomtitlebar{\vskip .22in \hrule height1pt \vskip .3in}
|
| 425 |
+
|
| 426 |
+
\newenvironment{icmlauthorlist}{%
|
| 427 |
+
\setlength\topsep{0pt}
|
| 428 |
+
\setlength\parskip{0pt}
|
| 429 |
+
\begin{center}
|
| 430 |
+
}{%
|
| 431 |
+
\end{center}
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
\newcounter{@affiliationcounter}
|
| 435 |
+
\newcommand{\@pa}[1]{%
|
| 436 |
+
\ifcsname the@affil#1\endcsname
|
| 437 |
+
% do nothing
|
| 438 |
+
\else
|
| 439 |
+
\ifcsname @icmlsymbol#1\endcsname
|
| 440 |
+
% nothing
|
| 441 |
+
\else
|
| 442 |
+
\stepcounter{@affiliationcounter}%
|
| 443 |
+
\newcounter{@affil#1}%
|
| 444 |
+
\setcounter{@affil#1}{\value{@affiliationcounter}}%
|
| 445 |
+
\fi
|
| 446 |
+
\fi%
|
| 447 |
+
\ifcsname @icmlsymbol#1\endcsname
|
| 448 |
+
\textsuperscript{\csname @icmlsymbol#1\endcsname\,}%
|
| 449 |
+
\else
|
| 450 |
+
\textsuperscript{\arabic{@affil#1}\,}%
|
| 451 |
+
\fi
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
\newcommand{\icmlauthor}[2]{%
|
| 455 |
+
\ificmlshowauthors
|
| 456 |
+
\mbox{\bf #1}\,\@for\theaffil:=#2\do{\@pa{\theaffil}} \addtofullauthorlist{#1}%
|
| 457 |
+
\else
|
| 458 |
+
\ifdefined\@icmlfirsttime\else
|
| 459 |
+
\gdef\@icmlfirsttime{1}
|
| 460 |
+
\mbox{\bf Anonymous Authors}\@pa{@anon} \addtofullauthorlist{Anonymous Authors}
|
| 461 |
+
\fi
|
| 462 |
+
\fi
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
\newcommand{\icmlsetsymbol}[2]{%
|
| 466 |
+
\expandafter\gdef\csname @icmlsymbol#1\endcsname{#2}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
\newcommand{\icmlaffiliation}[2]{%
|
| 470 |
+
\ificmlshowauthors
|
| 471 |
+
\ifcsname the@affil#1\endcsname
|
| 472 |
+
\expandafter\gdef\csname @affilname\csname the@affil#1\endcsname\endcsname{#2}%
|
| 473 |
+
\else
|
| 474 |
+
{\bf AUTHORERR: Error in use of \textbackslash{}icmlaffiliation command. Label ``#1'' not mentioned in some \textbackslash{}icmlauthor\{author name\}\{labels here\} command beforehand. }
|
| 475 |
+
\typeout{}%
|
| 476 |
+
\typeout{}%
|
| 477 |
+
\typeout{*******************************************************}%
|
| 478 |
+
\typeout{Affiliation label undefined. }%
|
| 479 |
+
\typeout{Make sure \string\icmlaffiliation\space follows }%
|
| 480 |
+
\typeout{all of \string\icmlauthor\space commands}%
|
| 481 |
+
\typeout{*******************************************************}%
|
| 482 |
+
\typeout{}%
|
| 483 |
+
\typeout{}%
|
| 484 |
+
\fi
|
| 485 |
+
\else
|
| 486 |
+
\expandafter\gdef\csname @affilname1\endcsname{Anonymous Institution, Anonymous City, Anonymous Region, Anonymous Country}
|
| 487 |
+
\fi
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
\newcommand{\icmlcorrespondingauthor}[2]{%
|
| 491 |
+
\ificmlshowauthors
|
| 492 |
+
\ifdefined\icmlcorrespondingauthor@text
|
| 493 |
+
\g@addto@macro\icmlcorrespondingauthor@text{, #1 \textless{}#2\textgreater{}}
|
| 494 |
+
\else
|
| 495 |
+
\gdef\icmlcorrespondingauthor@text{#1 \textless{}#2\textgreater{}}
|
| 496 |
+
\fi
|
| 497 |
+
\else
|
| 498 |
+
\gdef\icmlcorrespondingauthor@text{Anonymous Author \textless{}anon.email@domain.com\textgreater{}}
|
| 499 |
+
\fi
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
\newcommand{\icmlEqualContribution}{\textsuperscript{*}Equal contribution }
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
% --- ICML 2026: ensure authors do not omit the affiliations/notice footnote ---
|
| 506 |
+
\newif\ificml@noticeprinted
|
| 507 |
+
\icml@noticeprintedfalse
|
| 508 |
+
\AtEndDocument{%
|
| 509 |
+
\ificml@noticeprinted\relax\else
|
| 510 |
+
\PackageWarningNoLine{icml2026}{%
|
| 511 |
+
You did not call \string\printAffiliationsAndNotice{}. If you have no notice,%
|
| 512 |
+
call \string\printAffiliationsAndNotice\string{} (empty braces).%
|
| 513 |
+
}%
|
| 514 |
+
\fi
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
\newcounter{@affilnum}
|
| 519 |
+
\newcommand{\printAffiliationsAndNotice}[1]{\global\icml@noticeprintedtrue%
|
| 520 |
+
\stepcounter{@affiliationcounter}%
|
| 521 |
+
{\let\thefootnote\relax\footnotetext{\hspace*{-\footnotesep}\ificmlshowauthors #1\fi%
|
| 522 |
+
\forloop{@affilnum}{1}{\value{@affilnum} < \value{@affiliationcounter}}{
|
| 523 |
+
\textsuperscript{\arabic{@affilnum}}\ifcsname @affilname\the@affilnum\endcsname%
|
| 524 |
+
\csname @affilname\the@affilnum\endcsname%
|
| 525 |
+
\else
|
| 526 |
+
{\bf AUTHORERR: Missing \textbackslash{}icmlaffiliation.}
|
| 527 |
+
\fi
|
| 528 |
+
}.%
|
| 529 |
+
\ifdefined\icmlcorrespondingauthor@text
|
| 530 |
+
{ }Correspondence to: \icmlcorrespondingauthor@text.
|
| 531 |
+
\else
|
| 532 |
+
{\bf AUTHORERR: Missing \textbackslash{}icmlcorrespondingauthor.}
|
| 533 |
+
\fi
|
| 534 |
+
|
| 535 |
+
\ \\
|
| 536 |
+
\Notice@String
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
\long\def\icmladdress#1{%
|
| 542 |
+
{\bf The \textbackslash{}icmladdress command is no longer used. See the example\_paper PDF .tex for usage of \textbackslash{}icmlauther and \textbackslash{}icmlaffiliation.}
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
%% keywords as first class citizens
|
| 546 |
+
\def\icmlkeywords#1{%
|
| 547 |
+
\ifdefined\nohyperref\else\ifdefined\hypersetup
|
| 548 |
+
\hypersetup{pdfkeywords={#1}}
|
| 549 |
+
\fi\fi
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
% modification to natbib citations
|
| 553 |
+
\setcitestyle{authoryear,round,citesep={;},aysep={,},yysep={;}}
|
| 554 |
+
|
| 555 |
+
% Redefinition of the abstract environment.
|
| 556 |
+
\renewenvironment{abstract}
|
| 557 |
+
{%
|
| 558 |
+
\centerline{\large\bf Abstract}
|
| 559 |
+
\vspace{-0.12in}\begin{quote}}
|
| 560 |
+
{\par\end{quote}\vskip 0.12in}
|
| 561 |
+
|
| 562 |
+
% numbered section headings with different treatment of numbers
|
| 563 |
+
|
| 564 |
+
\def\@startsection#1#2#3#4#5#6{\if@noskipsec \leavevmode \fi
|
| 565 |
+
\par \@tempskipa #4\relax
|
| 566 |
+
\@afterindenttrue
|
| 567 |
+
\ifdim \@tempskipa <\z@ \@tempskipa -\@tempskipa \fi
|
| 568 |
+
\if@nobreak \everypar{}\else
|
| 569 |
+
\addpenalty{\@secpenalty}\addvspace{\@tempskipa}\fi \@ifstar
|
| 570 |
+
{\@ssect{#3}{#4}{#5}{#6}}{\@dblarg{\@sict{#1}{#2}{#3}{#4}{#5}{#6}}}}
|
| 571 |
+
|
| 572 |
+
\def\@sict#1#2#3#4#5#6[#7]#8{\ifnum #2>\c@secnumdepth
|
| 573 |
+
\def\@svsec{}\else
|
| 574 |
+
\refstepcounter{#1}\edef\@svsec{\csname the#1\endcsname}\fi
|
| 575 |
+
\@tempskipa #5\relax
|
| 576 |
+
\ifdim \@tempskipa>\z@
|
| 577 |
+
\begingroup #6\relax
|
| 578 |
+
\@hangfrom{\hskip #3\relax\@svsec.~}{\interlinepenalty \@M #8\par}
|
| 579 |
+
\endgroup
|
| 580 |
+
\csname #1mark\endcsname{#7}\addcontentsline
|
| 581 |
+
{toc}{#1}{\ifnum #2>\c@secnumdepth \else
|
| 582 |
+
\protect\numberline{\csname the#1\endcsname}\fi
|
| 583 |
+
#7}\else
|
| 584 |
+
\def\@svsechd{#6\hskip #3\@svsec #8\csname #1mark\endcsname
|
| 585 |
+
{#7}\addcontentsline
|
| 586 |
+
{toc}{#1}{\ifnum #2>\c@secnumdepth \else
|
| 587 |
+
\protect\numberline{\csname the#1\endcsname}\fi
|
| 588 |
+
#7}}\fi
|
| 589 |
+
\@xsect{#5}}
|
| 590 |
+
|
| 591 |
+
\def\@sect#1#2#3#4#5#6[#7]#8{\ifnum #2>\c@secnumdepth
|
| 592 |
+
\def\@svsec{}\else
|
| 593 |
+
\refstepcounter{#1}\edef\@svsec{\csname the#1\endcsname\hskip 0.4em }\fi
|
| 594 |
+
\@tempskipa #5\relax
|
| 595 |
+
\ifdim \@tempskipa>\z@
|
| 596 |
+
\begingroup #6\relax
|
| 597 |
+
\@hangfrom{\hskip #3\relax\@svsec}{\interlinepenalty \@M #8\par}
|
| 598 |
+
\endgroup
|
| 599 |
+
\csname #1mark\endcsname{#7}\addcontentsline
|
| 600 |
+
{toc}{#1}{\ifnum #2>\c@secnumdepth \else
|
| 601 |
+
\protect\numberline{\csname the#1\endcsname}\fi
|
| 602 |
+
#7}\else
|
| 603 |
+
\def\@svsechd{#6\hskip #3\@svsec #8\csname #1mark\endcsname
|
| 604 |
+
{#7}\addcontentsline
|
| 605 |
+
{toc}{#1}{\ifnum #2>\c@secnumdepth \else
|
| 606 |
+
\protect\numberline{\csname the#1\endcsname}\fi
|
| 607 |
+
#7}}\fi
|
| 608 |
+
\@xsect{#5}}
|
| 609 |
+
|
| 610 |
+
% section headings with less space above and below them
|
| 611 |
+
\def\thesection {\arabic{section}}
|
| 612 |
+
\def\thesubsection {\thesection.\arabic{subsection}}
|
| 613 |
+
\def\section{\@startsection{section}{1}{\z@}{-0.12in}{0.02in}
|
| 614 |
+
{\large\bf\raggedright}}
|
| 615 |
+
\def\subsection{\@startsection{subsection}{2}{\z@}{-0.10in}{0.01in}
|
| 616 |
+
{\normalsize\bf\raggedright}}
|
| 617 |
+
\def\subsubsection{\@startsection{subsubsection}{3}{\z@}{-0.08in}{0.01in}
|
| 618 |
+
{\normalsize\sc\raggedright}}
|
| 619 |
+
\def\paragraph{\@startsection{paragraph}{4}{\z@}{1.5ex plus
|
| 620 |
+
0.5ex minus .2ex}{-1em}{\normalsize\bf}}
|
| 621 |
+
\def\subparagraph{\@startsection{subparagraph}{5}{\z@}{1.5ex plus
|
| 622 |
+
0.5ex minus .2ex}{-1em}{\normalsize\bf}}
|
| 623 |
+
|
| 624 |
+
% Footnotes
|
| 625 |
+
\footnotesep 6.65pt %
|
| 626 |
+
\skip\footins 9pt
|
| 627 |
+
\def\footnoterule{\kern-3pt \hrule width 0.8in \kern 2.6pt }
|
| 628 |
+
\setcounter{footnote}{0}
|
| 629 |
+
|
| 630 |
+
% Lists and paragraphs
|
| 631 |
+
\parindent 0pt
|
| 632 |
+
\topsep 4pt plus 1pt minus 2pt
|
| 633 |
+
\partopsep 1pt plus 0.5pt minus 0.5pt
|
| 634 |
+
\itemsep 2pt plus 1pt minus 0.5pt
|
| 635 |
+
\parsep 2pt plus 1pt minus 0.5pt
|
| 636 |
+
\parskip 6pt
|
| 637 |
+
|
| 638 |
+
\leftmargin 2em \leftmargini\leftmargin \leftmarginii 2em
|
| 639 |
+
\leftmarginiii 1.5em \leftmarginiv 1.0em \leftmarginv .5em
|
| 640 |
+
\leftmarginvi .5em
|
| 641 |
+
\labelwidth\leftmargini\advance\labelwidth-\labelsep \labelsep 5pt
|
| 642 |
+
|
| 643 |
+
\def\@listi{\leftmargin\leftmargini}
|
| 644 |
+
\def\@listii{\leftmargin\leftmarginii
|
| 645 |
+
\labelwidth\leftmarginii\advance\labelwidth-\labelsep
|
| 646 |
+
\topsep 2pt plus 1pt minus 0.5pt
|
| 647 |
+
\parsep 1pt plus 0.5pt minus 0.5pt
|
| 648 |
+
\itemsep \parsep}
|
| 649 |
+
\def\@listiii{\leftmargin\leftmarginiii
|
| 650 |
+
\labelwidth\leftmarginiii\advance\labelwidth-\labelsep
|
| 651 |
+
\topsep 1pt plus 0.5pt minus 0.5pt
|
| 652 |
+
\parsep \z@ \partopsep 0.5pt plus 0pt minus 0.5pt
|
| 653 |
+
\itemsep \topsep}
|
| 654 |
+
\def\@listiv{\leftmargin\leftmarginiv
|
| 655 |
+
\labelwidth\leftmarginiv\advance\labelwidth-\labelsep}
|
| 656 |
+
\def\@listv{\leftmargin\leftmarginv
|
| 657 |
+
\labelwidth\leftmarginv\advance\labelwidth-\labelsep}
|
| 658 |
+
\def\@listvi{\leftmargin\leftmarginvi
|
| 659 |
+
\labelwidth\leftmarginvi\advance\labelwidth-\labelsep}
|
| 660 |
+
|
| 661 |
+
\abovedisplayskip 7pt plus2pt minus5pt%
|
| 662 |
+
\belowdisplayskip \abovedisplayskip
|
| 663 |
+
\abovedisplayshortskip 0pt plus3pt%
|
| 664 |
+
\belowdisplayshortskip 4pt plus3pt minus3pt%
|
| 665 |
+
|
| 666 |
+
% Less leading in most fonts (due to the narrow columns)
|
| 667 |
+
% The choices were between 1-pt and 1.5-pt leading
|
| 668 |
+
\def\@normalsize{\@setsize\normalsize{11pt}\xpt\@xpt}
|
| 669 |
+
\def\small{\@setsize\small{10pt}\ixpt\@ixpt}
|
| 670 |
+
\def\footnotesize{\@setsize\footnotesize{10pt}\ixpt\@ixpt}
|
| 671 |
+
\def\scriptsize{\@setsize\scriptsize{8pt}\viipt\@viipt}
|
| 672 |
+
\def\tiny{\@setsize\tiny{7pt}\vipt\@vipt}
|
| 673 |
+
\def\large{\@setsize\large{14pt}\xiipt\@xiipt}
|
| 674 |
+
\def\Large{\@setsize\Large{16pt}\xivpt\@xivpt}
|
| 675 |
+
\def\LARGE{\@setsize\LARGE{20pt}\xviipt\@xviipt}
|
| 676 |
+
\def\huge{\@setsize\huge{23pt}\xxpt\@xxpt}
|
| 677 |
+
\def\Huge{\@setsize\Huge{28pt}\xxvpt\@xxvpt}
|
| 678 |
+
|
| 679 |
+
% Revised formatting for figure captions and table titles.
|
| 680 |
+
\captionsetup{
|
| 681 |
+
skip=0.1in,
|
| 682 |
+
font=small,
|
| 683 |
+
labelfont={it,small},
|
| 684 |
+
labelsep=period
|
| 685 |
+
}
|
| 686 |
+
\captionsetup[table]{position=above}
|
| 687 |
+
\captionsetup[figure]{position=below}
|
| 688 |
+
|
| 689 |
+
\def\fnum@figure{Figure \thefigure}
|
| 690 |
+
\def\fnum@table{Table \thetable}
|
| 691 |
+
|
| 692 |
+
% Strut macros for skipping spaces above and below text in tables.
|
| 693 |
+
\def\abovestrut#1{\rule[0in]{0in}{#1}\ignorespaces}
|
| 694 |
+
\def\belowstrut#1{\rule[-#1]{0in}{#1}\ignorespaces}
|
| 695 |
+
|
| 696 |
+
\def\abovespace{\abovestrut{0.20in}}
|
| 697 |
+
\def\aroundspace{\abovestrut{0.20in}\belowstrut{0.10in}}
|
| 698 |
+
\def\belowspace{\belowstrut{0.10in}}
|
| 699 |
+
|
| 700 |
+
% Various personal itemization commands.
|
| 701 |
+
\def\texitem#1{\par\noindent\hangindent 12pt
|
| 702 |
+
\hbox to 12pt {\hss #1 ~}\ignorespaces}
|
| 703 |
+
\def\icmlitem{\texitem{$\bullet$}}
|
| 704 |
+
|
| 705 |
+
% To comment out multiple lines of text.
|
| 706 |
+
\long\def\comment#1{}
|
| 707 |
+
|
| 708 |
+
%% Line counter (not in final version). Adapted from NIPS style file by Christoph Sawade
|
| 709 |
+
|
| 710 |
+
% Vertical Ruler
|
| 711 |
+
% This code is, largely, from the CVPR 2010 conference style file
|
| 712 |
+
% ----- define vruler
|
| 713 |
+
\makeatletter
|
| 714 |
+
\newbox\icmlrulerbox
|
| 715 |
+
\newcount\icmlrulercount
|
| 716 |
+
\newdimen\icmlruleroffset
|
| 717 |
+
\newdimen\cv@lineheight
|
| 718 |
+
\newdimen\cv@boxheight
|
| 719 |
+
\newbox\cv@tmpbox
|
| 720 |
+
\newcount\cv@refno
|
| 721 |
+
\newcount\cv@tot
|
| 722 |
+
% NUMBER with left flushed zeros \fillzeros[<WIDTH>]<NUMBER>
|
| 723 |
+
\newcount\cv@tmpc@ \newcount\cv@tmpc
|
| 724 |
+
\def\fillzeros[#1]#2{\cv@tmpc@=#2\relax\ifnum\cv@tmpc@<0\cv@tmpc@=-\cv@tmpc@\fi
|
| 725 |
+
\cv@tmpc=1 %
|
| 726 |
+
\loop\ifnum\cv@tmpc@<10 \else \divide\cv@tmpc@ by 10 \advance\cv@tmpc by 1 \fi
|
| 727 |
+
\ifnum\cv@tmpc@=10\relax\cv@tmpc@=11\relax\fi \ifnum\cv@tmpc@>10 \repeat
|
| 728 |
+
\ifnum#2<0\advance\cv@tmpc1\relax-\fi
|
| 729 |
+
\loop\ifnum\cv@tmpc<#1\relax0\advance\cv@tmpc1\relax\fi \ifnum\cv@tmpc<#1 \repeat
|
| 730 |
+
\cv@tmpc@=#2\relax\ifnum\cv@tmpc@<0\cv@tmpc@=-\cv@tmpc@\fi \relax\the\cv@tmpc@}%
|
| 731 |
+
% \makevruler[<SCALE>][<INITIAL_COUNT>][<STEP>][<DIGITS>][<HEIGHT>]
|
| 732 |
+
\def\makevruler[#1][#2][#3][#4][#5]{
|
| 733 |
+
\begingroup\offinterlineskip
|
| 734 |
+
\textheight=#5\vbadness=10000\vfuzz=120ex\overfullrule=0pt%
|
| 735 |
+
\global\setbox\icmlrulerbox=\vbox to \textheight{%
|
| 736 |
+
{
|
| 737 |
+
\parskip=0pt\hfuzz=150em\cv@boxheight=\textheight
|
| 738 |
+
\cv@lineheight=#1\global\icmlrulercount=#2%
|
| 739 |
+
\cv@tot\cv@boxheight\divide\cv@tot\cv@lineheight\advance\cv@tot2%
|
| 740 |
+
\cv@refno1\vskip-\cv@lineheight\vskip1ex%
|
| 741 |
+
\loop\setbox\cv@tmpbox=\hbox to0cm{\hfil {\hfil\fillzeros[#4]\icmlrulercount}}%
|
| 742 |
+
\ht\cv@tmpbox\cv@lineheight\dp\cv@tmpbox0pt\box\cv@tmpbox\break
|
| 743 |
+
\advance\cv@refno1\global\advance\icmlrulercount#3\relax
|
| 744 |
+
\ifnum\cv@refno<\cv@tot\repeat
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
\endgroup
|
| 748 |
+
}%
|
| 749 |
+
\makeatother
|
| 750 |
+
% ----- end of vruler
|
| 751 |
+
|
| 752 |
+
% \makevruler[<SCALE>][<INITIAL_COUNT>][<STEP>][<DIGITS>][<HEIGHT>]
|
| 753 |
+
\def\icmlruler#1{\makevruler[12pt][#1][1][3][\textheight]\usebox{\icmlrulerbox}}
|
| 754 |
+
\AddToShipoutPicture{%
|
| 755 |
+
\icmlruleroffset=\textheight
|
| 756 |
+
\advance\icmlruleroffset by 5.2pt % top margin
|
| 757 |
+
\color[rgb]{.7,.7,.7}
|
| 758 |
+
\ificmlshowauthors\else
|
| 759 |
+
\AtTextUpperLeft{%
|
| 760 |
+
\put(\LenToUnit{-35pt},\LenToUnit{-\icmlruleroffset}){%left ruler
|
| 761 |
+
\icmlruler{\icmlrulercount}}
|
| 762 |
+
%\put(\LenToUnit{1.04\textwidth},\LenToUnit{-\icmlruleroffset}){%right ruler
|
| 763 |
+
% \icmlruler{\icmlrulercount}}
|
| 764 |
+
}
|
| 765 |
+
\fi
|
| 766 |
+
}
|
| 767 |
+
\endinput
|
output/comprehensive_improvements/figures/experiment_b_pathway_consistency.png
ADDED
|
output/final_fixes/results/pathway_specificity.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"pancreas_scPTR_gamma": {
|
| 3 |
+
"dataset": "pancreas",
|
| 4 |
+
"method": "scPTR_gamma",
|
| 5 |
+
"n_total_pathways": 40,
|
| 6 |
+
"n_generic": 12,
|
| 7 |
+
"n_tissue_specific": 13,
|
| 8 |
+
"n_non_generic": 28,
|
| 9 |
+
"n_unique_to_method": 2,
|
| 10 |
+
"generic_fraction": 0.3,
|
| 11 |
+
"mean_invisibility": 0.029185585677623742,
|
| 12 |
+
"mean_diff_genes": 6441.125
|
| 13 |
+
},
|
| 14 |
+
"pancreas_raw_u_s_ratio": {
|
| 15 |
+
"dataset": "pancreas",
|
| 16 |
+
"method": "raw_u_s_ratio",
|
| 17 |
+
"n_total_pathways": 40,
|
| 18 |
+
"n_generic": 8,
|
| 19 |
+
"n_tissue_specific": 10,
|
| 20 |
+
"n_non_generic": 32,
|
| 21 |
+
"n_unique_to_method": 2,
|
| 22 |
+
"generic_fraction": 0.2,
|
| 23 |
+
"mean_invisibility": 0.1864106031134724,
|
| 24 |
+
"mean_diff_genes": 5790.0
|
| 25 |
+
},
|
| 26 |
+
"pancreas_unspliced_only": {
|
| 27 |
+
"dataset": "pancreas",
|
| 28 |
+
"method": "unspliced_only",
|
| 29 |
+
"n_total_pathways": 40,
|
| 30 |
+
"n_generic": 4,
|
| 31 |
+
"n_tissue_specific": 14,
|
| 32 |
+
"n_non_generic": 36,
|
| 33 |
+
"n_unique_to_method": 8,
|
| 34 |
+
"generic_fraction": 0.1,
|
| 35 |
+
"mean_invisibility": 0.38459356455132365,
|
| 36 |
+
"mean_diff_genes": 7768.375
|
| 37 |
+
},
|
| 38 |
+
"dentate_gyrus_scPTR_gamma": {
|
| 39 |
+
"dataset": "dentate_gyrus",
|
| 40 |
+
"method": "scPTR_gamma",
|
| 41 |
+
"n_total_pathways": 50,
|
| 42 |
+
"n_generic": 27,
|
| 43 |
+
"n_tissue_specific": 9,
|
| 44 |
+
"n_non_generic": 23,
|
| 45 |
+
"n_unique_to_method": 2,
|
| 46 |
+
"generic_fraction": 0.54,
|
| 47 |
+
"mean_invisibility": 0.11836135569451883,
|
| 48 |
+
"mean_diff_genes": 1739.5454545454545
|
| 49 |
+
},
|
| 50 |
+
"dentate_gyrus_raw_u_s_ratio": {
|
| 51 |
+
"dataset": "dentate_gyrus",
|
| 52 |
+
"method": "raw_u_s_ratio",
|
| 53 |
+
"n_total_pathways": 50,
|
| 54 |
+
"n_generic": 24,
|
| 55 |
+
"n_tissue_specific": 7,
|
| 56 |
+
"n_non_generic": 26,
|
| 57 |
+
"n_unique_to_method": 5,
|
| 58 |
+
"generic_fraction": 0.48,
|
| 59 |
+
"mean_invisibility": 0.13538253624838859,
|
| 60 |
+
"mean_diff_genes": 1641.909090909091
|
| 61 |
+
},
|
| 62 |
+
"dentate_gyrus_unspliced_only": {
|
| 63 |
+
"dataset": "dentate_gyrus",
|
| 64 |
+
"method": "unspliced_only",
|
| 65 |
+
"n_total_pathways": 50,
|
| 66 |
+
"n_generic": 34,
|
| 67 |
+
"n_tissue_specific": 6,
|
| 68 |
+
"n_non_generic": 16,
|
| 69 |
+
"n_unique_to_method": 2,
|
| 70 |
+
"generic_fraction": 0.68,
|
| 71 |
+
"mean_invisibility": 0.15938146188855168,
|
| 72 |
+
"mean_diff_genes": 3161.3
|
| 73 |
+
},
|
| 74 |
+
"winner_counts": {
|
| 75 |
+
"scPTR_gamma": 2,
|
| 76 |
+
"raw_u_s_ratio": 0,
|
| 77 |
+
"unspliced_only": 7,
|
| 78 |
+
"tie": 10
|
| 79 |
+
}
|
| 80 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=64", "setuptools-scm"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "scptr"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Single-cell post-transcriptional regulation analysis"
|
| 9 |
+
requires-python = ">=3.9"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"anndata>=0.8",
|
| 12 |
+
"scanpy>=1.9",
|
| 13 |
+
"numpy>=1.21",
|
| 14 |
+
"scipy>=1.7",
|
| 15 |
+
"numba>=0.55",
|
| 16 |
+
"pandas>=1.3",
|
| 17 |
+
"matplotlib>=3.5",
|
| 18 |
+
"seaborn>=0.11",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[project.optional-dependencies]
|
| 22 |
+
dev = [
|
| 23 |
+
"pytest>=7.0",
|
| 24 |
+
"scikit-learn>=1.0",
|
| 25 |
+
]
|
| 26 |
+
datasets = [
|
| 27 |
+
"pooch>=1.6",
|
| 28 |
+
]
|
| 29 |
+
deep = [
|
| 30 |
+
"torch>=2.0",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
[tool.setuptools.packages.find]
|
| 34 |
+
where = ["src"]
|
| 35 |
+
|
| 36 |
+
[tool.setuptools.package-data]
|
| 37 |
+
"scptr.tools" = ["data/*.csv"]
|
| 38 |
+
"scptr.datasets" = ["data/*.csv"]
|
| 39 |
+
"scptr.benchmark" = ["data/*.txt"]
|
| 40 |
+
|
| 41 |
+
[tool.pytest.ini_options]
|
| 42 |
+
markers = [
|
| 43 |
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
| 44 |
+
]
|
src/scptr/benchmark/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark module for scPTR — validation and evaluation metrics."""
|
| 2 |
+
|
| 3 |
+
from ._halflife_correlation import correlate_with_halflives
|
| 4 |
+
from ._enrichment import are_enrichment, nmd_enrichment
|
| 5 |
+
from ._robustness import subsampling_robustness
|
| 6 |
+
from ._consistency import cross_dataset_consistency
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"correlate_with_halflives",
|
| 10 |
+
"are_enrichment",
|
| 11 |
+
"nmd_enrichment",
|
| 12 |
+
"subsampling_robustness",
|
| 13 |
+
"cross_dataset_consistency",
|
| 14 |
+
]
|
src/scptr/benchmark/_enrichment.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enrichment analysis for AU-rich element (ARE) and NMD target genes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from anndata import AnnData
|
| 10 |
+
from scipy import stats
|
| 11 |
+
|
| 12 |
+
from .._constants import GAMMA
|
| 13 |
+
from .._utils import get_layer, require_layers
|
| 14 |
+
|
| 15 |
+
_DATA_DIR = Path(__file__).parent / "data"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _load_gene_list(filename: str) -> set[str]:
|
| 19 |
+
"""Load a gene list from a bundled text file (one gene per line)."""
|
| 20 |
+
path = _DATA_DIR / filename
|
| 21 |
+
with open(path) as f:
|
| 22 |
+
return {line.strip() for line in f if line.strip()}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _enrichment_test(
|
| 26 |
+
adata: AnnData,
|
| 27 |
+
gene_set: set[str],
|
| 28 |
+
label: str,
|
| 29 |
+
min_gamma_fraction: float = 0.1,
|
| 30 |
+
) -> dict:
|
| 31 |
+
"""Mann-Whitney U test: do genes in gene_set have higher gamma?
|
| 32 |
+
|
| 33 |
+
Only tests genes with sufficient non-zero gamma signal.
|
| 34 |
+
"""
|
| 35 |
+
require_layers(adata, GAMMA)
|
| 36 |
+
|
| 37 |
+
gamma = get_layer(adata, GAMMA)
|
| 38 |
+
median_gamma = np.median(gamma, axis=0)
|
| 39 |
+
|
| 40 |
+
# Filter to genes with reliable gamma estimates
|
| 41 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 42 |
+
reliable = nonzero_frac >= min_gamma_fraction
|
| 43 |
+
|
| 44 |
+
gene_names = adata.var_names.tolist()
|
| 45 |
+
in_set = np.array([g in gene_set for g in gene_names])
|
| 46 |
+
|
| 47 |
+
# Apply reliability filter
|
| 48 |
+
in_set_reliable = in_set & reliable
|
| 49 |
+
background_reliable = (~in_set) & reliable
|
| 50 |
+
|
| 51 |
+
n_in = in_set_reliable.sum()
|
| 52 |
+
n_out = background_reliable.sum()
|
| 53 |
+
|
| 54 |
+
if n_in < 2 or n_out < 2:
|
| 55 |
+
return {
|
| 56 |
+
"label": label,
|
| 57 |
+
"n_genes_in_set": int(n_in),
|
| 58 |
+
"n_genes_in_set_unfiltered": int(in_set.sum()),
|
| 59 |
+
"n_genes_background": int(n_out),
|
| 60 |
+
"median_gamma_in_set": np.nan,
|
| 61 |
+
"median_gamma_background": np.nan,
|
| 62 |
+
"U_statistic": np.nan,
|
| 63 |
+
"p_value": np.nan,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
gamma_in = median_gamma[in_set_reliable]
|
| 67 |
+
gamma_out = median_gamma[background_reliable]
|
| 68 |
+
|
| 69 |
+
U, p = stats.mannwhitneyu(gamma_in, gamma_out, alternative="greater")
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
"label": label,
|
| 73 |
+
"n_genes_in_set": int(n_in),
|
| 74 |
+
"n_genes_in_set_unfiltered": int(in_set.sum()),
|
| 75 |
+
"n_genes_background": int(n_out),
|
| 76 |
+
"median_gamma_in_set": float(np.median(gamma_in)),
|
| 77 |
+
"median_gamma_background": float(np.median(gamma_out)),
|
| 78 |
+
"U_statistic": float(U),
|
| 79 |
+
"p_value": float(p),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def are_enrichment(adata: AnnData) -> dict:
|
| 84 |
+
"""Test whether ARE genes have higher gamma than background.
|
| 85 |
+
|
| 86 |
+
AU-rich elements (AREs) in 3' UTRs promote mRNA degradation.
|
| 87 |
+
Genes with AREs should have higher degradation rates (gamma).
|
| 88 |
+
|
| 89 |
+
Returns
|
| 90 |
+
-------
|
| 91 |
+
dict with test statistics including ``U_statistic`` and ``p_value``.
|
| 92 |
+
"""
|
| 93 |
+
gene_set = _load_gene_list("are_genes.txt")
|
| 94 |
+
return _enrichment_test(adata, gene_set, "ARE")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def nmd_enrichment(adata: AnnData) -> dict:
|
| 98 |
+
"""Test whether NMD target genes have higher gamma than background.
|
| 99 |
+
|
| 100 |
+
Nonsense-mediated mRNA decay (NMD) targets should show higher
|
| 101 |
+
degradation rates.
|
| 102 |
+
|
| 103 |
+
Returns
|
| 104 |
+
-------
|
| 105 |
+
dict with test statistics including ``U_statistic`` and ``p_value``.
|
| 106 |
+
"""
|
| 107 |
+
gene_set = _load_gene_list("nmd_genes.txt")
|
| 108 |
+
return _enrichment_test(adata, gene_set, "NMD")
|
src/scptr/benchmark/_halflife_correlation.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Correlation of estimated gamma with published mRNA half-lives."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from anndata import AnnData
|
| 8 |
+
from scipy import stats
|
| 9 |
+
|
| 10 |
+
from .._constants import GAMMA
|
| 11 |
+
from .._utils import get_layer, require_layers
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def correlate_with_halflives(
|
| 15 |
+
adata: AnnData,
|
| 16 |
+
halflives_df: pd.DataFrame,
|
| 17 |
+
gene_col: str = "gene_symbol",
|
| 18 |
+
halflife_col: str = "half_life_hours",
|
| 19 |
+
min_gamma_fraction: float = 0.1,
|
| 20 |
+
case_insensitive: bool = True,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""Correlate per-gene median gamma with published mRNA half-lives.
|
| 23 |
+
|
| 24 |
+
Expects a negative correlation: high gamma (fast degradation) should
|
| 25 |
+
correspond to short half-lives.
|
| 26 |
+
|
| 27 |
+
Parameters
|
| 28 |
+
----------
|
| 29 |
+
adata
|
| 30 |
+
Annotated data matrix with ``gamma`` layer.
|
| 31 |
+
halflives_df
|
| 32 |
+
DataFrame with gene symbols and half-life measurements.
|
| 33 |
+
gene_col
|
| 34 |
+
Column name for gene symbols in ``halflives_df``.
|
| 35 |
+
halflife_col
|
| 36 |
+
Column name for half-life values in ``halflives_df``.
|
| 37 |
+
min_gamma_fraction
|
| 38 |
+
Minimum fraction of cells with gamma > 0 for a gene to be
|
| 39 |
+
included in the correlation (default 0.1). Genes with too
|
| 40 |
+
few unspliced reads produce unreliable gamma estimates.
|
| 41 |
+
case_insensitive
|
| 42 |
+
Match gene symbols case-insensitively (default True). Useful
|
| 43 |
+
for cross-species comparisons (mouse Titlecase vs human UPPER).
|
| 44 |
+
|
| 45 |
+
Returns
|
| 46 |
+
-------
|
| 47 |
+
dict with keys: ``spearman_r``, ``spearman_p``, ``pearson_r``,
|
| 48 |
+
``pearson_p``, ``n_genes``, ``n_genes_unfiltered``, ``matched_genes``.
|
| 49 |
+
"""
|
| 50 |
+
require_layers(adata, GAMMA)
|
| 51 |
+
|
| 52 |
+
gamma = get_layer(adata, GAMMA)
|
| 53 |
+
|
| 54 |
+
# Filter genes: require minimum fraction of cells with non-zero gamma
|
| 55 |
+
nonzero_frac = (gamma > 0).mean(axis=0)
|
| 56 |
+
gene_mask = nonzero_frac >= min_gamma_fraction
|
| 57 |
+
|
| 58 |
+
median_gamma = np.median(gamma, axis=0)
|
| 59 |
+
|
| 60 |
+
gene_names = adata.var_names.tolist()
|
| 61 |
+
gamma_series = pd.Series(median_gamma, index=gene_names)
|
| 62 |
+
mask_series = pd.Series(gene_mask, index=gene_names)
|
| 63 |
+
|
| 64 |
+
hl_series = halflives_df.set_index(gene_col)[halflife_col]
|
| 65 |
+
|
| 66 |
+
if case_insensitive:
|
| 67 |
+
# Build uppercase-to-original mapping, match via uppercase
|
| 68 |
+
gamma_upper = {g.upper(): g for g in gene_names}
|
| 69 |
+
hl_upper = {}
|
| 70 |
+
for g in hl_series.index:
|
| 71 |
+
if isinstance(g, str):
|
| 72 |
+
hl_upper[g.upper()] = g
|
| 73 |
+
shared_upper = set(gamma_upper.keys()) & set(hl_upper.keys())
|
| 74 |
+
# Map back to original names
|
| 75 |
+
shared_all = pd.Index([gamma_upper[u] for u in shared_upper])
|
| 76 |
+
# Rebuild hl_series indexed by adata gene names
|
| 77 |
+
hl_remap = {gamma_upper[u]: hl_series[hl_upper[u]] for u in shared_upper}
|
| 78 |
+
hl_series = pd.Series(hl_remap)
|
| 79 |
+
else:
|
| 80 |
+
shared_all = gamma_series.index.intersection(hl_series.index)
|
| 81 |
+
|
| 82 |
+
# Apply gene quality filter
|
| 83 |
+
shared = shared_all[mask_series[shared_all].values]
|
| 84 |
+
|
| 85 |
+
n_unfiltered = len(shared_all)
|
| 86 |
+
|
| 87 |
+
if len(shared) < 3:
|
| 88 |
+
return {
|
| 89 |
+
"spearman_r": np.nan,
|
| 90 |
+
"spearman_p": np.nan,
|
| 91 |
+
"pearson_r": np.nan,
|
| 92 |
+
"pearson_p": np.nan,
|
| 93 |
+
"n_genes": len(shared),
|
| 94 |
+
"n_genes_unfiltered": n_unfiltered,
|
| 95 |
+
"matched_genes": shared.tolist(),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
g = gamma_series[shared].values.astype(float)
|
| 99 |
+
h = hl_series[shared].values.astype(float)
|
| 100 |
+
|
| 101 |
+
# Remove NaN/Inf
|
| 102 |
+
valid = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0)
|
| 103 |
+
g, h = g[valid], h[valid]
|
| 104 |
+
|
| 105 |
+
if len(g) < 3:
|
| 106 |
+
return {
|
| 107 |
+
"spearman_r": np.nan,
|
| 108 |
+
"spearman_p": np.nan,
|
| 109 |
+
"pearson_r": np.nan,
|
| 110 |
+
"pearson_p": np.nan,
|
| 111 |
+
"n_genes": 0,
|
| 112 |
+
"n_genes_unfiltered": n_unfiltered,
|
| 113 |
+
"matched_genes": [],
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
sp_r, sp_p = stats.spearmanr(g, h)
|
| 117 |
+
pe_r, pe_p = stats.pearsonr(np.log1p(g), np.log1p(h))
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"spearman_r": float(sp_r),
|
| 121 |
+
"spearman_p": float(sp_p),
|
| 122 |
+
"pearson_r": float(pe_r),
|
| 123 |
+
"pearson_p": float(pe_p),
|
| 124 |
+
"n_genes": int(valid.sum()),
|
| 125 |
+
"n_genes_unfiltered": n_unfiltered,
|
| 126 |
+
"matched_genes": shared[valid].tolist(),
|
| 127 |
+
}
|
src/scptr/benchmark/_robustness.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Subsampling robustness analysis."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from anndata import AnnData
|
| 8 |
+
from scipy import stats
|
| 9 |
+
|
| 10 |
+
from .._constants import GAMMA
|
| 11 |
+
from .._utils import get_layer, require_layers
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def subsampling_robustness(
|
| 15 |
+
adata: AnnData,
|
| 16 |
+
fractions: list[float] | None = None,
|
| 17 |
+
n_repeats: int = 3,
|
| 18 |
+
random_state: int = 0,
|
| 19 |
+
) -> pd.DataFrame:
|
| 20 |
+
"""Evaluate robustness of gamma estimates by subsampling cells.
|
| 21 |
+
|
| 22 |
+
For each fraction, subsample cells, rerun the pipeline, and correlate
|
| 23 |
+
the resulting per-gene median gamma with the full-data estimate.
|
| 24 |
+
|
| 25 |
+
Parameters
|
| 26 |
+
----------
|
| 27 |
+
adata
|
| 28 |
+
Fully analyzed AnnData (must have ``gamma`` layer, ``Mu``/``Ms``
|
| 29 |
+
layers, and ``var['beta']``).
|
| 30 |
+
fractions
|
| 31 |
+
Cell fractions to test (default: [0.3, 0.5, 0.7, 0.9]).
|
| 32 |
+
n_repeats
|
| 33 |
+
Number of random repeats per fraction.
|
| 34 |
+
random_state
|
| 35 |
+
Base random seed.
|
| 36 |
+
|
| 37 |
+
Returns
|
| 38 |
+
-------
|
| 39 |
+
DataFrame with columns: ``fraction``, ``repeat``, ``spearman_r``,
|
| 40 |
+
``pearson_r``, ``n_genes``.
|
| 41 |
+
"""
|
| 42 |
+
require_layers(adata, GAMMA)
|
| 43 |
+
|
| 44 |
+
if fractions is None:
|
| 45 |
+
fractions = [0.3, 0.5, 0.7, 0.9]
|
| 46 |
+
|
| 47 |
+
gamma_full = get_layer(adata, GAMMA)
|
| 48 |
+
median_gamma_full = np.median(gamma_full, axis=0)
|
| 49 |
+
|
| 50 |
+
rng = np.random.RandomState(random_state)
|
| 51 |
+
records = []
|
| 52 |
+
|
| 53 |
+
for frac in fractions:
|
| 54 |
+
n_cells = max(int(adata.n_obs * frac), 10)
|
| 55 |
+
for rep in range(n_repeats):
|
| 56 |
+
idx = rng.choice(adata.n_obs, size=n_cells, replace=False)
|
| 57 |
+
gamma_sub = gamma_full[idx, :]
|
| 58 |
+
median_gamma_sub = np.median(gamma_sub, axis=0)
|
| 59 |
+
|
| 60 |
+
# Remove genes with zero variance
|
| 61 |
+
valid = (np.std(median_gamma_full) > 0) & (np.std(median_gamma_sub) > 0)
|
| 62 |
+
if not valid:
|
| 63 |
+
sp_r = pe_r = np.nan
|
| 64 |
+
else:
|
| 65 |
+
sp_r, _ = stats.spearmanr(median_gamma_full, median_gamma_sub)
|
| 66 |
+
pe_r, _ = stats.pearsonr(median_gamma_full, median_gamma_sub)
|
| 67 |
+
|
| 68 |
+
records.append({
|
| 69 |
+
"fraction": frac,
|
| 70 |
+
"repeat": rep,
|
| 71 |
+
"spearman_r": float(sp_r),
|
| 72 |
+
"pearson_r": float(pe_r),
|
| 73 |
+
"n_genes": int(adata.n_vars),
|
| 74 |
+
"n_cells_sampled": n_cells,
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
return pd.DataFrame(records)
|
src/scptr/benchmark/data/are_genes.txt
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
TNF
|
| 2 |
+
IL6
|
| 3 |
+
IL8
|
| 4 |
+
CSF2
|
| 5 |
+
VEGFA
|
| 6 |
+
MYC
|
| 7 |
+
FOS
|
| 8 |
+
JUN
|
| 9 |
+
EGR1
|
| 10 |
+
PTGS2
|
| 11 |
+
IL2
|
| 12 |
+
IL3
|
| 13 |
+
IL4
|
| 14 |
+
IL10
|
| 15 |
+
IFNG
|
| 16 |
+
CCL2
|
| 17 |
+
CCL3
|
| 18 |
+
CCL4
|
| 19 |
+
CCL5
|
| 20 |
+
CXCL1
|
| 21 |
+
CXCL2
|
| 22 |
+
CXCL8
|
| 23 |
+
CXCL10
|
| 24 |
+
MMP1
|
| 25 |
+
MMP9
|
| 26 |
+
SERPINE1
|
| 27 |
+
PLAU
|
| 28 |
+
PLAUR
|
| 29 |
+
THBS1
|
| 30 |
+
NOS2
|
| 31 |
+
SOD2
|
| 32 |
+
HMOX1
|
| 33 |
+
DUSP1
|
| 34 |
+
DUSP2
|
| 35 |
+
ZFP36
|
| 36 |
+
ZFP36L1
|
| 37 |
+
ZFP36L2
|
| 38 |
+
NFKBIA
|
| 39 |
+
BCL2L1
|
| 40 |
+
BIRC3
|
| 41 |
+
CDKN1A
|
| 42 |
+
GADD45A
|
| 43 |
+
GADD45B
|
| 44 |
+
ATF3
|
| 45 |
+
JUNB
|
| 46 |
+
FOSB
|
| 47 |
+
NR4A1
|
| 48 |
+
NR4A2
|
| 49 |
+
NR4A3
|
| 50 |
+
KLF2
|
| 51 |
+
KLF4
|
| 52 |
+
KLF6
|
| 53 |
+
ETS1
|
| 54 |
+
ETS2
|
| 55 |
+
NFKB1
|
| 56 |
+
NFKB2
|
| 57 |
+
RELA
|
| 58 |
+
RELB
|
| 59 |
+
REL
|
| 60 |
+
TRAF1
|
| 61 |
+
TRAF2
|
| 62 |
+
TNFAIP3
|
| 63 |
+
TNFAIP6
|
| 64 |
+
CD69
|
| 65 |
+
CD83
|
| 66 |
+
ICAM1
|
| 67 |
+
VCAM1
|
| 68 |
+
SELE
|
| 69 |
+
SELP
|
| 70 |
+
EDN1
|
| 71 |
+
ET1
|
| 72 |
+
HBEGF
|
| 73 |
+
EREG
|
| 74 |
+
AREG
|
| 75 |
+
BTC
|
| 76 |
+
TGFB1
|
| 77 |
+
LIF
|
| 78 |
+
OSM
|
| 79 |
+
CNTF
|
| 80 |
+
IL1B
|
| 81 |
+
IL1A
|
| 82 |
+
IL1RN
|
| 83 |
+
IL12B
|
| 84 |
+
IL15
|
| 85 |
+
IL18
|
| 86 |
+
IL23A
|
| 87 |
+
IL27
|
| 88 |
+
CD40LG
|
| 89 |
+
TNFSF10
|
| 90 |
+
FASLG
|
| 91 |
+
NGF
|
| 92 |
+
BDNF
|
| 93 |
+
GDNF
|
| 94 |
+
CNTF
|
| 95 |
+
NTF3
|
| 96 |
+
Tnf
|
| 97 |
+
Il6
|
| 98 |
+
Myc
|
| 99 |
+
Fos
|
| 100 |
+
Jun
|
| 101 |
+
Egr1
|
| 102 |
+
Vegfa
|
| 103 |
+
Ptgs2
|
| 104 |
+
Il2
|
| 105 |
+
Il4
|
| 106 |
+
Il10
|
| 107 |
+
Ifng
|
| 108 |
+
Ccl2
|
| 109 |
+
Ccl3
|
| 110 |
+
Ccl5
|
| 111 |
+
Cxcl1
|
| 112 |
+
Cxcl2
|
| 113 |
+
Cxcl10
|
| 114 |
+
Mmp9
|
| 115 |
+
Serpine1
|
| 116 |
+
Nos2
|
| 117 |
+
Sod2
|
| 118 |
+
Hmox1
|
| 119 |
+
Dusp1
|
| 120 |
+
Zfp36
|
| 121 |
+
Nfkbia
|
| 122 |
+
Cdkn1a
|
| 123 |
+
Gadd45a
|
| 124 |
+
Gadd45b
|
| 125 |
+
Atf3
|
| 126 |
+
Junb
|
| 127 |
+
Fosb
|
| 128 |
+
Nr4a1
|
| 129 |
+
Nr4a2
|
| 130 |
+
Klf2
|
| 131 |
+
Klf4
|
| 132 |
+
Klf6
|
| 133 |
+
Nfkb1
|
| 134 |
+
Rela
|
| 135 |
+
Tnfaip3
|
| 136 |
+
Icam1
|
| 137 |
+
Tgfb1
|
| 138 |
+
Il1b
|
| 139 |
+
Il1a
|
src/scptr/benchmark/data/eclip_targets.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/scptr/benchmark/data/human_utr_features.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/scptr/benchmark/data/human_utr_length.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/scptr/benchmark/data/mouse_utr_length.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/scptr/benchmark/data/nmd_genes.txt
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GADD45B
|
| 2 |
+
ATF4
|
| 3 |
+
DDIT3
|
| 4 |
+
PPP1R15A
|
| 5 |
+
ASNS
|
| 6 |
+
SESN2
|
| 7 |
+
SLC7A11
|
| 8 |
+
CTH
|
| 9 |
+
SLC7A5
|
| 10 |
+
HERPUD1
|
| 11 |
+
DNAJB9
|
| 12 |
+
HYOU1
|
| 13 |
+
SEC24D
|
| 14 |
+
TRIB3
|
| 15 |
+
NUPR1
|
| 16 |
+
CHAC1
|
| 17 |
+
STC2
|
| 18 |
+
GDF15
|
| 19 |
+
INHBE
|
| 20 |
+
VEGFA
|
| 21 |
+
ADM
|
| 22 |
+
ANGPTL4
|
| 23 |
+
ERO1A
|
| 24 |
+
P4HA1
|
| 25 |
+
EGLN3
|
| 26 |
+
BNIP3
|
| 27 |
+
BNIP3L
|
| 28 |
+
PDK1
|
| 29 |
+
SLC2A1
|
| 30 |
+
HK2
|
| 31 |
+
PFKFB3
|
| 32 |
+
ENO2
|
| 33 |
+
ALDOA
|
| 34 |
+
PGK1
|
| 35 |
+
LDHA
|
| 36 |
+
CA9
|
| 37 |
+
SCG5
|
| 38 |
+
SULF2
|
| 39 |
+
TFR2
|
| 40 |
+
KCNK3
|
| 41 |
+
CLDN1
|
| 42 |
+
SNHG1
|
| 43 |
+
SNHG12
|
| 44 |
+
SNHG15
|
| 45 |
+
GAS5
|
| 46 |
+
ZFAS1
|
| 47 |
+
NEAT1
|
| 48 |
+
MALAT1
|
| 49 |
+
DANCR
|
| 50 |
+
HOTAIR
|
| 51 |
+
XIST
|
| 52 |
+
KCNQ1OT1
|
| 53 |
+
MEG3
|
| 54 |
+
H19
|
| 55 |
+
NORAD
|
| 56 |
+
SMG1
|
| 57 |
+
UPF1
|
| 58 |
+
UPF2
|
| 59 |
+
UPF3B
|
| 60 |
+
SMG5
|
| 61 |
+
SMG6
|
| 62 |
+
SMG7
|
| 63 |
+
SMG8
|
| 64 |
+
SMG9
|
| 65 |
+
EIF4A3
|
| 66 |
+
MAGOH
|
| 67 |
+
RBM8A
|
| 68 |
+
CASC3
|
| 69 |
+
RNPS1
|
| 70 |
+
ACIN1
|
| 71 |
+
SAP18
|
| 72 |
+
PNN
|
| 73 |
+
Gadd45b
|
| 74 |
+
Atf4
|
| 75 |
+
Ddit3
|
| 76 |
+
Ppp1r15a
|
| 77 |
+
Asns
|
| 78 |
+
Sesn2
|
| 79 |
+
Slc7a11
|
| 80 |
+
Herpud1
|
| 81 |
+
Dnajb9
|
| 82 |
+
Trib3
|
| 83 |
+
Nupr1
|
| 84 |
+
Chac1
|
| 85 |
+
Stc2
|
| 86 |
+
Gdf15
|
| 87 |
+
Vegfa
|
| 88 |
+
Adm
|
| 89 |
+
Angptl4
|
| 90 |
+
Ero1a
|
| 91 |
+
Bnip3
|
| 92 |
+
Pdk1
|
| 93 |
+
Slc2a1
|
| 94 |
+
Hk2
|
| 95 |
+
Pfkfb3
|
| 96 |
+
Eno2
|
| 97 |
+
Aldoa
|
| 98 |
+
Pgk1
|
| 99 |
+
Ldha
|
| 100 |
+
Ca9
|
| 101 |
+
Smg1
|
| 102 |
+
Upf1
|
| 103 |
+
Upf2
|
| 104 |
+
Upf3b
|
| 105 |
+
Smg5
|
| 106 |
+
Smg6
|
| 107 |
+
Smg7
|
| 108 |
+
Eif4a3
|
| 109 |
+
Magoh
|
| 110 |
+
Rbm8a
|
| 111 |
+
Rnps1
|
| 112 |
+
Acin1
|
| 113 |
+
Gas5
|
| 114 |
+
Neat1
|
| 115 |
+
Malat1
|
| 116 |
+
Meg3
|
| 117 |
+
H19
|
| 118 |
+
Norad
|