scPTR / analyses /run_tier1_fixes.py
bryan7264's picture
Add files using upload-large-folder tool
9d901ad verified
Raw
History Blame Contribute Delete
37.2 kB
#!/usr/bin/env python
"""Address Tier 1 and Tier 2 reviewer concerns systematically.
T1-1: Functionally characterize invisible states (GSEA on differentially degraded genes)
T1-2: Fix gamma=0 reporting (filter genes with insufficient unspliced coverage)
T1-3: Investigate destabilizing bias in RBP networks
T1-4: PT velocity streamlines on UMAP
T2-1: Ablation experiments (naive u/s ratio vs full scPTR)
T2-2: Explain TF score discrepancy between datasets
T2-3: Housekeeping gene analysis for cross-dataset consistency
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scanpy as sc
from scipy import stats
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
sys.path.insert(0, str(Path(__file__).parent))
from _common import set_figure_style
import scptr
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "tier1_fixes"
def save_fig(fig, name, subdir="figures"):
if fig is None:
return
out_dir = OUTPUT_DIR / subdir
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{name}.png"
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {path}")
def run_pipeline(adata, name):
"""Run standard scPTR pipeline."""
print(f"\n--- Pipeline: {name} ---")
scptr.pp.filter_genes(adata)
scptr.pp.normalize_layers(adata)
scptr.pp.neighbors(adata, n_neighbors=30)
scptr.pp.smooth_layers(adata)
scptr.tl.estimate_beta(adata)
scptr.tl.estimate_gamma(adata)
scptr.tl.variance_decomposition(adata)
scptr.tl.pt_states(adata)
scptr.tl.pt_velocity(adata)
print(f" Done: {adata.shape}")
return adata
# =========================================================================
# T1-2: Fix gamma=0 reporting
# =========================================================================
def fix_gamma_reporting(adata, name):
"""Report gamma statistics only on genes with reliable estimates.
The gamma=0 median is a sparsity artifact: genes with zero unspliced
counts get gamma=0 by definition. Report separately for:
1. All genes (including zeros)
2. Genes with >=10% cells having nonzero gamma ("gamma-informative")
"""
print(f"\n{'='*60}")
print(f"T1-2: GAMMA REPORTING FIX ({name})")
print(f"{'='*60}")
gamma = adata.layers["gamma"]
n_genes = gamma.shape[1]
# Per-gene: fraction of cells with nonzero gamma
nonzero_frac = (gamma > 0).mean(axis=0)
median_gamma = np.median(gamma, axis=0)
# Thresholds for "informative"
for thresh in [0.0, 0.05, 0.1, 0.2]:
mask = nonzero_frac >= thresh
n = mask.sum()
if n > 0:
med = np.median(median_gamma[mask])
mean = np.mean(median_gamma[mask])
print(f" Genes with >= {thresh:.0%} nonzero gamma: {n}/{n_genes} "
f"(median of medians = {med:.4f}, mean = {mean:.4f})")
# Key metric: what fraction of genes have usable gamma?
informative = nonzero_frac >= 0.1
print(f"\n Gamma-informative genes (>=10% nonzero): {informative.sum()}/{n_genes} "
f"({100*informative.mean():.1f}%)")
print(f" These genes' median gamma: {np.median(median_gamma[informative]):.4f}")
# Unspliced detection rate
u = adata.layers.get("Mu", adata.layers.get("unspliced"))
if u is not None:
u_arr = u.toarray() if hasattr(u, 'toarray') else np.asarray(u)
u_detection = (u_arr > 0).mean(axis=0)
print(f"\n Unspliced detection: mean={u_detection.mean():.3f}, "
f"median={np.median(u_detection):.3f}")
print(f" Genes with >5% unspliced detection: {(u_detection > 0.05).sum()}/{n_genes}")
return {
"n_genes_total": int(n_genes),
"n_informative_10pct": int(informative.sum()),
"frac_informative": float(informative.mean()),
"median_gamma_informative": float(np.median(median_gamma[informative])),
"median_gamma_all": float(np.median(median_gamma)),
}
# =========================================================================
# T1-1: Functional characterization of invisible states
# =========================================================================
def characterize_invisible_states(adata, name):
"""Find invisible states and characterize differentially degraded genes."""
print(f"\n{'='*60}")
print(f"T1-1: FUNCTIONAL CHARACTERIZATION ({name})")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results" / "invisible_states" / name
res_dir.mkdir(parents=True, exist_ok=True)
gamma = adata.layers["gamma"]
clusters = adata.obs["clusters"]
all_results = []
for cluster_name in clusters.unique():
mask = (clusters == cluster_name).values
n_cells = mask.sum()
if n_cells < 50:
continue
gamma_sub = gamma[mask]
# Filter to gamma-informative genes for this cluster
gene_nonzero = (gamma_sub > 0).mean(axis=0)
good_genes = gene_nonzero >= 0.1
if good_genes.sum() < 20:
continue
gamma_filtered = gamma_sub[:, good_genes]
gene_names = adata.var_names[good_genes]
# PCA + KMeans on gamma
n_pcs = min(15, n_cells - 1, gamma_filtered.shape[1] - 1)
pca = PCA(n_components=n_pcs, random_state=42)
gamma_pcs = pca.fit_transform(gamma_filtered)
best_k, best_sil, best_labels = 1, -1, np.zeros(n_cells, dtype=int)
for k in [2, 3]:
if n_cells < k * 10:
continue
km = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = km.fit_predict(gamma_pcs)
if min(np.bincount(labels)) < 10:
continue
sil = silhouette_score(gamma_pcs, labels)
if sil > best_sil:
best_sil, best_k, best_labels = sil, k, labels
if best_k <= 1:
continue
# Expression silhouette for same labels
expr_sub = adata.X[mask].toarray() if hasattr(adata.X, 'toarray') else np.asarray(adata.X[mask])
n_expr_pcs = min(15, n_cells - 1, expr_sub.shape[1] - 1)
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
expr_pcs = pca_expr.fit_transform(expr_sub)
sil_expr = silhouette_score(expr_pcs, best_labels)
invisibility = best_sil - sil_expr
is_invisible = invisibility > 0.05
if not is_invisible:
continue
print(f"\n {cluster_name}: INVISIBLE (sil_gamma={best_sil:.3f}, "
f"sil_expr={sil_expr:.3f})")
# Differential degradation between sub-clusters
diff_results = []
for gi, gene in enumerate(gene_names):
groups = [gamma_filtered[best_labels == j, gi] for j in range(best_k)]
if all(len(g) >= 5 for g in groups):
if best_k == 2:
u_stat, p_val = stats.mannwhitneyu(groups[0], groups[1],
alternative='two-sided')
else:
_, p_val = stats.kruskal(*groups)
medians = [np.median(g) for g in groups]
max_med = max(medians)
min_med = min(medians)
log_fc = np.log2((max_med + 0.01) / (min_med + 0.01))
diff_results.append({
"gene": gene,
"p_value": p_val,
"log2_fc_gamma": log_fc,
"medians": medians,
})
if not diff_results:
continue
diff_df = pd.DataFrame(diff_results)
# FDR correction
from statsmodels.stats.multitest import multipletests
_, diff_df["fdr"], _, _ = multipletests(diff_df["p_value"], method="fdr_bh")
# Significant differentially degraded genes
sig = diff_df[diff_df["fdr"] < 0.05].sort_values("log2_fc_gamma", ascending=False)
print(f" Differentially degraded genes (FDR<0.05): {len(sig)}/{len(diff_df)}")
if len(sig) > 0:
# Top destabilized (high gamma in one sub-cluster)
top_destab = sig.head(10)
print(f" Top destabilized: {top_destab['gene'].tolist()}")
# Top stabilized (low gamma difference but significant)
top_stab = sig.tail(10)
print(f" Top stabilized: {top_stab['gene'].tolist()}")
sig.to_csv(res_dir / f"{cluster_name}_diff_degraded.csv", index=False)
# Run enrichment using gseapy (Enrichr API)
try:
import gseapy as gp
# Use top differentially degraded genes for enrichment
gene_list = sig["gene"].tolist()
if len(gene_list) >= 5:
# Determine organism
# If gene names are Titlecase → mouse; UPPERCASE → human
sample_gene = gene_list[0]
organism = "mouse" if sample_gene[0].isupper() and sample_gene[1:].islower() else "human"
gene_sets = ["GO_Biological_Process_2023",
"KEGG_2021_Human" if organism == "human" else "KEGG_2019_Mouse"]
enr = gp.enrichr(gene_list=gene_list,
gene_sets=gene_sets,
organism=organism,
outdir=None,
no_plot=True)
enr_df = enr.results
sig_enr = enr_df[enr_df["Adjusted P-value"] < 0.1].head(15)
if len(sig_enr) > 0:
print(f" Enriched pathways (FDR<0.1):")
for _, row in sig_enr.iterrows():
print(f" {row['Term'][:60]}: p={row['Adjusted P-value']:.4f}")
sig_enr.to_csv(res_dir / f"{cluster_name}_enrichment.csv", index=False)
else:
print(f" No significant pathway enrichment found")
except Exception as e:
print(f" [WARNING] Enrichment failed: {e}")
all_results.append({
"cluster": cluster_name,
"n_cells": n_cells,
"n_subclusters": best_k,
"sil_gamma": best_sil,
"sil_expr": sil_expr,
"invisibility": invisibility,
"n_diff_genes": len(sig) if len(sig) > 0 else 0,
})
return pd.DataFrame(all_results)
# =========================================================================
# T1-3: Investigate destabilizing bias in RBP networks
# =========================================================================
def investigate_destabilizing_bias(adata, name):
"""Investigate why RBP networks show predominantly destabilizing effects."""
print(f"\n{'='*60}")
print(f"T1-3: DESTABILIZING BIAS INVESTIGATION ({name})")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results" / "network_bias"
res_dir.mkdir(parents=True, exist_ok=True)
gamma = adata.layers["gamma"]
gamma_med = np.median(gamma, axis=0)
# Load RBP list
rbp_path = Path(__file__).parent.parent / "src" / "scptr" / "tools" / "data" / "known_rbps.csv"
rbps = pd.read_csv(rbp_path)["gene_symbol"].tolist()
# Find RBPs in dataset (case-insensitive)
adata_genes_upper = {g.upper(): g for g in adata.var_names}
rbp_in_data = []
for r in rbps:
if r.upper() in adata_genes_upper:
rbp_in_data.append(adata_genes_upper[r.upper()])
print(f" RBPs in dataset: {len(rbp_in_data)}")
# Get expression matrix
if hasattr(adata.X, 'toarray'):
expr = adata.X.toarray()
else:
expr = np.asarray(adata.X)
# Select target genes: top variable gamma (filtered to informative)
nonzero_frac = (gamma > 0).mean(axis=0)
informative = nonzero_frac >= 0.1
gamma_var = np.var(gamma[:, informative], axis=0)
n_targets = min(200, informative.sum())
top_var_idx = np.argsort(gamma_var)[-n_targets:]
info_indices = np.where(informative)[0]
target_indices = info_indices[top_var_idx]
target_genes = adata.var_names[target_indices]
# Correlation analysis
print("\n Correlation analysis:")
all_edges = []
for rbp_name in rbp_in_data:
rbp_idx = list(adata.var_names).index(rbp_name)
rbp_expr = expr[:, rbp_idx]
if np.std(rbp_expr) < 1e-6:
continue
for ti, target_name in zip(target_indices, target_genes):
target_gamma = gamma[:, ti]
# Only use cells with nonzero gamma for this gene
valid = target_gamma > 0
if valid.sum() < 50:
continue
r, p = stats.spearmanr(rbp_expr[valid], target_gamma[valid])
if p < 0.05 / (len(rbp_in_data) * n_targets): # Bonferroni
all_edges.append({
"rbp": rbp_name,
"target": target_name,
"spearman_r": r,
"p_value": p,
"direction": "destabilizing" if r > 0 else "stabilizing",
})
edges_df = pd.DataFrame(all_edges)
if len(edges_df) == 0:
print(" No significant edges found")
return
n_destab = (edges_df["spearman_r"] > 0).sum()
n_stab = (edges_df["spearman_r"] < 0).sum()
print(f" Total significant edges: {len(edges_df)}")
print(f" Destabilizing (r>0): {n_destab} ({100*n_destab/len(edges_df):.1f}%)")
print(f" Stabilizing (r<0): {n_stab} ({100*n_stab/len(edges_df):.1f}%)")
# Key diagnostic: is the bias in the gamma distribution itself?
# Check: is gamma positively correlated with total expression?
expr_mean = expr.mean(axis=0)
gamma_mean = gamma.mean(axis=0)
r_expr_gamma, _ = stats.spearmanr(expr_mean[informative], gamma_mean[informative])
print(f"\n Diagnostic: Spearman(mean_expression, mean_gamma) = {r_expr_gamma:.4f}")
print(f" If positive, RBP expression correlates with gamma because both")
print(f" correlate with overall expression level → confounding.")
# Check: does the bias persist after regressing out total expression?
print("\n After controlling for total expression per cell:")
total_expr_per_cell = expr.sum(axis=1)
n_stab_ctrl = 0
n_destab_ctrl = 0
controlled_edges = []
for rbp_name in rbp_in_data[:10]: # Test top 10 RBPs
rbp_idx = list(adata.var_names).index(rbp_name)
rbp_expr = expr[:, rbp_idx]
if np.std(rbp_expr) < 1e-6:
continue
# Partial correlation: regress out total expression
# Residualize both RBP expression and gamma against total expression
from numpy.polynomial.polynomial import polyfit, polyval
rbp_resid = rbp_expr - np.mean(rbp_expr)
# Simple: rank-based partial correlation
rbp_rank = stats.rankdata(rbp_expr)
total_rank = stats.rankdata(total_expr_per_cell)
# Regress out total from RBP
slope = np.cov(rbp_rank, total_rank)[0, 1] / np.var(total_rank)
rbp_resid = rbp_rank - slope * total_rank
for ti in target_indices[:50]:
target_gamma = gamma[:, ti]
valid = target_gamma > 0
if valid.sum() < 50:
continue
gamma_rank = stats.rankdata(target_gamma[valid])
total_rank_v = stats.rankdata(total_expr_per_cell[valid])
slope_g = np.cov(gamma_rank, total_rank_v)[0, 1] / (np.var(total_rank_v) + 1e-10)
gamma_resid = gamma_rank - slope_g * total_rank_v
r, p = stats.spearmanr(rbp_resid[valid], gamma_resid)
if r > 0:
n_destab_ctrl += 1
else:
n_stab_ctrl += 1
total_ctrl = n_destab_ctrl + n_stab_ctrl
if total_ctrl > 0:
print(f" Destabilizing: {n_destab_ctrl}/{total_ctrl} ({100*n_destab_ctrl/total_ctrl:.1f}%)")
print(f" Stabilizing: {n_stab_ctrl}/{total_ctrl} ({100*n_stab_ctrl/total_ctrl:.1f}%)")
if n_destab_ctrl / total_ctrl < 0.6:
print(f" → Bias is reduced after controlling for library size!")
print(f" → The original bias was partly a confound: RBPs with higher")
print(f" expression → higher overall counts → higher gamma artifacts")
else:
print(f" → Bias persists even after correction")
# Per-RBP breakdown
print("\n Per-RBP breakdown:")
hub_counts = edges_df.groupby("rbp").agg(
n_targets=("target", "count"),
n_stab=("direction", lambda x: (x == "stabilizing").sum()),
n_destab=("direction", lambda x: (x == "destabilizing").sum()),
mean_r=("spearman_r", "mean"),
).sort_values("n_targets", ascending=False)
for rbp_name, row in hub_counts.head(10).iterrows():
ratio = row["n_destab"] / max(row["n_targets"], 1)
print(f" {rbp_name}: {int(row['n_targets'])} targets "
f"({int(row['n_stab'])} stab, {int(row['n_destab'])} destab, "
f"mean_r={row['mean_r']:.3f})")
edges_df.to_csv(res_dir / f"edges_{name}.csv", index=False)
hub_counts.to_csv(res_dir / f"hub_counts_{name}.csv")
return {
"n_edges": len(edges_df),
"frac_destabilizing": float(n_destab / len(edges_df)),
"expr_gamma_correlation": float(r_expr_gamma),
}
# =========================================================================
# T1-4: PT velocity streamlines on UMAP
# =========================================================================
def velocity_streamlines(adata, name):
"""Generate proper streamline plots for PT velocity on UMAP."""
print(f"\n{'='*60}")
print(f"T1-4: VELOCITY STREAMLINES ({name})")
print(f"{'='*60}")
gamma = adata.layers["gamma"]
velocity = adata.layers["pt_velocity"]
# We need UMAP coordinates
if "X_gamma_umap" not in adata.obsm:
print(" No gamma UMAP, computing...")
sc.tl.umap(adata)
coords = adata.obsm["X_umap"]
else:
coords = adata.obsm["X_gamma_umap"]
# Build transition matrix from velocity
from sklearn.neighbors import NearestNeighbors
nn = NearestNeighbors(n_neighbors=30)
nn.fit(coords)
dists, indices = nn.kneighbors(coords)
# For each cell, compute velocity-weighted displacement in UMAP space
n_cells = len(coords)
dx = np.zeros((n_cells, 2))
for i in range(n_cells):
neighbors = indices[i, 1:] # exclude self
vel_i = velocity[i]
for j in neighbors:
# Gamma displacement: how different is neighbor's gamma from mine?
gamma_disp = gamma[j] - gamma[i]
# Project: does the velocity vector point toward this neighbor?
cos_sim = np.dot(vel_i, gamma_disp) / (
np.linalg.norm(vel_i) * np.linalg.norm(gamma_disp) + 1e-10
)
if cos_sim > 0:
# Weight by cosine similarity and UMAP displacement
umap_disp = coords[j] - coords[i]
dx[i] += cos_sim * umap_disp
# Normalize
norms = np.linalg.norm(dx, axis=1, keepdims=True)
cap = np.percentile(norms[norms > 0], 95)
dx = dx / (cap + 1e-10)
# Velocity magnitude for coloring
vel_mag = np.linalg.norm(velocity, axis=1)
vel_mag = vel_mag / (np.percentile(vel_mag, 95) + 1e-10)
# Create streamline-style plot using quiver at grid points
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
# Panel 1: Quiver plot colored by cluster
clusters = adata.obs["clusters"]
cluster_colors = {c: plt.cm.tab20(i / 20) for i, c in enumerate(clusters.unique())}
for c in clusters.unique():
mask = (clusters == c).values
axes[0].scatter(coords[mask, 0], coords[mask, 1], s=3, alpha=0.3,
c=[cluster_colors[c]], label=c)
# Subsample arrows for clarity
n_arrows = min(500, n_cells)
arrow_idx = np.random.choice(n_cells, n_arrows, replace=False)
arrow_mask = np.linalg.norm(dx[arrow_idx], axis=1) > 0.01
axes[0].quiver(coords[arrow_idx[arrow_mask], 0],
coords[arrow_idx[arrow_mask], 1],
dx[arrow_idx[arrow_mask], 0],
dx[arrow_idx[arrow_mask], 1],
color="black", alpha=0.6, scale=20, width=0.003,
headwidth=4, headlength=5)
axes[0].set_title(f"PT Velocity Streamlines: {name}")
axes[0].set_xlabel("UMAP 1")
axes[0].set_ylabel("UMAP 2")
axes[0].legend(fontsize=6, loc="best", markerscale=3)
# Panel 2: Velocity magnitude
sc_plot = axes[1].scatter(coords[:, 0], coords[:, 1], s=3, alpha=0.5,
c=np.clip(vel_mag, 0, 1), cmap="YlOrRd")
axes[1].quiver(coords[arrow_idx[arrow_mask], 0],
coords[arrow_idx[arrow_mask], 1],
dx[arrow_idx[arrow_mask], 0],
dx[arrow_idx[arrow_mask], 1],
color="black", alpha=0.4, scale=20, width=0.002,
headwidth=4, headlength=5)
axes[1].set_title(f"PT Velocity Magnitude: {name}")
axes[1].set_xlabel("UMAP 1")
axes[1].set_ylabel("UMAP 2")
plt.colorbar(sc_plot, ax=axes[1], label="Velocity magnitude")
fig.tight_layout()
save_fig(fig, f"velocity_streamlines_{name}")
print(f" Mean velocity magnitude: {np.mean(np.linalg.norm(velocity, axis=1)):.4f}")
print(f" Cells with significant displacement: {arrow_mask.sum()}/{n_arrows}")
# =========================================================================
# T2-1: Ablation experiments
# =========================================================================
def ablation_experiments(adata, name):
"""Compare full scPTR against naive alternatives using invisibility score.
For each cluster x method:
1. Find sub-clusters in method's space (sil_method)
2. Evaluate SAME labels in expression PCA space (sil_expr)
3. Invisibility = sil_method - sil_expr
The key claim: scPTR gamma maximizes invisibility (finds sub-populations
most invisible to expression), not raw separability.
"""
print(f"\n{'='*60}")
print(f"T2-1: ABLATION EXPERIMENTS ({name})")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results" / "ablation"
res_dir.mkdir(parents=True, exist_ok=True)
gamma = adata.layers["gamma"]
clusters = adata.obs["clusters"]
# Get unspliced counts
u_layer = adata.layers.get("Mu", adata.layers.get("unspliced"))
s_layer = adata.layers.get("Ms", adata.layers.get("spliced"))
u = u_layer.toarray() if hasattr(u_layer, 'toarray') else np.asarray(u_layer)
s = s_layer.toarray() if hasattr(s_layer, 'toarray') else np.asarray(s_layer)
# Expression matrix (for expression silhouette computation)
expr_full = adata.X.toarray() if hasattr(adata.X, 'toarray') else np.asarray(adata.X)
# Method 1: Full scPTR gamma (already computed)
# Method 2: Raw u/s ratio (naive, no kinetic model)
raw_ratio = np.zeros_like(gamma)
s_safe = np.where(s > 0.01, s, 1.0)
raw_ratio = u / s_safe
raw_ratio[s < 0.01] = 0
# Method 3: PCA on unspliced counts alone
# Method 4: PCA on expression alone (baseline)
methods = {
"scPTR_gamma": gamma,
"raw_u_s_ratio": raw_ratio,
"unspliced_only": u,
"expression": expr_full,
}
results = []
for cluster_name in clusters.unique():
mask = (clusters == cluster_name).values
n_cells = mask.sum()
if n_cells < 50:
continue
# Pre-compute expression PCA for this cluster (used for all methods)
expr_sub = expr_full[mask]
nonzero_expr = (expr_sub > 0).mean(axis=0)
good_expr = nonzero_expr >= 0.05
if good_expr.sum() < 20:
continue
n_expr_pcs = min(15, n_cells - 1, good_expr.sum() - 1)
pca_expr = PCA(n_components=n_expr_pcs, random_state=42)
expr_pcs = pca_expr.fit_transform(expr_sub[:, good_expr])
for method_name, data in methods.items():
data_sub = data[mask]
# Filter to informative features
nonzero = (data_sub > 0).mean(axis=0)
good = nonzero >= 0.05
if good.sum() < 20:
continue
data_filtered = data_sub[:, good]
n_pcs = min(15, n_cells - 1, data_filtered.shape[1] - 1)
pca = PCA(n_components=n_pcs, random_state=42)
pcs = pca.fit_transform(data_filtered)
best_sil = -1
best_labels = None
for k in [2, 3]:
if n_cells < k * 10:
continue
km = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = km.fit_predict(pcs)
if min(np.bincount(labels)) < 10:
continue
sil = silhouette_score(pcs, labels)
if sil > best_sil:
best_sil = sil
best_labels = labels
if best_labels is None:
continue
# Compute silhouette of SAME labels in expression PCA space
sil_expr = silhouette_score(expr_pcs, best_labels)
invisibility = best_sil - sil_expr
results.append({
"cluster": cluster_name,
"method": method_name,
"n_cells": n_cells,
"sil_method_space": best_sil,
"sil_expr_space": sil_expr,
"invisibility": invisibility,
})
results_df = pd.DataFrame(results)
results_df.to_csv(res_dir / f"ablation_{name}.csv", index=False)
# Summary: mean invisibility by method
print("\n Mean invisibility score by method (higher = better):")
summary = results_df.groupby("method")["invisibility"].agg(["mean", "std", "count"])
for method, row in summary.sort_values("mean", ascending=False).iterrows():
print(f" {method:<20s}: {row['mean']:.4f} +/- {row['std']:.4f} "
f"(n={int(row['count'])})")
print("\n Mean silhouette in method-space vs expression-space:")
for method in ["scPTR_gamma", "raw_u_s_ratio", "unspliced_only", "expression"]:
sub = results_df[results_df["method"] == method]
if len(sub) == 0:
continue
print(f" {method:<20s}: sil_method={sub['sil_method_space'].mean():.4f}, "
f"sil_expr={sub['sil_expr_space'].mean():.4f}, "
f"invisibility={sub['invisibility'].mean():.4f}")
# Figure: Panel A (invisibility bars) + Panel B (per-cluster heatmap)
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
# Panel A: Mean invisibility by method
methods_order = ["expression", "unspliced_only", "raw_u_s_ratio", "scPTR_gamma"]
method_labels = ["Expression\n(baseline)", "Unspliced\nonly", "Raw u/s\nratio", "scPTR\ngamma"]
colors = ["gray", "lightblue", "orange", "steelblue"]
positions = np.arange(len(methods_order))
means = []
stds = []
for m in methods_order:
sub = results_df[results_df["method"] == m]["invisibility"]
means.append(sub.mean() if len(sub) > 0 else 0)
stds.append(sub.std() if len(sub) > 0 else 0)
bars = axes[0].bar(positions, means, 0.6, yerr=stds, color=colors,
edgecolor="black", linewidth=0.5, capsize=3)
axes[0].set_xticks(positions)
axes[0].set_xticklabels(method_labels, fontsize=10)
axes[0].set_ylabel("Mean Invisibility Score\n(sil_method - sil_expr)")
axes[0].set_title(f"A: Invisibility Score ({name})")
axes[0].axhline(y=0, color="black", linestyle="-", linewidth=0.5)
for i, (m, s) in enumerate(zip(means, stds)):
axes[0].text(i, m + s + 0.005, f"{m:.3f}", ha="center", fontsize=9)
# Panel B: Per-cluster invisibility heatmap
pivot = results_df.pivot_table(
index="cluster", columns="method", values="invisibility", aggfunc="mean"
)
if len(pivot) > 0:
# Reorder columns
col_order = [m for m in methods_order if m in pivot.columns]
pivot = pivot[col_order]
im = axes[1].imshow(pivot.values, aspect="auto", cmap="RdBu_r",
vmin=-0.3, vmax=0.3)
axes[1].set_xticks(np.arange(len(col_order)))
axes[1].set_xticklabels([m.replace("_", "\n") for m in col_order], fontsize=8)
axes[1].set_yticks(np.arange(len(pivot.index)))
axes[1].set_yticklabels(pivot.index, fontsize=8)
axes[1].set_title(f"B: Per-cluster Invisibility ({name})")
# Annotate cells
for i in range(len(pivot.index)):
for j in range(len(col_order)):
val = pivot.values[i, j]
if not np.isnan(val):
axes[1].text(j, i, f"{val:.2f}", ha="center", va="center",
fontsize=7, color="white" if abs(val) > 0.15 else "black")
plt.colorbar(im, ax=axes[1], label="Invisibility", shrink=0.8)
fig.suptitle(f"Ablation: Invisibility Score Analysis ({name})", fontsize=13)
fig.tight_layout()
save_fig(fig, f"ablation_{name}")
return results_df
# =========================================================================
# T2-2: TF score discrepancy
# =========================================================================
def explain_tf_discrepancy(datasets):
"""Investigate why TF score varies dramatically across datasets."""
print(f"\n{'='*60}")
print(f"T2-2: TF SCORE DISCREPANCY")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results"
res_dir.mkdir(parents=True, exist_ok=True)
for name, adata in datasets.items():
gamma = adata.layers["gamma"]
u = adata.layers.get("Mu", adata.layers.get("unspliced"))
u_arr = u.toarray() if hasattr(u, 'toarray') else np.asarray(u)
tf = adata.var["tf_score"].values
nonzero_frac = (gamma > 0).mean(axis=0)
u_detection = (u_arr > 0).mean(axis=0)
print(f"\n {name}:")
print(f" TF score: median={np.median(tf):.4f}, mean={np.mean(tf):.4f}")
print(f" Unspliced detection rate: median={np.median(u_detection):.4f}")
print(f" Gamma nonzero fraction: median={np.median(nonzero_frac):.4f}")
# Key insight: when gamma=0 for a gene, log1p(gamma)=0 → Var(log1p(gamma))=0
# → TF = Var(log1p(u)) / (Var(log1p(u)) + 0) = 1.0
# When gamma is nonzero, its variance dominates → TF ≈ 0
n_zero_gamma = (np.median(gamma, axis=0) == 0).sum()
tf_for_nonzero = tf[np.median(gamma, axis=0) > 0]
tf_for_zero = tf[np.median(gamma, axis=0) == 0]
print(f" Genes with zero median gamma: {n_zero_gamma}/{len(tf)}")
print(f" TF score for zero-gamma genes: {np.median(tf_for_zero):.4f}")
print(f" TF score for nonzero-gamma genes: {np.median(tf_for_nonzero):.4f}")
# Correlation between unspliced detection and TF score
r, p = stats.spearmanr(u_detection, nonzero_frac)
print(f" Corr(u_detection, gamma_nonzero): r={r:.4f}")
print(f"\n EXPLANATION:")
print(f" The TF score discrepancy is a data sparsity artifact:")
print(f" - In 10x data (pancreas, DG), most genes have very sparse unspliced")
print(f" counts, leading to gamma=0 for most cells → Var(log1p(gamma))≈0")
print(f" → TF=1.0 (trivially) for those genes.")
print(f" - In sci-fate, the new/old mapping produces dense 'unspliced' counts")
print(f" → gamma is nonzero for most genes → TF reflects real biology.")
print(f" - FIX: Report TF scores only for gamma-informative genes (>=10% nonzero).")
# =========================================================================
# T2-3: Housekeeping gene analysis
# =========================================================================
def housekeeping_analysis(datasets):
"""Show cross-dataset consistency improves for housekeeping genes."""
print(f"\n{'='*60}")
print(f"T2-3: HOUSEKEEPING GENE ANALYSIS")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results"
res_dir.mkdir(parents=True, exist_ok=True)
# Curated list of housekeeping genes (common across species)
hk_genes = [
"ACTB", "GAPDH", "TUBB", "HSP90AB1", "LDHA", "PPIA", "RPL13A",
"RPS18", "EEF1A1", "UBC", "B2M", "TUBA1B", "ENO1", "PKM",
"YWHAZ", "HNRNPA1", "NPM1", "HSPA8", "EIF4A1", "ATP5F1B",
"NONO", "SNRPD2", "SRSF3", "DDX5", "HNRNPC", "HNRNPU",
"SF3B1", "RPL3", "RPL7", "RPS3", "RPS6", "RPL4", "RPL5",
"RPS2", "RPL8", "RPS4X", "RPL11", "RPL13", "RPL18",
"RPL27", "RPS5", "RPS7", "RPS8", "RPS14", "RPS15A",
"RPS19", "RPS24", "RPS27A", "RPL6", "RPL9", "RPL10",
]
hk_set = set(g.upper() for g in hk_genes)
# Compute per-dataset median gamma
medians = {}
for name, adata in datasets.items():
gamma = adata.layers["gamma"]
med = pd.Series(np.median(gamma, axis=0), index=adata.var_names)
medians[name] = med
# Pairwise correlation: all genes vs housekeeping only
names = sorted(datasets.keys())
print("\n Cross-dataset consistency:")
print(f" {'Pair':<30s} {'All genes':>12s} {'Housekeeping':>14s} {'Improvement':>12s}")
print(f" {'-'*68}")
for i, name_a in enumerate(names):
for name_b in names[i + 1:]:
# Case-insensitive matching
map_a = {g.upper(): g for g in medians[name_a].index if isinstance(g, str)}
map_b = {g.upper(): g for g in medians[name_b].index if isinstance(g, str)}
# All shared genes
shared_upper = set(map_a.keys()) & set(map_b.keys())
ga_all = np.array([medians[name_a][map_a[u]] for u in shared_upper])
gb_all = np.array([medians[name_b][map_b[u]] for u in shared_upper])
valid = np.isfinite(ga_all) & np.isfinite(gb_all)
r_all, _ = stats.spearmanr(ga_all[valid], gb_all[valid])
# Housekeeping genes only
shared_hk = shared_upper & hk_set
if len(shared_hk) >= 5:
ga_hk = np.array([medians[name_a][map_a[u]] for u in shared_hk])
gb_hk = np.array([medians[name_b][map_b[u]] for u in shared_hk])
valid_hk = np.isfinite(ga_hk) & np.isfinite(gb_hk)
if valid_hk.sum() >= 5:
r_hk, _ = stats.spearmanr(ga_hk[valid_hk], gb_hk[valid_hk])
else:
r_hk = np.nan
else:
r_hk = np.nan
pair = f"{name_a} vs {name_b}"
improvement = r_hk - r_all if not np.isnan(r_hk) else np.nan
print(f" {pair:<30s} {r_all:>12.4f} {r_hk:>14.4f} "
f"{'':>2s}{'+' if improvement > 0 else ''}{improvement:.4f}")
# =========================================================================
# MAIN
# =========================================================================
def main():
set_figure_style()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Load and process datasets
print("=" * 60)
print("LOADING DATASETS")
print("=" * 60)
adata_pan = scptr.datasets.pancreas()
adata_pan = run_pipeline(adata_pan, "pancreas")
adata_dg = scptr.datasets.dentate_gyrus()
adata_dg = run_pipeline(adata_dg, "dentate_gyrus")
datasets = {"pancreas": adata_pan, "dentate_gyrus": adata_dg}
# T1-2: Fix gamma reporting
gamma_stats = {}
for name, adata in datasets.items():
gamma_stats[name] = fix_gamma_reporting(adata, name)
res_dir = OUTPUT_DIR / "results"
res_dir.mkdir(parents=True, exist_ok=True)
with open(res_dir / "gamma_reporting.json", "w") as f:
json.dump(gamma_stats, f, indent=2)
# T1-1: Functional characterization
for name, adata in datasets.items():
invis_df = characterize_invisible_states(adata, name)
if len(invis_df) > 0:
invis_df.to_csv(res_dir / f"invisible_states_{name}.csv", index=False)
# T1-3: Destabilizing bias
for name, adata in datasets.items():
investigate_destabilizing_bias(adata, name)
# T1-4: Velocity streamlines
for name, adata in datasets.items():
velocity_streamlines(adata, name)
# T2-1: Ablation
for name, adata in datasets.items():
ablation_experiments(adata, name)
# T2-2: TF discrepancy
# Also load sci-fate for comparison
from run_scifate import load_scifate_data, prepare_for_scptr
adata_sf_raw = load_scifate_data()
adata_sf = prepare_for_scptr(adata_sf_raw)
adata_sf = run_pipeline(adata_sf, "scifate")
all_datasets = {**datasets, "scifate": adata_sf}
explain_tf_discrepancy(all_datasets)
# T2-3: Housekeeping genes
housekeeping_analysis(all_datasets)
print(f"\n{'='*60}")
print("ALL TIER 1/2 FIXES COMPLETE")
print(f"{'='*60}")
print(f"Results saved to: {OUTPUT_DIR.resolve()}")
if __name__ == "__main__":
main()