scPTR / analyses /run_precedence.py
bryan7264's picture
Add files using upload-large-folder tool
9d901ad verified
Raw
History Blame Contribute Delete
17 kB
#!/usr/bin/env python
"""Demonstrate that PT velocity precedes RNA velocity at cell fate transitions.
Central hypothesis: Post-transcriptional regulation (gamma changes) acts as an
early signal that precedes and potentially drives transcriptional changes during
cell fate transitions.
Strategy:
1. Order cells along pseudotime (diffusion pseudotime via scanpy)
2. Smooth gamma and expression along pseudotime
3. For transition-associated genes, detect when gamma change and expression
change begin — gamma onset should precede expression onset
4. Cross-correlation analysis: gamma(t) should predict expression(t+delta)
"""
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, signal, ndimage
sys.path.insert(0, str(Path(__file__).parent))
from _common import set_figure_style
import scptr
OUTPUT_DIR = Path(__file__).parent.parent / "output" / "precedence"
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 compute_pseudotime(adata, root_cluster):
"""Compute diffusion pseudotime from a root cluster."""
# Use diffusion pseudotime via scanpy
sc.tl.diffmap(adata)
# Find root cell: centroid of root cluster in diffusion space
root_mask = adata.obs["clusters"] == root_cluster
root_cells = np.where(root_mask)[0]
if len(root_cells) == 0:
raise ValueError(f"No cells in cluster {root_cluster}")
# Pick cell closest to cluster centroid in diffmap
dm = adata.obsm["X_diffmap"]
centroid = dm[root_cells].mean(axis=0)
dists = np.linalg.norm(dm[root_cells] - centroid, axis=1)
root_idx = root_cells[np.argmin(dists)]
adata.uns["iroot"] = root_idx
sc.tl.dpt(adata)
pt = adata.obs["dpt_pseudotime"].values.copy()
# Handle infinite values
pt[~np.isfinite(pt)] = np.nanmax(pt[np.isfinite(pt)])
return pt
def smooth_along_pseudotime(values, pseudotime, n_bins=100):
"""Bin and smooth values along pseudotime axis.
Returns bin centers and smoothed values (per gene if 2D).
"""
bins = np.linspace(0, np.max(pseudotime), n_bins + 1)
bin_centers = (bins[:-1] + bins[1:]) / 2
bin_idx = np.digitize(pseudotime, bins) - 1
bin_idx = np.clip(bin_idx, 0, n_bins - 1)
if values.ndim == 1:
smoothed = np.zeros(n_bins)
for i in range(n_bins):
mask = bin_idx == i
if mask.sum() > 0:
smoothed[i] = np.mean(values[mask])
# Gaussian smoothing
smoothed = ndimage.gaussian_filter1d(smoothed, sigma=2)
return bin_centers, smoothed
# 2D: genes x bins
n_genes = values.shape[1]
smoothed = np.zeros((n_bins, n_genes))
for i in range(n_bins):
mask = bin_idx == i
if mask.sum() > 0:
smoothed[i] = np.mean(values[mask], axis=0)
# Smooth each gene
for g in range(n_genes):
smoothed[:, g] = ndimage.gaussian_filter1d(smoothed[:, g], sigma=2)
return bin_centers, smoothed
def detect_onset(trace, threshold_frac=0.1):
"""Detect onset of change: first index where signal exceeds
threshold_frac * (max - baseline)."""
baseline = np.mean(trace[:5]) # first 5 bins as baseline
peak = np.max(np.abs(trace - baseline))
threshold = baseline + threshold_frac * peak
for i, val in enumerate(trace):
if abs(val - baseline) > threshold_frac * peak:
return i
return len(trace) - 1
def cross_correlate_lag(gamma_trace, expr_trace, max_lag=20):
"""Compute cross-correlation to find temporal lag.
Positive lag = gamma leads expression.
Returns optimal lag and correlation at that lag.
"""
# Normalize
g = (gamma_trace - np.mean(gamma_trace))
g_std = np.std(g)
if g_std > 0:
g = g / g_std
e = (expr_trace - np.mean(expr_trace))
e_std = np.std(e)
if e_std > 0:
e = e / e_std
n = len(g)
best_lag = 0
best_corr = 0
for lag in range(-max_lag, max_lag + 1):
if lag >= 0:
corr = np.corrcoef(g[:n-lag], e[lag:])[0, 1] if n - lag > 5 else 0
else:
corr = np.corrcoef(g[-lag:], e[:n+lag])[0, 1] if n + lag > 5 else 0
if abs(corr) > abs(best_corr):
best_corr = corr
best_lag = lag
return best_lag, best_corr
def run_precedence_analysis(adata, dataset_name, root_cluster, n_bins=100):
"""Run temporal precedence analysis on one dataset."""
print(f"\n{'='*60}")
print(f"PRECEDENCE ANALYSIS: {dataset_name}")
print(f"{'='*60}")
res_dir = OUTPUT_DIR / "results" / dataset_name
res_dir.mkdir(parents=True, exist_ok=True)
# Step 1: Compute pseudotime
print("\n--- Computing pseudotime ---")
pt = compute_pseudotime(adata, root_cluster)
print(f" Root cluster: {root_cluster}")
print(f" Pseudotime range: [{pt.min():.4f}, {pt.max():.4f}]")
# Step 2: Get gamma and expression matrices
gamma = adata.layers["gamma"]
if hasattr(adata.X, 'toarray'):
expr = adata.X.toarray()
else:
expr = np.asarray(adata.X)
expr = np.log1p(expr) # log-normalize for comparison
# Step 3: Smooth both along pseudotime
print("\n--- Smoothing along pseudotime ---")
bin_centers, gamma_smooth = smooth_along_pseudotime(gamma, pt, n_bins)
_, expr_smooth = smooth_along_pseudotime(expr, pt, n_bins)
# Step 4: Identify transition genes (high variance along pseudotime)
gamma_var = np.var(gamma_smooth, axis=0)
expr_var = np.var(expr_smooth, axis=0)
# Require both gamma and expression to vary along pseudotime
gamma_var_thresh = np.percentile(gamma_var[gamma_var > 0], 75)
expr_var_thresh = np.percentile(expr_var[expr_var > 0], 75)
transition_mask = (gamma_var > gamma_var_thresh) & (expr_var > expr_var_thresh)
transition_genes = adata.var_names[transition_mask]
print(f" Transition genes: {len(transition_genes)}")
# Step 5: Onset detection
print("\n--- Onset detection ---")
onset_results = []
for i, gene in enumerate(adata.var_names):
if not transition_mask[i]:
continue
g_trace = gamma_smooth[:, i]
e_trace = expr_smooth[:, i]
g_onset = detect_onset(g_trace)
e_onset = detect_onset(e_trace)
lead_bins = e_onset - g_onset # positive = gamma leads
onset_results.append({
"gene": gene,
"gamma_onset_bin": g_onset,
"expr_onset_bin": e_onset,
"lead_bins": lead_bins,
})
onset_df = pd.DataFrame(onset_results)
n_gamma_leads = (onset_df["lead_bins"] > 0).sum()
n_expr_leads = (onset_df["lead_bins"] < 0).sum()
n_simultaneous = (onset_df["lead_bins"] == 0).sum()
print(f" Gamma leads: {n_gamma_leads}/{len(onset_df)} genes")
print(f" Expression leads: {n_expr_leads}/{len(onset_df)} genes")
print(f" Simultaneous: {n_simultaneous}/{len(onset_df)} genes")
print(f" Mean lead (bins): {onset_df['lead_bins'].mean():.2f}")
# Binomial test: is gamma-leading significantly more common than chance?
n_nontied = n_gamma_leads + n_expr_leads
if n_nontied > 0:
binom_p = stats.binomtest(n_gamma_leads, n_nontied, 0.5).pvalue
print(f" Binomial test (gamma leads more): p = {binom_p:.4e}")
else:
binom_p = 1.0
onset_df.to_csv(res_dir / "onset_detection.csv", index=False)
# Step 6: Cross-correlation analysis
print("\n--- Cross-correlation analysis ---")
lag_results = []
for i, gene in enumerate(adata.var_names):
if not transition_mask[i]:
continue
g_trace = gamma_smooth[:, i]
e_trace = expr_smooth[:, i]
lag, corr = cross_correlate_lag(g_trace, e_trace, max_lag=15)
lag_results.append({
"gene": gene,
"optimal_lag": lag,
"cross_corr": corr,
})
lag_df = pd.DataFrame(lag_results)
mean_lag = lag_df["optimal_lag"].mean()
median_lag = lag_df["optimal_lag"].median()
n_positive_lag = (lag_df["optimal_lag"] > 0).sum()
print(f" Mean optimal lag: {mean_lag:.2f} bins (positive = gamma leads)")
print(f" Median optimal lag: {median_lag:.1f} bins")
print(f" Genes with positive lag: {n_positive_lag}/{len(lag_df)}")
lag_df.to_csv(res_dir / "cross_correlation.csv", index=False)
# Step 7: Combine results
results = {
"n_transition_genes": len(transition_genes),
"onset_gamma_leads": int(n_gamma_leads),
"onset_expr_leads": int(n_expr_leads),
"onset_simultaneous": int(n_simultaneous),
"onset_mean_lead_bins": float(onset_df["lead_bins"].mean()),
"onset_binomial_p": float(binom_p),
"crosscorr_mean_lag": float(mean_lag),
"crosscorr_median_lag": float(median_lag),
"crosscorr_positive_lag_frac": float(n_positive_lag / len(lag_df)),
}
with open(res_dir / "precedence_results.json", "w") as f:
json.dump(results, f, indent=2)
# =========================================================================
# FIGURES
# =========================================================================
# Figure 1: Onset histogram
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].hist(onset_df["lead_bins"], bins=30, color="steelblue",
alpha=0.8, edgecolor="white")
axes[0].axvline(0, color="red", linestyle="--", alpha=0.5, label="Simultaneous")
axes[0].axvline(onset_df["lead_bins"].mean(), color="darkred",
linestyle="-", lw=2,
label=f"Mean={onset_df['lead_bins'].mean():.1f}")
axes[0].set_xlabel("Lead (bins): positive = gamma leads expression")
axes[0].set_ylabel("Number of genes")
axes[0].set_title(f"Onset detection ({n_gamma_leads}/{len(onset_df)} gamma-leading)")
axes[0].legend()
# Cross-correlation lag histogram
axes[1].hist(lag_df["optimal_lag"], bins=30, color="darkorange",
alpha=0.8, edgecolor="white")
axes[1].axvline(0, color="red", linestyle="--", alpha=0.5, label="No lag")
axes[1].axvline(mean_lag, color="darkred", linestyle="-", lw=2,
label=f"Mean={mean_lag:.1f}")
axes[1].set_xlabel("Optimal lag (bins): positive = gamma leads")
axes[1].set_ylabel("Number of genes")
axes[1].set_title(f"Cross-correlation lag ({n_positive_lag}/{len(lag_df)} positive)")
axes[1].legend()
fig.suptitle(f"PT Velocity Precedes RNA Velocity: {dataset_name}",
fontsize=13, y=1.02)
fig.tight_layout()
save_fig(fig, f"precedence_{dataset_name}")
# Figure 2: Example gene traces
# Pick top 6 genes with largest gamma-leading onset
top_genes = onset_df.nlargest(6, "lead_bins")
fig, axes = plt.subplots(2, 3, figsize=(16, 9))
axes = axes.flatten()
for idx, (_, row) in enumerate(top_genes.iterrows()):
if idx >= 6:
break
gene = row["gene"]
gi = list(adata.var_names).index(gene)
g_trace = gamma_smooth[:, gi]
e_trace = expr_smooth[:, gi]
# Normalize for comparison
g_norm = (g_trace - g_trace.min()) / (g_trace.max() - g_trace.min() + 1e-10)
e_norm = (e_trace - e_trace.min()) / (e_trace.max() - e_trace.min() + 1e-10)
ax = axes[idx]
ax.plot(bin_centers, g_norm, "b-", lw=2, label="Gamma (norm)")
ax.plot(bin_centers, e_norm, "r-", lw=2, label="Expression (norm)")
ax.axvline(bin_centers[int(row["gamma_onset_bin"])], color="blue",
linestyle=":", alpha=0.5)
ax.axvline(bin_centers[int(row["expr_onset_bin"])], color="red",
linestyle=":", alpha=0.5)
ax.set_xlabel("Pseudotime")
ax.set_ylabel("Normalized value")
ax.set_title(f"{gene} (lead={int(row['lead_bins'])} bins)")
ax.legend(fontsize=7)
fig.suptitle(f"Top Gamma-Leading Genes: {dataset_name}", fontsize=13, y=1.02)
fig.tight_layout()
save_fig(fig, f"example_genes_{dataset_name}")
return results
def main():
set_figure_style()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# =========================================================================
# PANCREAS: Ductal → Beta cell lineage
# =========================================================================
print("=" * 60)
print("LOADING AND PROCESSING PANCREAS")
print("=" * 60)
adata_pan = scptr.datasets.pancreas()
scptr.pp.filter_genes(adata_pan)
scptr.pp.normalize_layers(adata_pan)
scptr.pp.neighbors(adata_pan, n_neighbors=30)
scptr.pp.smooth_layers(adata_pan)
scptr.tl.estimate_beta(adata_pan)
scptr.tl.estimate_gamma(adata_pan)
scptr.tl.variance_decomposition(adata_pan)
scptr.tl.pt_states(adata_pan)
scptr.tl.pt_velocity(adata_pan)
print(f" Pipeline complete: {adata_pan.shape}")
# Root cluster for pseudotime: Ductal (progenitor)
print(f" Clusters: {adata_pan.obs['clusters'].unique().tolist()}")
pan_results = run_precedence_analysis(
adata_pan, "pancreas", root_cluster="Ductal"
)
# =========================================================================
# DENTATE GYRUS: Radial glia → Granule neuron lineage
# =========================================================================
print("\n" + "=" * 60)
print("LOADING AND PROCESSING DENTATE GYRUS")
print("=" * 60)
adata_dg = scptr.datasets.dentate_gyrus()
scptr.pp.filter_genes(adata_dg)
scptr.pp.normalize_layers(adata_dg)
scptr.pp.neighbors(adata_dg, n_neighbors=30)
scptr.pp.smooth_layers(adata_dg)
scptr.tl.estimate_beta(adata_dg)
scptr.tl.estimate_gamma(adata_dg)
scptr.tl.variance_decomposition(adata_dg)
scptr.tl.pt_states(adata_dg)
scptr.tl.pt_velocity(adata_dg)
print(f" Pipeline complete: {adata_dg.shape}")
print(f" Clusters: {adata_dg.obs['clusters'].unique().tolist()}")
dg_results = run_precedence_analysis(
adata_dg, "dentate_gyrus", root_cluster="Radial Glia-like"
)
# =========================================================================
# COMBINED SUMMARY
# =========================================================================
print("\n" + "=" * 60)
print("COMBINED SUMMARY")
print("=" * 60)
for name, results in [("pancreas", pan_results), ("dentate_gyrus", dg_results)]:
print(f"\n {name}:")
print(f" Transition genes: {results['n_transition_genes']}")
print(f" Gamma leads: {results['onset_gamma_leads']}, "
f"Expr leads: {results['onset_expr_leads']}")
print(f" Mean onset lead: {results['onset_mean_lead_bins']:.2f} bins")
print(f" Binomial p: {results['onset_binomial_p']:.4e}")
print(f" Cross-corr mean lag: {results['crosscorr_mean_lag']:.2f} bins")
# Summary figure: comparison bar chart
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
datasets = ["pancreas", "dentate_gyrus"]
all_results = [pan_results, dg_results]
# Left: onset detection
leads = [r["onset_gamma_leads"] for r in all_results]
follows = [r["onset_expr_leads"] for r in all_results]
simult = [r["onset_simultaneous"] for r in all_results]
x = np.arange(len(datasets))
width = 0.25
axes[0].bar(x - width, leads, width, label="Gamma leads", color="steelblue")
axes[0].bar(x, simult, width, label="Simultaneous", color="gray")
axes[0].bar(x + width, follows, width, label="Expression leads", color="salmon")
axes[0].set_xticks(x)
axes[0].set_xticklabels(datasets)
axes[0].set_ylabel("Number of genes")
axes[0].set_title("Onset Detection: Which Changes First?")
axes[0].legend()
# Right: mean lag
mean_lags = [r["crosscorr_mean_lag"] for r in all_results]
colors = ["steelblue" if l > 0 else "salmon" for l in mean_lags]
axes[1].bar(datasets, mean_lags, color=colors)
axes[1].set_ylabel("Mean optimal lag (bins)")
axes[1].set_title("Cross-Correlation: Positive = Gamma Leads")
axes[1].axhline(0, color="gray", linestyle="--", alpha=0.3)
fig.suptitle("Post-Transcriptional Changes Precede Transcriptional Changes",
fontsize=13, y=1.02)
fig.tight_layout()
save_fig(fig, "combined_precedence")
# Save combined results
combined = {"pancreas": pan_results, "dentate_gyrus": dg_results}
res_dir = OUTPUT_DIR / "results"
res_dir.mkdir(parents=True, exist_ok=True)
with open(res_dir / "combined_precedence.json", "w") as f:
json.dump(combined, f, indent=2)
print(f"\nAll results saved to: {OUTPUT_DIR.resolve()}")
if __name__ == "__main__":
main()