File size: 17,049 Bytes
9d901ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
#!/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()