| |
| """Head-to-head comparison: scPTR vs scVelo vs velVI. |
| |
| Compares degradation rate / kinetic parameter estimates across methods |
| using the same evaluation framework: |
| 1. Half-life correlation (mouse + human references) |
| 2. Cell-type discrimination (silhouette, ANOVA F-stat) |
| 3. Latent quality (pseudotime correlation, cell-type silhouette) |
| 4. Runtime |
| |
| Datasets: pancreas, dentate gyrus. |
| """ |
| from _common import * |
| import scanpy as sc |
| import scvelo as scv |
| import time as _time |
| from sklearn.metrics import silhouette_score |
| from sklearn.decomposition import PCA |
|
|
| OUT = output_dir("12_method_comparison") |
|
|
|
|
| |
|
|
| def run_scvelo_steady(adata_raw, name): |
| """Run scVelo steady-state mode, extract gamma-like parameter.""" |
| print(f"\n scVelo steady-state ({name})...") |
| adata = adata_raw.copy() |
| t0 = _time.time() |
|
|
| scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=2000) |
| scv.pp.moments(adata, n_pcs=30, n_neighbors=30) |
| scv.tl.velocity(adata, mode="steady_state") |
| scv.tl.velocity_graph(adata) |
|
|
| elapsed = _time.time() - t0 |
|
|
| |
| |
| gamma = adata.var.get("velocity_gamma", pd.Series(dtype=float)) |
|
|
| |
| if "velocity" in adata.layers: |
| vel = np.asarray(adata.layers["velocity"]) |
| vel = np.nan_to_num(vel, 0) |
| vel_pca = PCA(n_components=min(10, vel.shape[1])).fit_transform(vel) |
| else: |
| vel_pca = None |
|
|
| print(f" Done in {elapsed:.1f}s, {len(gamma)} genes, {adata.n_vars} after filter") |
| return adata, gamma, vel_pca, elapsed |
|
|
|
|
| |
|
|
| def run_scvelo_dynamical(adata_raw, name): |
| """Run scVelo dynamical mode, extract gamma and latent time.""" |
| print(f"\n scVelo dynamical ({name})...") |
| adata = adata_raw.copy() |
| t0 = _time.time() |
|
|
| scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=2000) |
| scv.pp.moments(adata, n_pcs=30, n_neighbors=30) |
| scv.tl.recover_dynamics(adata, n_jobs=4) |
| scv.tl.velocity(adata, mode="dynamical") |
| scv.tl.velocity_graph(adata) |
| scv.tl.latent_time(adata) |
|
|
| elapsed = _time.time() - t0 |
|
|
| gamma = adata.var.get("fit_gamma", pd.Series(dtype=float)) |
| latent_time = adata.obs.get("latent_time", pd.Series(dtype=float)) |
|
|
| if "velocity" in adata.layers: |
| vel = np.asarray(adata.layers["velocity"]) |
| vel = np.nan_to_num(vel, 0) |
| vel_pca = PCA(n_components=min(10, vel.shape[1])).fit_transform(vel) |
| else: |
| vel_pca = None |
|
|
| print(f" Done in {elapsed:.1f}s, {len(gamma)} genes") |
| return adata, gamma, latent_time, vel_pca, elapsed |
|
|
|
|
| |
|
|
| def run_velovi(adata_raw, name): |
| """Run velVI (VELOVI from scvi-tools).""" |
| print(f"\n velVI ({name})...") |
| from scvi.external import VELOVI |
|
|
| adata = adata_raw.copy() |
| t0 = _time.time() |
|
|
| |
| scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=1000) |
| scv.pp.moments(adata, n_pcs=30, n_neighbors=30) |
|
|
| |
| VELOVI.setup_anndata(adata, spliced_layer="Ms", unspliced_layer="Mu") |
| model = VELOVI(adata, n_latent=10, n_hidden=128, n_layers=1) |
| model.train(max_epochs=50, early_stopping=True, early_stopping_patience=10, |
| accelerator="cpu") |
|
|
| elapsed = _time.time() - t0 |
|
|
| |
| latent = model.get_latent_representation() |
| adata.obsm["X_velovi"] = latent |
|
|
| |
| |
| |
| try: |
| outputs = model.get_velocity() |
| if "velocity" not in adata.layers: |
| adata.layers["velocity"] = outputs |
| except Exception: |
| pass |
|
|
| |
| gamma_vals = None |
| try: |
| gamma_vals = model.get_rates() |
| if isinstance(gamma_vals, dict) and "gamma" in gamma_vals: |
| gamma_vals = gamma_vals["gamma"] |
| except Exception: |
| pass |
|
|
| vel_pca = None |
| if "velocity" in adata.layers: |
| vel = np.asarray(adata.layers["velocity"]) |
| vel = np.nan_to_num(vel, 0) |
| vel_pca = PCA(n_components=min(10, vel.shape[1])).fit_transform(vel) |
|
|
| print(f" Done in {elapsed:.1f}s") |
| return adata, gamma_vals, latent, vel_pca, elapsed |
|
|
|
|
| |
|
|
| def run_scptr_analytical(adata_raw, name): |
| """Run scPTR analytical pipeline.""" |
| print(f"\n scPTR analytical ({name})...") |
| adata = adata_raw.copy() |
| t0 = _time.time() |
|
|
| 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) |
|
|
| elapsed = _time.time() - t0 |
|
|
| gamma_med = np.median(adata.layers["gamma"], axis=0) |
| gamma_s = pd.Series(gamma_med, index=adata.var_names) |
|
|
| |
| gamma_pca = PCA(n_components=min(10, adata.layers["gamma"].shape[1])).fit_transform( |
| adata.layers["gamma"] |
| ) |
|
|
| print(f" Done in {elapsed:.1f}s, {adata.n_vars} genes") |
| return adata, gamma_s, gamma_pca, elapsed |
|
|
|
|
| |
|
|
| def run_scptr_deep(adata_raw, name): |
| """Run DeepPTR.""" |
| print(f"\n DeepPTR ({name})...") |
| adata = adata_raw.copy() |
| t0 = _time.time() |
|
|
| 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) |
| adata = select_top_genes(adata, n_top=300) |
|
|
| torch.set_num_threads(4) |
| model, history = scptr.deep.fit_deepptr(adata, verbose=False, **DEEP_HP) |
|
|
| elapsed = _time.time() - t0 |
|
|
| gamma_med = np.median(adata.layers["gamma"], axis=0) |
| gamma_s = pd.Series(gamma_med, index=adata.var_names) |
|
|
| print(f" Done in {elapsed:.1f}s, {adata.n_vars} genes") |
| return adata, gamma_s, adata.obsm.get("X_z_T"), elapsed |
|
|
|
|
| |
|
|
| def eval_halflife(gamma_series_or_adata, hl_df, method_name): |
| """Evaluate half-life correlation for any method's gamma estimates.""" |
| if isinstance(gamma_series_or_adata, pd.Series): |
| gamma_s = gamma_series_or_adata |
| else: |
| |
| adata = gamma_series_or_adata |
| gamma_med = np.median(adata.layers["gamma"], axis=0) |
| gamma_s = pd.Series(gamma_med, index=adata.var_names) |
|
|
| hl_s = hl_df.set_index("gene_symbol")["half_life_hours"] |
|
|
| |
| gamma_upper = {g.upper(): g for g in gamma_s.index} |
| hl_upper = {g.upper(): g for g in hl_s.index if isinstance(g, str)} |
| shared = set(gamma_upper.keys()) & set(hl_upper.keys()) |
|
|
| g = np.array([gamma_s[gamma_upper[u]] for u in shared], dtype=float) |
| h = np.array([hl_s[hl_upper[u]] for u in shared], dtype=float) |
|
|
| valid = np.isfinite(g) & np.isfinite(h) & (g > 0) & (h > 0) |
| if valid.sum() < 3: |
| return np.nan, 0 |
| r, _ = stats.spearmanr(g[valid], h[valid]) |
| return float(r), int(valid.sum()) |
|
|
|
|
| def eval_celltype(latent_or_pca, labels, method_name): |
| """Evaluate cell-type discrimination in latent/kinetic space.""" |
| if latent_or_pca is None: |
| return np.nan |
| labels_arr = np.asarray(labels) |
| if isinstance(labels_arr[0], str): |
| from sklearn.preprocessing import LabelEncoder |
| labels_arr = LabelEncoder().fit_transform(labels_arr) |
|
|
| n_sample = min(2000, len(labels_arr)) |
| try: |
| return float(silhouette_score(latent_or_pca, labels_arr, sample_size=n_sample)) |
| except Exception: |
| return np.nan |
|
|
|
|
| def eval_anova_fstat(gamma_matrix, labels, gene_names): |
| """Median F-statistic across genes for cell-type gamma differences.""" |
| from scipy.stats import f_oneway |
| cts = sorted(set(labels)) |
| f_stats = [] |
| for g in range(gamma_matrix.shape[1]): |
| groups = [gamma_matrix[labels == ct, g] for ct in cts if (labels == ct).sum() >= 5] |
| if len(groups) < 2: |
| continue |
| try: |
| f, p = f_oneway(*groups) |
| if np.isfinite(f): |
| f_stats.append(f) |
| except Exception: |
| pass |
| return float(np.median(f_stats)) if f_stats else np.nan |
|
|
|
|
| |
|
|
| def run_comparison(name, loader, cluster_key): |
| print(f"\n{'#' * 60}") |
| print(f"# {name.upper()}: METHOD COMPARISON") |
| print(f"{'#' * 60}") |
|
|
| adata_raw = loader() |
| hl_mouse, hl_human = load_halflife_refs() |
| labels = adata_raw.obs.get(cluster_key, pd.Series(dtype=str)).values |
|
|
| results = {} |
|
|
| |
| |
| try: |
| sv_ss_adata, sv_ss_gamma, sv_ss_pca, sv_ss_time = run_scvelo_steady(adata_raw, name) |
| results["scvelo_ss"] = {"time": sv_ss_time, "n_genes": len(sv_ss_gamma)} |
| except Exception as e: |
| print(f" scVelo SS failed: {e}") |
| sv_ss_gamma, sv_ss_pca = pd.Series(dtype=float), None |
| results["scvelo_ss"] = {"error": str(e)} |
|
|
| |
| try: |
| sv_dyn_adata, sv_dyn_gamma, sv_dyn_ltime, sv_dyn_pca, sv_dyn_time = run_scvelo_dynamical(adata_raw, name) |
| results["scvelo_dyn"] = {"time": sv_dyn_time, "n_genes": len(sv_dyn_gamma)} |
| except Exception as e: |
| print(f" scVelo dyn failed: {e}") |
| sv_dyn_gamma, sv_dyn_ltime, sv_dyn_pca = pd.Series(dtype=float), None, None |
| results["scvelo_dyn"] = {"error": str(e)} |
|
|
| |
| try: |
| vi_adata, vi_gamma, vi_latent, vi_pca, vi_time = run_velovi(adata_raw, name) |
| results["velovi"] = {"time": vi_time} |
| except Exception as e: |
| print(f" velVI failed: {e}") |
| vi_gamma, vi_latent, vi_pca = None, None, None |
| results["velovi"] = {"error": str(e)} |
|
|
| |
| sp_adata, sp_gamma, sp_pca, sp_time = run_scptr_analytical(adata_raw, name) |
| results["scptr_analytical"] = {"time": sp_time, "n_genes": len(sp_gamma)} |
|
|
| |
| dp_adata, dp_gamma, dp_latent, dp_time = run_scptr_deep(adata_raw, name) |
| results["deepptr"] = {"time": dp_time, "n_genes": len(dp_gamma)} |
|
|
| |
| print(f"\n--- Half-life correlations ---") |
| methods_gamma = { |
| "scVelo SS": sv_ss_gamma, |
| "scVelo dyn": sv_dyn_gamma, |
| "scPTR analytical": sp_gamma, |
| "DeepPTR": dp_gamma, |
| } |
|
|
| for ref_name, hl_df in [("mouse", hl_mouse), ("human", hl_human)]: |
| print(f"\n {ref_name}:") |
| for mname, gamma in methods_gamma.items(): |
| if gamma is not None and len(gamma) > 0: |
| r, n = eval_halflife(gamma, hl_df, mname) |
| results.setdefault(mname.lower().replace(" ", "_"), {}) |
| results[mname.lower().replace(" ", "_")][f"halflife_{ref_name}"] = {"r": r, "n": n} |
| print(f" {mname:25s}: r={r:.4f} (n={n})") |
| else: |
| print(f" {mname:25s}: N/A") |
|
|
| |
| if vi_gamma is not None: |
| if isinstance(vi_gamma, np.ndarray) and vi_gamma.ndim == 1: |
| |
| try: |
| vi_gs = pd.Series(vi_gamma, index=vi_adata.var_names) |
| r, n = eval_halflife(vi_gs, hl_df, "velVI") |
| except Exception: |
| r, n = np.nan, 0 |
| elif isinstance(vi_gamma, pd.Series): |
| r, n = eval_halflife(vi_gamma, hl_df, "velVI") |
| else: |
| r, n = np.nan, 0 |
| results.setdefault("velovi", {}) |
| results["velovi"][f"halflife_{ref_name}"] = {"r": r, "n": n} |
| print(f" {'velVI':25s}: r={r:.4f} (n={n})") |
|
|
| |
| print(f"\n--- Cell-type discrimination (silhouette) ---") |
| if cluster_key in adata_raw.obs.columns: |
| spaces = { |
| "scVelo SS velocity": sv_ss_pca, |
| "scVelo dyn velocity": sv_dyn_pca, |
| "velVI latent": vi_latent, |
| "velVI velocity": vi_pca, |
| "scPTR gamma": sp_pca, |
| "DeepPTR z_T": dp_latent, |
| } |
| for sname, space in spaces.items(): |
| if space is not None: |
| |
| if "scVelo" in sname: |
| if "SS" in sname: |
| lbl = sv_ss_adata.obs.get(cluster_key, pd.Series(dtype=str)).values |
| else: |
| lbl = sv_dyn_adata.obs.get(cluster_key, pd.Series(dtype=str)).values |
| elif "velVI" in sname: |
| lbl = vi_adata.obs.get(cluster_key, pd.Series(dtype=str)).values |
| elif "scPTR" in sname: |
| lbl = sp_adata.obs.get(cluster_key, pd.Series(dtype=str)).values |
| else: |
| lbl = dp_adata.obs.get(cluster_key, pd.Series(dtype=str)).values |
|
|
| sil = eval_celltype(space, lbl, sname) |
| key = sname.lower().replace(" ", "_") |
| results[key] = results.get(key, {}) |
| results[key]["silhouette"] = sil |
| print(f" {sname:25s}: {sil:.4f}") |
|
|
| |
| print(f"\n--- Gamma cell-type F-statistic ---") |
| if cluster_key in sp_adata.obs.columns: |
| f_an = eval_anova_fstat(sp_adata.layers["gamma"], |
| sp_adata.obs[cluster_key].values, |
| sp_adata.var_names) |
| f_dp = eval_anova_fstat(dp_adata.layers["gamma"], |
| dp_adata.obs[cluster_key].values, |
| dp_adata.var_names) |
| print(f" scPTR analytical: median F = {f_an:.2f}") |
| print(f" DeepPTR: median F = {f_dp:.2f}") |
| results["gamma_fstat"] = {"analytical": f_an, "deepptr": f_dp} |
|
|
| |
| print(f"\n--- Runtime ---") |
| runtimes = { |
| "scVelo SS": results.get("scvelo_ss", {}).get("time", np.nan), |
| "scVelo dyn": results.get("scvelo_dyn", {}).get("time", np.nan), |
| "velVI": results.get("velovi", {}).get("time", np.nan), |
| "scPTR analytical": results.get("scptr_analytical", {}).get("time", np.nan), |
| "DeepPTR": results.get("deepptr", {}).get("time", np.nan), |
| } |
| for mname, t in runtimes.items(): |
| print(f" {mname:25s}: {t:.1f}s" if np.isfinite(t) else f" {mname:25s}: N/A") |
|
|
| save_json(results, f"{name}_comparison", OUT) |
|
|
| |
| fig, axes = plt.subplots(1, 3, figsize=(16, 5)) |
|
|
| |
| methods = ["scVelo SS", "scVelo dyn", "scPTR analytical", "DeepPTR"] |
| method_keys = ["scvelo_ss", "scvelo_dyn", "scptr_analytical", "deepptr"] |
| colors = ["#1f77b4", "#2ca02c", "#ff7f0e", "#d62728"] |
|
|
| for ref_idx, ref_name in enumerate(["mouse", "human"]): |
| x_offset = ref_idx * 0.4 |
| for i, (mname, mkey) in enumerate(zip(methods, method_keys)): |
| r = results.get(mkey, {}).get(f"halflife_{ref_name}", {}).get("r", np.nan) |
| if np.isfinite(r): |
| axes[0].bar(i + x_offset, abs(r), 0.35, color=colors[i], |
| alpha=0.7 if ref_idx == 0 else 0.4) |
|
|
| axes[0].set_xticks(range(len(methods))) |
| axes[0].set_xticklabels(methods, rotation=30, ha="right", fontsize=8) |
| axes[0].set_ylabel("|Spearman r| with half-life") |
| axes[0].set_title(f"{name}: Half-life correlation") |
|
|
| |
| valid_runtimes = {k: v for k, v in runtimes.items() if np.isfinite(v)} |
| if valid_runtimes: |
| axes[1].barh(list(valid_runtimes.keys()), list(valid_runtimes.values()), |
| color=colors[:len(valid_runtimes)], alpha=0.7) |
| axes[1].set_xlabel("Runtime (seconds)") |
| axes[1].set_title("Runtime comparison") |
|
|
| |
| sil_data = {} |
| for sname in ["scVelo SS velocity", "scVelo dyn velocity", "scPTR gamma", "DeepPTR z_T"]: |
| key = sname.lower().replace(" ", "_") |
| s = results.get(key, {}).get("silhouette", np.nan) |
| if np.isfinite(s): |
| sil_data[sname] = s |
|
|
| if sil_data: |
| axes[2].bar(list(sil_data.keys()), list(sil_data.values()), color="steelblue", alpha=0.7) |
| axes[2].set_ylabel("Silhouette score") |
| axes[2].set_title("Cell-type discrimination") |
| plt.setp(axes[2].get_xticklabels(), rotation=30, ha="right", fontsize=8) |
|
|
| fig.suptitle(f"{name}: Method comparison", y=1.02) |
| fig.tight_layout() |
| save_fig(fig, f"{name}_method_comparison", OUT) |
|
|
| return results |
|
|
|
|
| def main(): |
| set_figure_style() |
| all_results = {} |
| for name, loader, ck in DATASETS: |
| all_results[name] = run_comparison(name, loader, ck) |
|
|
| |
| print(f"\n{'=' * 80}") |
| print("METHOD COMPARISON SUMMARY") |
| print("=" * 80) |
| print(f"\n{'Method':<25} {'HL mouse':>10} {'HL human':>10} {'Runtime':>10}") |
| print("-" * 60) |
|
|
| for ds_name in all_results: |
| print(f"\n {ds_name.upper()}") |
| r = all_results[ds_name] |
| for mkey, mname in [("scvelo_ss", "scVelo SS"), ("scvelo_dyn", "scVelo dyn"), |
| ("velovi", "velVI"), |
| ("scptr_analytical", "scPTR analytical"), ("deepptr", "DeepPTR")]: |
| d = r.get(mkey, {}) |
| r_m = d.get("halflife_mouse", {}).get("r", np.nan) |
| r_h = d.get("halflife_human", {}).get("r", np.nan) |
| t = d.get("time", np.nan) |
| r_m_s = f"{r_m:.4f}" if np.isfinite(r_m) else "N/A" |
| r_h_s = f"{r_h:.4f}" if np.isfinite(r_h) else "N/A" |
| t_s = f"{t:.0f}s" if np.isfinite(t) else "N/A" |
| print(f" {mname:<25} {r_m_s:>10} {r_h_s:>10} {t_s:>10}") |
|
|
| save_json(all_results, "comparison_all", OUT) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|