CoolFace
Apppublic

harshameghadri/medgemma-spatial

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes
uncertainty_spatial_analysis.py479 linesDownload Raw Back to spatial_analysis
1"""2Uncertainty-Aware Spatial Analysis Pipeline3Stage 0: Pre-processing with annotation quality validation4Stage 1: Spatial pattern extraction with uncertainty quantification5"""6 7import scanpy as sc8import numpy as np9import pandas as pd10from scipy import stats11from typing import Dict, Tuple, List12import warnings13warnings.filterwarnings('ignore')14 15# ==============================================================================16# STAGE 0: ANNOTATION QUALITY LAYER (Doublet Detection)17# ==============================================================================18 19def detect_doublets_scrublet(adata, expected_doublet_rate=0.06):20    """21    Detect doublets using Scrublet algorithm.22 23    Returns:24        adata: Updated with doublet scores and predictions25        quality_metrics: Dict with doublet statistics26    """27    try:28        import scrublet as scr29    except ImportError:30        print("WARNING: Scrublet not installed. Skipping doublet detection.")31        print("Install with: pip install scrublet")32        adata.obs['doublet_score'] = 0.033        adata.obs['predicted_doublet'] = False34        return adata, {35            'doublet_detection_available': False,36            'n_predicted_doublets': 0,37            'doublet_rate': 0.038        }39 40    scrub = scr.Scrublet(adata.X, expected_doublet_rate=expected_doublet_rate)41    doublet_scores, predicted_doublets = scrub.scrub_doublets(min_counts=2, min_cells=3, min_gene_variability_pctl=85)42 43    adata.obs['doublet_score'] = doublet_scores44 45    # Handle case when Scrublet fails to auto-detect threshold46    if predicted_doublets is None:47        print("  WARNING: Scrublet failed to auto-detect threshold. Using manual threshold at 0.25")48        threshold = 0.2549        predicted_doublets = doublet_scores > threshold50 51    adata.obs['predicted_doublet'] = predicted_doublets52 53    n_doublets = predicted_doublets.sum()54    doublet_rate = n_doublets / len(adata)55 56    quality_metrics = {57        'doublet_detection_available': True,58        'n_predicted_doublets': int(n_doublets),59        'doublet_rate': float(doublet_rate),60        'mean_doublet_score': float(doublet_scores.mean()),61        'max_doublet_score': float(doublet_scores.max())62    }63 64    print(f"Doublet detection: {n_doublets}/{len(adata)} spots ({doublet_rate:.1%})")65 66    return adata, quality_metrics67 68 69def assess_annotation_confidence(adata, confidence_key='conf_score'):70    """71    Assess cell type annotation confidence and flag low-quality annotations.72 73    Returns:74        annotation_quality: Dict with confidence statistics75    """76    if confidence_key not in adata.obs.columns:77        return {78            'annotation_confidence_available': False,79            'mean_confidence': np.nan,80            'low_confidence_rate': np.nan81        }82 83    confidence = adata.obs[confidence_key]84    low_conf_threshold = 0.585 86    annotation_quality = {87        'annotation_confidence_available': True,88        'mean_confidence': float(confidence.mean()),89        'median_confidence': float(confidence.median()),90        'min_confidence': float(confidence.min()),91        'low_confidence_rate': float((confidence < low_conf_threshold).mean()),92        'n_low_confidence': int((confidence < low_conf_threshold).sum())93    }94 95    print(f"Annotation confidence: mean={annotation_quality['mean_confidence']:.3f}, "96          f"low_conf_rate={annotation_quality['low_confidence_rate']:.1%}")97 98    return annotation_quality99 100 101# ==============================================================================102# STAGE 1: UNCERTAINTY-AWARE SPATIAL STATISTICS103# ==============================================================================104 105def compute_morans_i_with_uncertainty(adata, n_genes=100, n_permutations=999):106    """107    Compute Moran's I with permutation-based p-values and confidence intervals.108 109    Returns:110        results: Dict with genes, Moran's I values, p-values, CIs, and signal strength111    """112    from sklearn.preprocessing import StandardScaler113 114    if 'spatial_connectivities' not in adata.obsp:115        print("ERROR: Spatial neighbors not computed. Run sq.gr.spatial_neighbors first.")116        return None117 118    W = adata.obsp['spatial_connectivities']119 120    # Select highly variable genes121    if 'highly_variable' not in adata.var.columns:122        sc.pp.highly_variable_genes(adata, n_top_genes=n_genes, flavor='seurat_v3')123 124    hvg = adata.var_names[adata.var['highly_variable']][:n_genes]125 126    results = {127        'genes': [],128        'morans_i': [],129        'p_value': [],130        'ci_lower': [],131        'ci_upper': [],132        'signal_strength': []133    }134 135    print(f"Computing Moran's I with {n_permutations} permutations for {len(hvg)} genes...")136 137    for gene in hvg:138        X = adata[:, gene].X.toarray().flatten()139 140        # Compute observed Moran's I141        X_std = StandardScaler().fit_transform(X.reshape(-1, 1)).flatten()142        n = len(X_std)143 144        numerator = (W.multiply(np.outer(X_std, X_std))).sum()145        denominator = (X_std ** 2).sum()146 147        I_obs = (n / W.sum()) * (numerator / denominator)148 149        # Permutation test150        I_perm = []151        for _ in range(n_permutations):152            X_perm = np.random.permutation(X_std)153            numerator_perm = (W.multiply(np.outer(X_perm, X_perm))).sum()154            I_perm.append((n / W.sum()) * (numerator_perm / denominator))155 156        I_perm = np.array(I_perm)157 158        # P-value (two-tailed)159        p_val = ((np.abs(I_perm) >= np.abs(I_obs)).sum() + 1) / (n_permutations + 1)160 161        # 95% Confidence interval from permutation distribution162        ci_lower, ci_upper = np.percentile(I_perm, [2.5, 97.5])163 164        # Signal strength classification165        if p_val < 0.001 and np.abs(I_obs) > 0.3:166            signal = "STRONG"167        elif p_val < 0.05 and np.abs(I_obs) > 0.1:168            signal = "MODERATE"169        elif p_val < 0.05:170            signal = "WEAK"171        else:172            signal = "NONE"173 174        results['genes'].append(gene)175        results['morans_i'].append(float(I_obs))176        results['p_value'].append(float(p_val))177        results['ci_lower'].append(float(ci_lower))178        results['ci_upper'].append(float(ci_upper))179        results['signal_strength'].append(signal)180 181    # Sort by signal strength and p-value182    df = pd.DataFrame(results)183    df = df.sort_values(['p_value', 'morans_i'], ascending=[True, False])184 185    return df.to_dict('list')186 187 188def compute_multiscale_neighborhood_enrichment(adata, radii=[1, 2, 3], n_permutations=999):189    """190    Compute neighborhood enrichment at multiple spatial scales.191 192    Returns:193        multiscale_results: Dict with enrichment at each radius + scale stability flags194    """195    if 'cell_type' not in adata.obs.columns:196        print("ERROR: Cell types not annotated. Run CellTypist first.")197        return None198 199    try:200        import squidpy as sq201        squidpy_available = True202    except ImportError:203        print("WARNING: Squidpy not available. Using simplified scanpy-based enrichment.")204        squidpy_available = False205 206    if not squidpy_available:207        # Simplified enrichment using existing spatial graph208        return {209            'radii': radii,210            'enrichment_by_radius': {},211            'scale_stable_pairs': [],212            'scale_dependent_pairs': [],213            'note': 'Squidpy not available - using single-scale analysis'214        }215 216    multiscale_results = {217        'radii': radii,218        'enrichment_by_radius': {},219        'scale_stable_pairs': [],220        'scale_dependent_pairs': []221    }222 223    print(f"Computing neighborhood enrichment at {len(radii)} spatial scales...")224 225    all_z_scores = {}226 227    for radius in radii:228        print(f"  Radius={radius}...")229 230        # Rebuild spatial graph with current radius231        sq.gr.spatial_neighbors(adata, coord_type='generic', n_neighs=radius*6, radius=radius*55)232 233        # Compute neighborhood enrichment234        sq.gr.nhood_enrichment(adata, cluster_key='cell_type', n_perms=n_permutations)235 236        z_scores = adata.uns['cell_type_nhood_enrichment']['zscore']237        cell_types = adata.obs['cell_type'].cat.categories.tolist()238 239        all_z_scores[radius] = z_scores240 241        # Extract significant enrichments242        enrichment_pairs = []243        for i, ct1 in enumerate(cell_types):244            for j, ct2 in enumerate(cell_types):245                z = z_scores[i, j]246                if np.abs(z) > 2.0:  # |z| > 2 is significant247                    enrichment_pairs.append({248                        'cell_type_1': ct1,249                        'cell_type_2': ct2,250                        'z_score': float(z),251                        'enriched': z > 0252                    })253 254        multiscale_results['enrichment_by_radius'][f'radius_{radius}'] = enrichment_pairs255 256    # Assess scale stability257    if len(radii) >= 2:258        z1 = all_z_scores[radii[0]]259        z2 = all_z_scores[radii[-1]]260 261        correlation = np.corrcoef(z1.flatten(), z2.flatten())[0, 1]262 263        cell_types = adata.obs['cell_type'].cat.categories.tolist()264 265        for i, ct1 in enumerate(cell_types):266            for j, ct2 in enumerate(cell_types):267                # Check if sign and magnitude consistent across scales268                z_small = z1[i, j]269                z_large = z2[i, j]270 271                if np.sign(z_small) == np.sign(z_large) and np.abs(z_small) > 2 and np.abs(z_large) > 2:272                    multiscale_results['scale_stable_pairs'].append(f"{ct1}-{ct2}")273                elif np.abs(z_small) > 2 or np.abs(z_large) > 2:274                    multiscale_results['scale_dependent_pairs'].append(f"{ct1}-{ct2}")275 276        multiscale_results['scale_stability_correlation'] = float(correlation)277 278        print(f"  Scale stability: {len(multiscale_results['scale_stable_pairs'])} stable pairs, "279              f"{len(multiscale_results['scale_dependent_pairs'])} scale-dependent pairs")280 281    return multiscale_results282 283 284def compute_spatial_entropy_with_bootstrapping(adata, n_bootstrap=1000):285    """286    Compute spatial entropy with bootstrap confidence intervals.287 288    Returns:289        entropy_results: Dict with entropy values and uncertainty estimates290    """291    if 'cell_type' not in adata.obs.columns:292        print("ERROR: Cell types not annotated.")293        return None294 295    from scipy.stats import entropy296 297    # Compute entropy for each spot's neighborhood298    spatial_entropy = []299 300    for spot_idx in range(len(adata)):301        # Get neighbors302        neighbors = adata.obsp['spatial_connectivities'][spot_idx].toarray().flatten()303        neighbor_indices = np.where(neighbors > 0)[0]304 305        if len(neighbor_indices) == 0:306            spatial_entropy.append(0.0)307            continue308 309        # Get cell type distribution in neighborhood310        neighbor_types = adata.obs['cell_type'].iloc[neighbor_indices]311        type_counts = neighbor_types.value_counts(normalize=True)312 313        # Shannon entropy314        H = entropy(type_counts.values)315        spatial_entropy.append(H)316 317    spatial_entropy = np.array(spatial_entropy)318    adata.obs['spatial_entropy'] = spatial_entropy319 320    # Bootstrap confidence intervals321    bootstrap_means = []322    n_spots = len(adata)323 324    for _ in range(n_bootstrap):325        sample_indices = np.random.choice(n_spots, size=n_spots, replace=True)326        bootstrap_means.append(spatial_entropy[sample_indices].mean())327 328    ci_lower, ci_upper = np.percentile(bootstrap_means, [2.5, 97.5])329 330    entropy_results = {331        'mean_entropy': float(spatial_entropy.mean()),332        'median_entropy': float(np.median(spatial_entropy)),333        'std_entropy': float(spatial_entropy.std()),334        'ci_lower': float(ci_lower),335        'ci_upper': float(ci_upper),336        'bootstrap_samples': n_bootstrap337    }338 339    print(f"Spatial entropy: {entropy_results['mean_entropy']:.3f} "340          f"(95% CI: [{ci_lower:.3f}, {ci_upper:.3f}])")341 342    return entropy_results343 344 345# ==============================================================================346# STAGE STOPPING LOGIC347# ==============================================================================348 349def assess_signal_quality(morans_results, entropy_results, annotation_quality):350    """351    Determine if signal quality is sufficient to proceed to comparative analysis.352 353    Returns:354        decision: "PROCEED", "STOP_WEAK_SIGNAL", or "STOP_LOW_QUALITY"355        rationale: Explanation for decision356    """357    stop_conditions = []358 359    # Check 1: Annotation quality360    if annotation_quality.get('annotation_confidence_available'):361        if annotation_quality['mean_confidence'] < 0.4:362            stop_conditions.append(f"Low annotation confidence (mean={annotation_quality['mean_confidence']:.2f} < 0.4)")363        if annotation_quality['low_confidence_rate'] > 0.5:364            stop_conditions.append(f"High low-confidence rate ({annotation_quality['low_confidence_rate']:.1%} > 50%)")365 366    # Check 2: Spatial signal strength367    if morans_results:368        strong_signals = sum(1 for s in morans_results['signal_strength'] if s == "STRONG")369        moderate_signals = sum(1 for s in morans_results['signal_strength'] if s == "MODERATE")370 371        if strong_signals == 0 and moderate_signals < 3:372            stop_conditions.append(f"Weak spatial signal (0 STRONG, {moderate_signals} MODERATE genes)")373 374    # Check 3: Spatial heterogeneity375    if entropy_results:376        if entropy_results['mean_entropy'] < 0.2:377            stop_conditions.append(f"Low spatial heterogeneity (entropy={entropy_results['mean_entropy']:.2f} < 0.2)")378 379    # Decision logic380    if len(stop_conditions) >= 2:381        return "STOP_WEAK_SIGNAL", "; ".join(stop_conditions)382    elif len(stop_conditions) == 1 and "Low annotation confidence" in stop_conditions[0]:383        return "STOP_LOW_QUALITY", stop_conditions[0]384    else:385        return "PROCEED", "Signal quality sufficient for comparative analysis"386 387 388# ==============================================================================389# MAIN PIPELINE390# ==============================================================================391 392def run_uncertainty_aware_spatial_analysis(adata_path, output_path):393    """394    Execute full uncertainty-aware spatial analysis pipeline.395    """396    print("=" * 80)397    print("UNCERTAINTY-AWARE SPATIAL ANALYSIS PIPELINE")398    print("=" * 80)399 400    # Load data401    print("\n[1/6] Loading data...")402    adata = sc.read_h5ad(adata_path)403    print(f"  Loaded: {adata.n_obs} spots, {adata.n_vars} genes")404 405    # Stage 0: Annotation quality406    print("\n[2/6] STAGE 0: Annotation Quality Assessment")407    adata, doublet_metrics = detect_doublets_scrublet(adata)408    annotation_quality = assess_annotation_confidence(adata)409 410    # Build spatial graph (needed for all subsequent stages)411    print("\n[3/6] Building spatial neighbor graph...")412    try:413        import squidpy as sq414        sq.gr.spatial_neighbors(adata, coord_type='generic', n_neighs=6)415    except ImportError:416        print("  WARNING: Squidpy not available, using scanpy spatial neighbors")417        sc.pp.neighbors(adata, use_rep='spatial')418 419    # Stage 1: Moran's I with uncertainty420    print("\n[4/6] STAGE 1: Spatial Autocorrelation (Moran's I)")421    morans_results = compute_morans_i_with_uncertainty(adata, n_genes=50, n_permutations=999)422 423    # Spatial entropy with uncertainty424    print("\n[5/6] Spatial Entropy (with bootstrap CI)")425    entropy_results = compute_spatial_entropy_with_bootstrapping(adata, n_bootstrap=1000)426 427    # Multi-scale analysis428    print("\n[6/6] Multi-Scale Neighborhood Enrichment")429    multiscale_results = compute_multiscale_neighborhood_enrichment(adata, radii=[1, 2, 3], n_permutations=999)430 431    # Stage stopping decision432    print("\n" + "=" * 80)433    print("STAGE STOPPING ASSESSMENT")434    print("=" * 80)435    decision, rationale = assess_signal_quality(morans_results, entropy_results, annotation_quality)436 437    print(f"\nDecision: {decision}")438    print(f"Rationale: {rationale}")439 440    # Compile results441    results = {442        'stage_0_annotation_quality': {443            'doublet_metrics': doublet_metrics,444            'annotation_confidence': annotation_quality445        },446        'stage_1_spatial_patterns': {447            'morans_i': morans_results,448            'spatial_entropy': entropy_results,449            'multiscale_enrichment': multiscale_results450        },451        'stage_stopping': {452            'decision': decision,453            'rationale': rationale454        }455    }456 457    # Save results458    import json459    with open(output_path, 'w') as f:460        json.dump(results, f, indent=2)461 462    print(f"\nResults saved to: {output_path}")463    print("=" * 80)464 465    return results466 467 468if __name__ == "__main__":469    import sys470 471    if len(sys.argv) < 3:472        print("Usage: python uncertainty_spatial_analysis.py <input.h5ad> <output.json>")473        sys.exit(1)474 475    adata_path = sys.argv[1]476    output_path = sys.argv[2]477 478    run_uncertainty_aware_spatial_analysis(adata_path, output_path)479