| |
| """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.""" |
| |
| sc.tl.diffmap(adata) |
|
|
| |
| 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}") |
|
|
| |
| 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() |
| |
| 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]) |
| |
| smoothed = ndimage.gaussian_filter1d(smoothed, sigma=2) |
| return bin_centers, smoothed |
|
|
| |
| 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) |
| |
| 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]) |
| 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. |
| """ |
| |
| 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) |
|
|
| |
| 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}]") |
|
|
| |
| gamma = adata.layers["gamma"] |
| if hasattr(adata.X, 'toarray'): |
| expr = adata.X.toarray() |
| else: |
| expr = np.asarray(adata.X) |
| expr = np.log1p(expr) |
|
|
| |
| 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) |
|
|
| |
| gamma_var = np.var(gamma_smooth, axis=0) |
| expr_var = np.var(expr_smooth, axis=0) |
|
|
| |
| 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)}") |
|
|
| |
| 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 |
|
|
| 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}") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| |
|
|
| |
| 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() |
|
|
| |
| 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}") |
|
|
| |
| |
| 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] |
|
|
| |
| 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) |
|
|
| |
| |
| |
| 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}") |
|
|
| |
| print(f" Clusters: {adata_pan.obs['clusters'].unique().tolist()}") |
| pan_results = run_precedence_analysis( |
| adata_pan, "pancreas", root_cluster="Ductal" |
| ) |
|
|
| |
| |
| |
| 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" |
| ) |
|
|
| |
| |
| |
| 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") |
|
|
| |
| fig, axes = plt.subplots(1, 2, figsize=(12, 5)) |
|
|
| datasets = ["pancreas", "dentate_gyrus"] |
| all_results = [pan_results, dg_results] |
|
|
| |
| 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() |
|
|
| |
| 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") |
|
|
| |
| 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() |
|
|