From 462fe59c73fb3cfb9de7776be8a1ea1da0f32967 Mon Sep 17 00:00:00 2001 From: Eric Cramer <13970720+emcramer@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:17 -0700 Subject: [PATCH 1/2] chore: make the ruff lint gate pass The lint job has never passed on main: `ruff check .` reported 6,130 errors, and because the test job declares `needs: lint`, no PR has been able to run the test suite. Fix the linter configuration and the code it flags: - Move `select`/`ignore` under `[tool.ruff.lint]`, which ruff has deprecated the top-level form in favour of. - Exclude tutorial notebooks and .ipynb_checkpoints. Notebooks rely on cross-cell state that ruff reads as undefined names; nbsphinx already exercises them at docs build time. - Ignore UP006/UP007/UP045. The package still supports Python 3.8 and rewriting ~950 annotations to PEP 585/604 form is churn with no runtime effect under `from __future__ import annotations`. - Per-file-ignore F401 in __init__.py, where imports are re-exports. Genuine defects the linter was correctly reporting: - `Union` was used in annotations but never imported in seven modules (lda, network, statistics). Deferred annotations kept this latent, but it raises NameError under typing.get_type_hints(), which signature introspection relies on. - `plt` was referenced in module-level annotations in viz/config.py while only imported lazily inside functions; added a TYPE_CHECKING import that keeps matplotlib optional at runtime. - `SpatialLDA`, `MapperResult` and `SpatialTissueData` were used as forward references without TYPE_CHECKING imports. The rest is mechanical: trailing whitespace, import ordering, unused locals, and `== True` to `is True` in assertions. Optional-dependency probes and Moran's I variables carry documented noqa comments. No behaviour change: 456 passed / 2 skipped, unchanged from main, and the public API surface of every module is byte-identical. --- .../11_physicell_integration.marimo.py | 86 ++-- examples/basic_workflow.py | 70 +-- examples/sample_data/generate_sample_data.py | 39 +- pyproject.toml | 16 + spatialtissuepy/core/__init__.py | 2 +- spatialtissuepy/core/cell.py | 5 +- spatialtissuepy/core/spatial_data.py | 22 +- spatialtissuepy/core/validators.py | 47 +- spatialtissuepy/io/__init__.py | 4 +- spatialtissuepy/io/readers.py | 14 +- spatialtissuepy/io/writers.py | 16 +- spatialtissuepy/lda/__init__.py | 53 +- spatialtissuepy/lda/analysis.py | 180 +++---- spatialtissuepy/lda/metrics.py | 190 ++++---- spatialtissuepy/lda/sampling.py | 132 ++--- spatialtissuepy/lda/spatial_lda.py | 235 ++++----- spatialtissuepy/lda/summary_metrics.py | 77 +-- spatialtissuepy/mcp/serialization.py | 29 +- spatialtissuepy/mcp/server.py | 4 +- spatialtissuepy/mcp/session.py | 20 +- spatialtissuepy/mcp/tools/__init__.py | 2 +- spatialtissuepy/mcp/tools/data.py | 8 +- spatialtissuepy/mcp/tools/lda.py | 9 +- spatialtissuepy/mcp/tools/network.py | 15 +- spatialtissuepy/mcp/tools/spatial.py | 16 +- spatialtissuepy/mcp/tools/statistics.py | 25 +- spatialtissuepy/mcp/tools/summary.py | 14 +- spatialtissuepy/mcp/tools/synthetic.py | 31 +- spatialtissuepy/mcp/tools/topology.py | 8 +- spatialtissuepy/mcp/tools/viz.py | 57 ++- spatialtissuepy/network/__init__.py | 103 ++-- spatialtissuepy/network/assortativity.py | 169 +++---- spatialtissuepy/network/cell_graph.py | 135 +++--- spatialtissuepy/network/centrality.py | 128 ++--- spatialtissuepy/network/clustering.py | 135 +++--- spatialtissuepy/network/communicability.py | 159 +++--- spatialtissuepy/network/graph_construction.py | 118 ++--- spatialtissuepy/network/metrics.py | 90 ++-- spatialtissuepy/spatial/__init__.py | 101 ++-- spatialtissuepy/spatial/clustering.py | 172 +++---- spatialtissuepy/spatial/distance.py | 82 ++-- spatialtissuepy/spatial/metrics.py | 49 +- spatialtissuepy/spatial/neighborhood.py | 158 +++--- spatialtissuepy/statistics/__init__.py | 79 ++- spatialtissuepy/statistics/colocalization.py | 218 ++++----- spatialtissuepy/statistics/hotspots.py | 172 +++---- spatialtissuepy/statistics/metrics.py | 103 ++-- spatialtissuepy/statistics/spatial_stats.py | 172 +++---- spatialtissuepy/summary/__init__.py | 46 +- spatialtissuepy/summary/neighborhood.py | 194 ++++---- spatialtissuepy/summary/panel.py | 44 +- spatialtissuepy/summary/population.py | 138 +++--- spatialtissuepy/summary/registry.py | 22 +- spatialtissuepy/summary/spatial.py | 209 ++++---- spatialtissuepy/summary/summary.py | 222 +++++---- spatialtissuepy/synthetic/__init__.py | 12 +- spatialtissuepy/synthetic/base.py | 183 ++++--- .../synthetic/physicell/__init__.py | 29 +- spatialtissuepy/synthetic/physicell/parser.py | 121 ++--- spatialtissuepy/synthetic/physicell/reader.py | 245 +++++----- spatialtissuepy/topology/__init__.py | 57 ++- spatialtissuepy/topology/analysis.py | 227 ++++----- spatialtissuepy/topology/cover.py | 145 +++--- spatialtissuepy/topology/filters.py | 110 ++--- spatialtissuepy/topology/mapper.py | 157 +++--- spatialtissuepy/topology/nerve.py | 121 ++--- spatialtissuepy/topology/spatial_filters.py | 199 ++++---- spatialtissuepy/topology/summary_metrics.py | 55 ++- spatialtissuepy/topology/visualization.py | 10 +- spatialtissuepy/utils/__init__.py | 4 +- spatialtissuepy/utils/metrics.py | 35 +- spatialtissuepy/viz/__init__.py | 101 ++-- spatialtissuepy/viz/comparison.py | 226 ++++----- spatialtissuepy/viz/config.py | 146 +++--- spatialtissuepy/viz/lda.py | 230 ++++----- spatialtissuepy/viz/mapper.py | 201 ++++---- spatialtissuepy/viz/network.py | 207 ++++---- spatialtissuepy/viz/qc.py | 204 ++++---- spatialtissuepy/viz/spatial.py | 274 +++++------ spatialtissuepy/viz/statistics.py | 186 +++---- tests/conftest.py | 57 ++- tests/test_core.py | 14 +- tests/test_core_extended.py | 33 +- tests/test_custom_metrics.py | 26 +- tests/test_io.py | 194 ++++---- tests/test_lda.py | 315 ++++++------ tests/test_network.py | 459 +++++++++--------- tests/test_physicell_io.py | 303 ++++++------ tests/test_spatial.py | 327 +++++++------ tests/test_statistics.py | 394 ++++++++------- tests/test_summary.py | 131 +++-- tests/test_synthetic.py | 39 +- tests/test_topology.py | 98 ++-- tests/test_viz.py | 42 +- 94 files changed, 5344 insertions(+), 5217 deletions(-) diff --git a/docs/tutorials/11_physicell_integration.marimo.py b/docs/tutorials/11_physicell_integration.marimo.py index 4498ece..77809a0 100644 --- a/docs/tutorials/11_physicell_integration.marimo.py +++ b/docs/tutorials/11_physicell_integration.marimo.py @@ -60,22 +60,16 @@ def _(): @app.cell def _(): + from pathlib import Path + + import matplotlib.pyplot as plt import numpy as np import pandas as pd - import matplotlib.pyplot as plt - from pathlib import Path - from spatialtissuepy import SpatialTissueData + from spatialtissuepy.summary import SpatialSummary, StatisticsPanel from spatialtissuepy.synthetic.physicell import ( PhysiCellSimulation, - PhysiCellTimeStep, - read_physicell_timestep, - discover_physicell_timesteps, - is_alive, - is_dead, ) - from spatialtissuepy.summary import StatisticsPanel, SpatialSummary - from spatialtissuepy.viz import plot_spatial_scatter np.random.seed(42) return ( @@ -149,7 +143,7 @@ def _(Path, PhysiCellSimulation): print(f"Number of timesteps: {sim.n_timesteps}") print(f"Time range: {sim.times[0]:.0f} - {sim.times[-1]:.0f} minutes") print(f"Time range: {sim.times[0]/60:.1f} - {sim.times[-1]/60:.1f} hours") - print(f"\nCell type mapping:") + print("\nCell type mapping:") for cell_id, cell_name in sim.cell_type_mapping.items(): print(f" {cell_id}: {cell_name}") return (sim,) @@ -177,7 +171,7 @@ def _(sim): def _(sim): # Load the first timestep ts_initial = sim.get_timestep(0) - print(f"Initial timestep:") + print("Initial timestep:") print(f" Time: {ts_initial.time:.0f} min ({ts_initial.time/60:.1f} hours)") print(f" Live cells: {ts_initial.n_cells}") print(f" Dead cells: {ts_initial.n_dead_cells}") @@ -192,7 +186,7 @@ def _(sim): # Load the final timestep ts_final = sim.get_timestep(sim.n_timesteps-1) - print(f"\nFinal timestep:") + print("\nFinal timestep:") print(f" Time: {ts_final.time:.0f} min ({ts_final.time/60:.1f} hours)") print(f" Live cells: {ts_final.n_cells}") print(f" Dead cells: {ts_final.n_dead_cells}") @@ -216,7 +210,7 @@ def _(ts_final): # Convert to SpatialTissueData spatial_data = ts_final.to_spatial_data() - print(f"SpatialTissueData object:") + print("SpatialTissueData object:") print(f" Coordinates shape: {spatial_data.coordinates.shape}") print(f" Cell types: {spatial_data.cell_types_unique}") print(f" Has markers: {spatial_data.markers is not None}") @@ -333,7 +327,7 @@ def _(): time_hours = counts_df['time'] / 60 # Plot tumor cells - ax1.plot(time_hours, counts_df['n_malignant_epithelial_cell'], + ax1.plot(time_hours, counts_df['n_malignant_epithelial_cell'], color=cell_colors['malignant_epithelial_cell'], linewidth=2, label='Tumor') ax1.set_xlabel('Time (hours)') ax1.set_ylabel('Cell Count') @@ -352,7 +346,7 @@ def _(): for col, ct, label in immune_types: if col in counts_df.columns: - ax2.plot(time_hours, counts_df[col], + ax2.plot(time_hours, counts_df[col], color=cell_colors[ct], linewidth=2, label=label) ax2.set_xlabel('Time (hours)') @@ -374,31 +368,31 @@ def _(): # Plot macrophage polarization ratio (M1 vs M2) with plt.style.context('seaborn-v0_8'): fig2, ax3 = plt.subplots(figsize=(10, 5)) - + time_h = counts_df['time'] / 60 m1_counts = counts_df['n_M1_macrophage'].values m2_counts = counts_df['n_M2_macrophage'].values - + # Avoid division by zero total_polarized = m1_counts + m2_counts m1_ratio = m1_counts / (total_polarized + 1e-6) # M1 / (M1 + M2) - + ax3.plot(time_h, m1_ratio, 'g-', linewidth=2, label='M1 Ratio') ax3.axhline(0.5, color='gray', linestyle='--', alpha=0.5, label='Equal M1/M2') - ax3.fill_between(time_h, m1_ratio, 0.5, - where=(m1_ratio > 0.5), alpha=0.3, color='green', + ax3.fill_between(time_h, m1_ratio, 0.5, + where=(m1_ratio > 0.5), alpha=0.3, color='green', label='Pro-inflammatory') - ax3.fill_between(time_h, m1_ratio, 0.5, + ax3.fill_between(time_h, m1_ratio, 0.5, where=(m1_ratio < 0.5), alpha=0.3, color='purple', label='Anti-inflammatory') - + ax3.set_xlabel('Time (hours)') ax3.set_ylabel('M1 / (M1 + M2) Ratio') ax3.set_title('Macrophage Polarization Over Time') ax3.set_ylim(0, 1) ax3.legend(loc='upper right') ax3.grid(alpha=0.3) - + plt.tight_layout() plt.show() plt.close() @@ -429,32 +423,32 @@ def _(SpatialSummary, StatisticsPanel, np, pd, sim): sample_hours = np.arange(0, 120, 10) sample_indices = [] - def _(): + def _(): for target_hours in sample_hours: target_min = target_hours * 60 idx = np.argmin(np.abs(sim.times - target_min)) if idx not in sample_indices: # Avoid duplicates sample_indices.append(idx) - + print(f"Computing spatial statistics for {len(sample_indices)} timesteps...") - + # Compute statistics over time metrics_over_time = [] - + for i, idx in enumerate(sample_indices): ts = sim.get_timestep(idx) tissue = ts.to_spatial_data() - + summary = SpatialSummary(tissue, panel) results = summary.to_dict() results['time'] = ts.time results['time_hours'] = ts.time / 60 results['time_index'] = ts.time_index metrics_over_time.append(results) - + if (i + 1) % 5 == 0: print(f" Processed {i + 1}/{len(sample_indices)} timesteps") - + metrics_df = pd.DataFrame(metrics_over_time) print("\nTracked metrics:") print(metrics_df.columns.tolist()) @@ -471,25 +465,25 @@ def _(): with plt.style.context('seaborn-v0_8'): # Plot spatial metrics over time fig3, axes3 = plt.subplots(1, 2, figsize=(12, 5)) - + # Mean nearest neighbor distance ax_nn = axes3[0] - ax_nn.plot(metrics_df['time_hours'], metrics_df['mean_nnd'], + ax_nn.plot(metrics_df['time_hours'], metrics_df['mean_nnd'], 'b-o', linewidth=2, markersize=4) ax_nn.set_xlabel('Time (hours)') ax_nn.set_ylabel('Mean NN Distance (μm)') ax_nn.set_title('Cell Density Over Time\n(Lower = More Dense)') ax_nn.grid(alpha=0.3) - + # Total cell count trend ax_total = axes3[1] - ax_total.plot(metrics_df['time_hours'], metrics_df['n_cells'], + ax_total.plot(metrics_df['time_hours'], metrics_df['n_cells'], 'g-o', linewidth=2, markersize=4) ax_total.set_xlabel('Time (hours)') ax_total.set_ylabel('Total Live Cells') ax_total.set_title('Total Cell Population') ax_total.grid(alpha=0.3) - + plt.tight_layout() plt.show() plt.close() @@ -541,11 +535,11 @@ def compute_mean_distance_to_tumor(tissue): def _(): # Compute tumor proximity over time tumor_proximity = [] - + for idx in sample_indices: ts = sim.get_timestep(idx) tissue = ts.to_spatial_data() - + row = { 'time_hours': ts.time / 60, 'n_tumor': np.sum(tissue.cell_types == 'malignant_epithelial_cell') @@ -564,22 +558,22 @@ def _(cell_colors, pd, plt, tumor_proximity): def _(): with plt.style.context('seaborn-v0_8'): fig4, ax4 = plt.subplots(figsize=(12, 6)) - + immune_proximity_cols = [c for c in proximity_df.columns if '_to_tumor' in c] - + for col in immune_proximity_cols: cell_type = col.replace('_to_tumor', '') color = cell_colors.get(cell_type, '#999999') label = cell_type.replace('_macrophage', ' Mac').replace('_cell', '') - ax4.plot(proximity_df['time_hours'], proximity_df[col], + ax4.plot(proximity_df['time_hours'], proximity_df[col], color=color, linewidth=2, marker='o', markersize=4, label=label) - + ax4.set_xlabel('Time (hours)') ax4.set_ylabel('Mean Distance to Nearest Tumor Cell (μm)') ax4.set_title('Immune Cell Proximity to Tumor Over Time') ax4.legend(loc='upper right') ax4.grid(alpha=0.3) - + plt.tight_layout() plt.show() plt.close() @@ -617,16 +611,16 @@ def _(): idx = np.argmin(np.abs(sim.times - target_min)) ts = sim.get_timestep(idx) tissue = ts.to_spatial_data() - + summary = SpatialSummary(tissue, full_panel) results = summary.to_dict() results['time_hours'] = ts.time / 60 results['timepoint'] = f"t={target_hours}h" key_results.append(results) - + key_df = pd.DataFrame(key_results) key_df = key_df.set_index('timepoint') - + # Display key metrics display_cols = ['time_hours', 'n_cells', 'mean_nn_distance']#, 'shannon_entropy'] display_cols = [c for c in display_cols if c in key_df.columns] diff --git a/examples/basic_workflow.py b/examples/basic_workflow.py index af9d540..11d3fef 100644 --- a/examples/basic_workflow.py +++ b/examples/basic_workflow.py @@ -4,41 +4,41 @@ This script demonstrates the core functionality of the package. """ +# Add parent directory to path for local development +import sys +from pathlib import Path + import numpy as np import pandas as pd -from pathlib import Path -# Add parent directory to path for local development -import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from spatialtissuepy import SpatialTissueData -from spatialtissuepy.core.cell import Cell def main(): print("=" * 60) print("spatialtissuepy - Basic Workflow Example") print("=" * 60) - + # ------------------------------------------------------------------------- # 1. Create data from scratch # ------------------------------------------------------------------------- print("\n1. Creating SpatialTissueData from arrays...") - + np.random.seed(42) n_cells = 500 - + # Generate random coordinates coords = np.random.rand(n_cells, 2) * 1000 # 1000x1000 µm FOV - + # Generate cell types cell_types = np.random.choice( ['Tumor', 'T_cell', 'Macrophage', 'Stromal', 'Endothelial'], n_cells, p=[0.4, 0.2, 0.15, 0.15, 0.1] # Tumor-dominated ) - + # Generate marker expression markers = pd.DataFrame({ 'CD3': np.random.rand(n_cells), @@ -46,7 +46,7 @@ def main(): 'CD68': np.random.rand(n_cells), 'PanCK': np.random.rand(n_cells), }) - + # Create SpatialTissueData object data = SpatialTissueData( coordinates=coords, @@ -54,104 +54,104 @@ def main(): markers=markers, metadata={'tissue': 'synthetic', 'experiment': 'demo'} ) - + print(f" Created: {data}") print(f" Cell type counts:\n{data.cell_type_counts}") - + # ------------------------------------------------------------------------- # 2. Explore the data # ------------------------------------------------------------------------- print("\n2. Exploring the data...") - + print(f" Number of cells: {data.n_cells}") print(f" Number of dimensions: {data.n_dims}") print(f" Number of cell types: {data.n_cell_types}") print(f" Spatial bounds: {data.bounds}") print(f" Spatial extent: {data.extent}") print(f" Marker names: {data.marker_names}") - + # ------------------------------------------------------------------------- # 3. Access individual cells # ------------------------------------------------------------------------- print("\n3. Accessing individual cells...") - + cell = data.get_cell(0) print(f" First cell: {cell}") print(f" Coordinates: {cell.coordinates}") print(f" CD3 expression: {cell.get_marker('CD3'):.3f}") - + # ------------------------------------------------------------------------- # 4. Spatial queries # ------------------------------------------------------------------------- print("\n4. Performing spatial queries...") - + # Find cells near center of FOV center = np.array([500, 500]) - + # Radius query nearby_indices = data.query_radius(center, radius=100) print(f" Cells within 100µm of center: {len(nearby_indices)}") - + # KNN query distances, knn_indices = data.query_knn(center, k=10) - print(f" 10 nearest neighbors to center:") + print(" 10 nearest neighbors to center:") print(f" - Distances: {distances[:5].round(2)}...") print(f" - Indices: {knn_indices[:5]}...") - + # ------------------------------------------------------------------------- # 5. Subset data # ------------------------------------------------------------------------- print("\n5. Subsetting data...") - + # By cell type t_cells = data.subset(cell_types=['T_cell']) print(f" T cells only: {t_cells.n_cells} cells") - + # By multiple types immune_cells = data.subset(cell_types=['T_cell', 'Macrophage']) print(f" Immune cells: {immune_cells.n_cells} cells") - + # By indices subset_indices = data.get_cells_by_type('Tumor')[:50] tumor_subset = data.subset(indices=subset_indices) print(f" First 50 tumor cells: {tumor_subset.n_cells} cells") - + # ------------------------------------------------------------------------- # 6. Iterate over cells # ------------------------------------------------------------------------- print("\n6. Iterating over cells...") - + tumor_count = 0 total_cd3 = 0 for cell in data.iter_cells(): if cell.cell_type == 'Tumor': tumor_count += 1 total_cd3 += cell.get_marker('CD3', default=0) - + print(f" Tumor cells counted: {tumor_count}") print(f" Mean CD3 expression: {total_cd3 / data.n_cells:.3f}") - + # ------------------------------------------------------------------------- # 7. Export data # ------------------------------------------------------------------------- print("\n7. Exporting data...") - + # To DataFrame df = data.to_dataframe() print(f" DataFrame shape: {df.shape}") print(f" DataFrame columns: {list(df.columns)}") - + # To CSV (in a temp location for demo) import tempfile with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f: data.to_csv(f.name) print(f" Saved to: {f.name}") - + # ------------------------------------------------------------------------- # 8. Multi-sample data # ------------------------------------------------------------------------- print("\n8. Working with multi-sample data...") - + # Create multi-sample data sample_ids = np.array(['sample_A'] * 250 + ['sample_B'] * 250) multi_data = SpatialTissueData( @@ -160,17 +160,17 @@ def main(): sample_ids=sample_ids, markers=markers ) - + print(f" Multi-sample data: {multi_data}") print(f" Is multi-sample: {multi_data.is_multisample}") print(f" Number of samples: {multi_data.n_samples}") print(f" Sample IDs: {multi_data.sample_ids_unique}") - + # Iterate over samples for sample_id, sample_data in multi_data.iter_samples(): print(f" {sample_id}: {sample_data.n_cells} cells, " f"{sample_data.n_cell_types} types") - + print("\n" + "=" * 60) print("Example completed successfully!") print("=" * 60) diff --git a/examples/sample_data/generate_sample_data.py b/examples/sample_data/generate_sample_data.py index c3ed593..fb3c707 100644 --- a/examples/sample_data/generate_sample_data.py +++ b/examples/sample_data/generate_sample_data.py @@ -2,9 +2,10 @@ Generate sample spatial tissue data for testing and examples. """ +from pathlib import Path + import numpy as np import pandas as pd -from pathlib import Path def generate_clustered_tissue( @@ -16,7 +17,7 @@ def generate_clustered_tissue( ) -> pd.DataFrame: """ Generate synthetic clustered tissue data. - + Parameters ---------- n_cells : int @@ -29,7 +30,7 @@ def generate_clustered_tissue( List of cell types. If None, uses default. seed : int Random seed. - + Returns ------- pd.DataFrame @@ -37,29 +38,29 @@ def generate_clustered_tissue( """ if cell_types is None: cell_types = ['Tumor', 'T_cell', 'Macrophage', 'Stromal', 'Endothelial'] - + np.random.seed(seed) - + # Generate cluster centers cluster_centers = np.random.rand(n_clusters, 2) * fov_size cluster_sizes = np.random.dirichlet(np.ones(n_clusters)) * n_cells cluster_sizes = cluster_sizes.astype(int) cluster_sizes[-1] = n_cells - cluster_sizes[:-1].sum() # Ensure total is correct - + # Generate cells around clusters all_coords = [] all_types = [] - + for i, (center, size) in enumerate(zip(cluster_centers, cluster_sizes)): # Each cluster has a dominant cell type dominant_type = cell_types[i % len(cell_types)] - + # Cluster spread spread = fov_size * 0.1 # 10% of FOV - + coords = np.random.randn(size, 2) * spread + center coords = np.clip(coords, 0, fov_size) - + # Assign cell types (80% dominant, 20% random) types = [] for _ in range(size): @@ -67,12 +68,12 @@ def generate_clustered_tissue( types.append(dominant_type) else: types.append(np.random.choice(cell_types)) - + all_coords.append(coords) all_types.extend(types) - + coords = np.vstack(all_coords) - + return pd.DataFrame({ 'x': coords[:, 0], 'y': coords[:, 1], @@ -87,7 +88,7 @@ def generate_multisample_data( ) -> pd.DataFrame: """ Generate multi-sample spatial data. - + Parameters ---------- n_samples : int @@ -96,14 +97,14 @@ def generate_multisample_data( Cells per sample. seed : int Random seed. - + Returns ------- pd.DataFrame DataFrame with x, y, cell_type, sample_id columns. """ np.random.seed(seed) - + all_dfs = [] for i in range(n_samples): df = generate_clustered_tissue( @@ -112,19 +113,19 @@ def generate_multisample_data( ) df['sample_id'] = f'sample_{i+1:02d}' all_dfs.append(df) - + return pd.concat(all_dfs, ignore_index=True) if __name__ == '__main__': # Generate and save sample data output_dir = Path(__file__).parent - + # Single sample single = generate_clustered_tissue(n_cells=1000, seed=42) single.to_csv(output_dir / 'sample_tissue.csv', index=False) print(f"Generated sample_tissue.csv with {len(single)} cells") - + # Multi-sample multi = generate_multisample_data(n_samples=3, cells_per_sample=500, seed=42) multi.to_csv(output_dir / 'multi_sample_tissue.csv', index=False) diff --git a/pyproject.toml b/pyproject.toml index eaa7643..43582cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,11 @@ include = '\.pyi?$' [tool.ruff] line-length = 88 target-version = "py38" +# Tutorial notebooks are prose-first and rely on cross-cell state that ruff +# reads as undefined names. They are exercised by nbsphinx at docs build time. +extend-exclude = ["docs/tutorials/*.ipynb", "*/.ipynb_checkpoints/*"] + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -122,8 +127,19 @@ select = [ ] ignore = [ "E501", # line too long (handled by black) + # Annotation-style modernisations. The package still supports Python 3.8, + # and rewriting ~950 annotations to PEP 585/604 form is churn with no + # runtime effect under `from __future__ import annotations`. Revisit when + # the 3.8 floor is dropped. + "UP006", # non-pep585-annotation (Dict -> dict) + "UP007", # non-pep604-annotation-union (Union[x, y] -> x | y) + "UP045", # non-pep604-annotation-optional (Optional[x] -> x | None) ] +[tool.ruff.lint.per-file-ignores] +# Package __init__ files re-export their public API; the imports are the point. +"__init__.py" = ["F401"] + [tool.mypy] python_version = "3.8" warn_return_any = true diff --git a/spatialtissuepy/core/__init__.py b/spatialtissuepy/core/__init__.py index ce359db..ca418ba 100644 --- a/spatialtissuepy/core/__init__.py +++ b/spatialtissuepy/core/__init__.py @@ -7,7 +7,7 @@ Cell : Lightweight cell representation """ -from spatialtissuepy.core.spatial_data import SpatialTissueData from spatialtissuepy.core.cell import Cell +from spatialtissuepy.core.spatial_data import SpatialTissueData __all__ = ["SpatialTissueData", "Cell"] diff --git a/spatialtissuepy/core/cell.py b/spatialtissuepy/core/cell.py index ce46711..1d8e9d8 100644 --- a/spatialtissuepy/core/cell.py +++ b/spatialtissuepy/core/cell.py @@ -5,7 +5,8 @@ without the overhead of the full SpatialTissueData container. """ -from typing import Optional, Dict, Any, List +from typing import Any, Dict, List, Optional + import numpy as np @@ -132,7 +133,7 @@ def distance_to(self, other: 'Cell') -> float: """ if self.ndim != other.ndim: raise ValueError( - f"Cannot compute distance between cells with different dimensionality" + "Cannot compute distance between cells with different dimensionality" ) return float(np.linalg.norm(self.coordinates - other.coordinates)) diff --git a/spatialtissuepy/core/spatial_data.py b/spatialtissuepy/core/spatial_data.py index 50411f3..2eb3765 100644 --- a/spatialtissuepy/core/spatial_data.py +++ b/spatialtissuepy/core/spatial_data.py @@ -6,8 +6,10 @@ """ from __future__ import annotations -from typing import Optional, Dict, Any, List, Union, Iterator, Tuple + from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union + import numpy as np import pandas as pd from scipy.spatial import cKDTree @@ -15,12 +17,12 @@ from spatialtissuepy.core.cell import Cell from spatialtissuepy.core.validators import ( ValidationError, - validate_coordinates, validate_cell_types, - validate_sample_ids, + validate_coordinates, validate_marker_data, validate_metadata, validate_positive_number, + validate_sample_ids, ) @@ -246,7 +248,7 @@ def from_csv( sample_col: Optional[str] = None, marker_cols: Optional[List[str]] = None, **read_csv_kwargs - ) -> 'SpatialTissueData': + ) -> SpatialTissueData: """ Load spatial data from a CSV file. @@ -348,7 +350,7 @@ def from_dataframe( celltype_col: str = 'cell_type', sample_col: Optional[str] = None, marker_cols: Optional[List[str]] = None - ) -> 'SpatialTissueData': + ) -> SpatialTissueData: """ Create SpatialTissueData from a pandas DataFrame. @@ -468,7 +470,7 @@ def subset( indices: Optional[np.ndarray] = None, cell_types: Optional[List[str]] = None, sample_ids: Optional[List[str]] = None - ) -> 'SpatialTissueData': + ) -> SpatialTissueData: """ Create a subset of the data. @@ -511,7 +513,7 @@ def subset( metadata=self._metadata.copy() ) - def subset_sample(self, sample_id: str) -> 'SpatialTissueData': + def subset_sample(self, sample_id: str) -> SpatialTissueData: """ Alias for subset(sample_ids=[sample_id]) for backward compatibility. """ @@ -529,7 +531,7 @@ def iter_cells(self) -> Iterator[Cell]: for i in range(self.n_cells): yield self.get_cell(i) - def iter_samples(self) -> Iterator[Tuple[str, 'SpatialTissueData']]: + def iter_samples(self) -> Iterator[Tuple[str, SpatialTissueData]]: """ Iterate over samples in a multi-sample dataset. @@ -653,7 +655,7 @@ def add_neighborhoods( self, neighborhoods: np.ndarray, params: Optional[Dict] = None - ) -> 'SpatialTissueData': + ) -> SpatialTissueData: """ Add precomputed neighborhood data. @@ -714,7 +716,7 @@ def __repr__(self) -> str: def __str__(self) -> str: lines = [ - f"SpatialTissueData", + "SpatialTissueData", f" Cells: {self.n_cells}", f" Dimensions: {self.n_dims}D", f" Cell types: {self.n_cell_types}", diff --git a/spatialtissuepy/core/validators.py b/spatialtissuepy/core/validators.py index 6f5c0cd..f09d936 100644 --- a/spatialtissuepy/core/validators.py +++ b/spatialtissuepy/core/validators.py @@ -5,6 +5,7 @@ """ from typing import Optional, Sequence, Union + import numpy as np import pandas as pd @@ -42,15 +43,15 @@ def validate_coordinates( If coordinates are invalid. """ coords = np.asarray(coords, dtype=np.float64) - + if coords.ndim != 2: raise ValidationError( f"Coordinates must be 2D array, got {coords.ndim}D" ) - + if coords.shape[0] == 0: raise ValidationError("Coordinates array is empty") - + if ndim is not None: if coords.shape[1] != ndim: raise ValidationError( @@ -60,16 +61,16 @@ def validate_coordinates( raise ValidationError( f"Coordinates must be 2D or 3D, got {coords.shape[1]}D" ) - + if not allow_nan and np.any(np.isnan(coords)): nan_count = np.sum(np.any(np.isnan(coords), axis=1)) raise ValidationError( f"Coordinates contain {nan_count} rows with NaN values" ) - + if np.any(np.isinf(coords)): raise ValidationError("Coordinates contain infinite values") - + return coords @@ -98,18 +99,18 @@ def validate_cell_types( If cell types are invalid. """ cell_types = np.asarray(cell_types, dtype=str) - + if cell_types.ndim != 1: raise ValidationError( f"Cell types must be 1D array, got {cell_types.ndim}D" ) - + if len(cell_types) != n_cells: raise ValidationError( f"Cell types length ({len(cell_types)}) does not match " f"number of cells ({n_cells})" ) - + # Check for empty strings empty_mask = cell_types == "" if np.any(empty_mask): @@ -117,7 +118,7 @@ def validate_cell_types( raise ValidationError( f"Cell types contain {empty_count} empty strings" ) - + return cell_types @@ -147,20 +148,20 @@ def validate_sample_ids( """ if sample_ids is None: return None - + sample_ids = np.asarray(sample_ids, dtype=str) - + if sample_ids.ndim != 1: raise ValidationError( f"Sample IDs must be 1D array, got {sample_ids.ndim}D" ) - + if len(sample_ids) != n_cells: raise ValidationError( f"Sample IDs length ({len(sample_ids)}) does not match " f"number of cells ({n_cells})" ) - + return sample_ids @@ -190,7 +191,7 @@ def validate_marker_data( """ if markers is None: return None - + if isinstance(markers, np.ndarray): if markers.ndim != 2: raise ValidationError( @@ -204,20 +205,20 @@ def validate_marker_data( raise ValidationError( f"Markers must be np.ndarray or pd.DataFrame, got {type(markers)}" ) - + if len(markers) != n_cells: raise ValidationError( f"Marker data rows ({len(markers)}) does not match " f"number of cells ({n_cells})" ) - + # Check for non-numeric columns non_numeric = markers.select_dtypes(exclude=[np.number]).columns.tolist() if non_numeric: raise ValidationError( f"Marker data contains non-numeric columns: {non_numeric}" ) - + return markers @@ -239,12 +240,12 @@ def validate_metadata( """ if metadata is None: return {} - + if not isinstance(metadata, dict): raise ValidationError( f"Metadata must be a dictionary, got {type(metadata)}" ) - + return dict(metadata) # Return a copy @@ -277,15 +278,15 @@ def validate_positive_number( """ if not isinstance(value, (int, float)): raise ValidationError(f"{name} must be numeric, got {type(value)}") - + if np.isnan(value) or np.isinf(value): raise ValidationError(f"{name} must be finite, got {value}") - + if allow_zero: if value < 0: raise ValidationError(f"{name} must be non-negative, got {value}") else: if value <= 0: raise ValidationError(f"{name} must be positive, got {value}") - + return value diff --git a/spatialtissuepy/io/__init__.py b/spatialtissuepy/io/__init__.py index 0aa985f..0003c0c 100644 --- a/spatialtissuepy/io/__init__.py +++ b/spatialtissuepy/io/__init__.py @@ -5,9 +5,9 @@ """ from spatialtissuepy.io.readers import ( + read_anndata, read_csv, read_json, - read_anndata, ) from spatialtissuepy.io.writers import ( write_csv, @@ -16,7 +16,7 @@ __all__ = [ "read_csv", - "read_json", + "read_json", "read_anndata", "write_csv", "write_json", diff --git a/spatialtissuepy/io/readers.py b/spatialtissuepy/io/readers.py index 71f8cc0..03a0a19 100644 --- a/spatialtissuepy/io/readers.py +++ b/spatialtissuepy/io/readers.py @@ -5,9 +5,11 @@ """ from __future__ import annotations -from typing import Optional, List, Dict, Any, Union -from pathlib import Path + import json +from pathlib import Path +from typing import List, Optional, Union + import numpy as np import pandas as pd @@ -114,8 +116,8 @@ def read_json( } """ filepath = Path(filepath) - - with open(filepath, 'r') as f: + + with open(filepath) as f: data = json.load(f) # Handle nested or flat structure @@ -145,7 +147,7 @@ def read_json( for i, cell in enumerate(cells): if x_key not in cell or y_key not in cell: raise ValidationError(f"Cell {i} missing coordinates") - + coord = [cell[x_key], cell[y_key]] if z_key and z_key in cell: coord.append(cell[z_key]) @@ -166,7 +168,7 @@ def read_json( # Convert to arrays coordinates = np.array(coords) - + markers = None if marker_data: # Ensure all markers have same length diff --git a/spatialtissuepy/io/writers.py b/spatialtissuepy/io/writers.py index 1920771..6112e6a 100644 --- a/spatialtissuepy/io/writers.py +++ b/spatialtissuepy/io/writers.py @@ -5,9 +5,11 @@ """ from __future__ import annotations -from typing import Optional, Dict, Any, Union, TYPE_CHECKING -from pathlib import Path + import json +from pathlib import Path +from typing import TYPE_CHECKING, Union + import numpy as np import pandas as pd @@ -17,7 +19,7 @@ class NumpyEncoder(json.JSONEncoder): """JSON encoder that handles numpy types.""" - + def default(self, obj): if isinstance(obj, np.ndarray): return obj.tolist() @@ -31,7 +33,7 @@ def default(self, obj): def write_csv( - data: 'SpatialTissueData', + data: SpatialTissueData, filepath: Union[str, Path], include_markers: bool = True, include_neighborhoods: bool = True, @@ -76,7 +78,7 @@ def write_csv( def write_json( - data: 'SpatialTissueData', + data: SpatialTissueData, filepath: Union[str, Path], include_markers: bool = True, include_metadata: bool = True, @@ -146,7 +148,7 @@ def write_json( def write_hdf5( - data: 'SpatialTissueData', + data: SpatialTissueData, filepath: Union[str, Path], compression: str = 'gzip', compression_opts: int = 4 @@ -235,7 +237,7 @@ def write_hdf5( def write_anndata( - data: 'SpatialTissueData', + data: SpatialTissueData, filepath: Union[str, Path], spatial_key: str = 'spatial' ) -> None: diff --git a/spatialtissuepy/lda/__init__.py b/spatialtissuepy/lda/__init__.py index 521e2ee..18c585b 100644 --- a/spatialtissuepy/lda/__init__.py +++ b/spatialtissuepy/lda/__init__.py @@ -25,18 +25,18 @@ Example ------- >>> from spatialtissuepy.lda import SpatialLDA, fit_spatial_lda ->>> +>>> >>> # Quick fit >>> model = fit_spatial_lda(data, n_topics=5, neighborhood_radius=50) ->>> +>>> >>> # Get topic assignments >>> topic_weights = model.transform(data) >>> dominant_topics = model.predict(data) ->>> +>>> >>> # Analyze topics >>> print(model.topic_summary()) >>> print(model.top_cell_types_per_topic()) ->>> +>>> >>> # Multi-sample analysis >>> model = SpatialLDA(n_topics=8) >>> model.fit([data1, data2, data3]) # Joint fitting @@ -51,41 +51,38 @@ Triple Negative Breast Cancer Revealed by Multiplexed Ion Beam Imaging. Cell. """ -from .spatial_lda import ( - SpatialLDA, - fit_spatial_lda, - compute_neighborhood_features, - compute_neighborhood_counts, -) - -from .sampling import ( - poisson_disk_sample, - grid_sample, - random_sample, - stratified_sample, - spatial_stratified_sample, -) - from .analysis import ( - topic_cell_type_matrix, - topic_enrichment, + compare_topics_across_samples, dominant_topic_per_cell, topic_assignment_uncertainty, - topic_spatial_distribution, - topic_spatial_autocorrelation, topic_boundary_cells, - compare_topics_across_samples, + topic_cell_type_matrix, + topic_enrichment, topic_prevalence_by_cell_type, + topic_spatial_autocorrelation, + topic_spatial_distribution, topic_transition_matrix, ) - from .metrics import ( + compute_model_selection_metrics, + spatial_topic_consistency, topic_coherence, + topic_concentration_index, topic_diversity, topic_exclusivity, - spatial_topic_consistency, - topic_concentration_index, - compute_model_selection_metrics, +) +from .sampling import ( + grid_sample, + poisson_disk_sample, + random_sample, + spatial_stratified_sample, + stratified_sample, +) +from .spatial_lda import ( + SpatialLDA, + compute_neighborhood_counts, + compute_neighborhood_features, + fit_spatial_lda, ) __all__ = [ diff --git a/spatialtissuepy/lda/analysis.py b/spatialtissuepy/lda/analysis.py index f047c37..1522ba7 100644 --- a/spatialtissuepy/lda/analysis.py +++ b/spatialtissuepy/lda/analysis.py @@ -6,13 +6,15 @@ """ from __future__ import annotations -from typing import Optional, Dict, List, Tuple, TYPE_CHECKING + +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np import pandas as pd -from scipy import stats as scipy_stats if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData + from .spatial_lda import SpatialLDA @@ -21,19 +23,19 @@ # ----------------------------------------------------------------------------- def topic_cell_type_matrix( - model: 'SpatialLDA', + model: SpatialLDA, normalize: bool = True ) -> pd.DataFrame: """ Get the topic-cell type association matrix. - + Parameters ---------- model : SpatialLDA Fitted Spatial LDA model. normalize : bool, default True If True, rows sum to 1 (probability distribution). - + Returns ------- pd.DataFrame @@ -41,13 +43,13 @@ def topic_cell_type_matrix( """ if not model._is_fitted: raise RuntimeError("Model not fitted.") - + matrix = model.topic_cell_type_matrix_.copy() - + if not normalize: # Return unnormalized (LDA components) matrix = model._lda_model.components_.copy() - + return pd.DataFrame( matrix, index=[f'Topic_{i}' for i in range(model.n_topics)], @@ -56,19 +58,19 @@ def topic_cell_type_matrix( def topic_enrichment( - model: 'SpatialLDA', + model: SpatialLDA, baseline: Optional[np.ndarray] = None ) -> pd.DataFrame: """ Compute cell type enrichment in each topic relative to baseline. - + Parameters ---------- model : SpatialLDA Fitted Spatial LDA model. baseline : np.ndarray, optional Baseline cell type proportions. If None, uses uniform distribution. - + Returns ------- pd.DataFrame @@ -76,18 +78,18 @@ def topic_enrichment( """ if not model._is_fitted: raise RuntimeError("Model not fitted.") - + topic_matrix = model.topic_cell_type_matrix_ n_types = topic_matrix.shape[1] - + if baseline is None: baseline = np.ones(n_types) / n_types - + # Compute log2 fold change with np.errstate(divide='ignore', invalid='ignore'): enrichment = np.log2(topic_matrix / baseline) enrichment = np.nan_to_num(enrichment, nan=0, posinf=5, neginf=-5) - + return pd.DataFrame( enrichment, index=[f'Topic_{i}' for i in range(model.n_topics)], @@ -96,13 +98,13 @@ def topic_enrichment( def dominant_topic_per_cell( - model: Union['SpatialLDA', np.ndarray], - data: Optional['SpatialTissueData'] = None, + model: Union[SpatialLDA, np.ndarray], + data: Optional[SpatialTissueData] = None, return_weights: bool = False ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: """ Get the dominant (most probable) topic for each cell. - + Parameters ---------- model : SpatialLDA or np.ndarray @@ -111,7 +113,7 @@ def dominant_topic_per_cell( Data to analyze (required if model is SpatialLDA). return_weights : bool, default False If True, also return the weight of the dominant topic. - + Returns ------- np.ndarray @@ -125,32 +127,32 @@ def dominant_topic_per_cell( if data is None: raise ValueError("data required when providing SpatialLDA model") topic_weights = model.transform(data) - + dominant = np.argmax(topic_weights, axis=1) - + if return_weights: weights = np.max(topic_weights, axis=1) return dominant, weights - + return dominant def topic_assignment_uncertainty( - model: Union['SpatialLDA', np.ndarray], - data: Optional['SpatialTissueData'] = None + model: Union[SpatialLDA, np.ndarray], + data: Optional[SpatialTissueData] = None ) -> np.ndarray: """ Compute entropy of topic assignments as uncertainty measure. - + Higher entropy = more uncertain assignment. Ranges from 0-1. - + Parameters ---------- model : SpatialLDA or np.ndarray Fitted model OR precomputed topic weight matrix. data : SpatialTissueData, optional Data to analyze (required if model is SpatialLDA). - + Returns ------- np.ndarray @@ -162,12 +164,12 @@ def topic_assignment_uncertainty( if data is None: raise ValueError("data required when providing SpatialLDA model") topic_weights = model.transform(data) - + # Compute entropy with np.errstate(divide='ignore', invalid='ignore'): log_weights = np.log2(topic_weights + 1e-10) entropy = -np.sum(topic_weights * log_weights, axis=1) - + return entropy @@ -176,13 +178,13 @@ def topic_assignment_uncertainty( # ----------------------------------------------------------------------------- def topic_spatial_distribution( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, topic_idx: int ) -> Dict[str, np.ndarray]: """ Analyze the spatial distribution of a topic. - + Parameters ---------- model : SpatialLDA @@ -191,7 +193,7 @@ def topic_spatial_distribution( Data to analyze. topic_idx : int Index of topic to analyze. - + Returns ------- dict @@ -204,20 +206,20 @@ def topic_spatial_distribution( topic_weights = model.transform(data) weights = topic_weights[:, topic_idx] coords = data._coordinates - + # Weighted centroid total_weight = np.sum(weights) if total_weight > 0: centroid = np.average(coords, weights=weights, axis=0) else: centroid = np.mean(coords, axis=0) - + # Weighted spread (standard deviation) if total_weight > 0: spread = np.sqrt(np.average((coords - centroid)**2, weights=weights, axis=0)) else: spread = np.std(coords, axis=0) - + return { 'positions': coords, 'weights': weights, @@ -227,14 +229,14 @@ def topic_spatial_distribution( def topic_spatial_autocorrelation( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, topic_idx: int, radius: float = 50.0 ) -> Dict[str, float]: """ Compute Moran's I for topic weights to assess spatial clustering. - + Parameters ---------- model : SpatialLDA @@ -245,23 +247,23 @@ def topic_spatial_autocorrelation( Index of topic. radius : float, default 50.0 Neighborhood radius for spatial weights. - + Returns ------- dict Moran's I statistics including 'I', 'expected', 'zscore', 'pvalue'. """ from spatialtissuepy.statistics.colocalization import morans_i - + topic_weights = model.transform(data) weights = topic_weights[:, topic_idx] - + return morans_i(data, weights, radius) def topic_boundary_cells( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, topic_a: int, topic_b: int, threshold: float = 0.3, @@ -269,7 +271,7 @@ def topic_boundary_cells( ) -> Tuple[np.ndarray, np.ndarray]: """ Find cells at the boundary between two topics. - + Parameters ---------- model : SpatialLDA @@ -282,7 +284,7 @@ def topic_boundary_cells( Minimum weight for a cell to be considered part of a topic. radius : float, default 50.0 Radius for finding neighbors. - + Returns ------- boundary_a : np.ndarray @@ -291,41 +293,41 @@ def topic_boundary_cells( Indices of topic_b cells near topic_a. """ from scipy.spatial import cKDTree - + topic_weights = model.transform(data) coords = data._coordinates - + # Assign cells to topics based on dominant + threshold dominant = np.argmax(topic_weights, axis=1) max_weight = np.max(topic_weights, axis=1) - + cells_a = np.where((dominant == topic_a) & (max_weight >= threshold))[0] cells_b = np.where((dominant == topic_b) & (max_weight >= threshold))[0] - + if len(cells_a) == 0 or len(cells_b) == 0: return np.array([]), np.array([]) - + # Find cells near the other topic tree_a = cKDTree(coords[cells_a]) tree_b = cKDTree(coords[cells_b]) - + # Cells in A near cells in B boundary_a_mask = np.zeros(len(cells_a), dtype=bool) for i, coord in enumerate(coords[cells_a]): neighbors = tree_b.query_ball_point(coord, radius) if len(neighbors) > 0: boundary_a_mask[i] = True - + # Cells in B near cells in A boundary_b_mask = np.zeros(len(cells_b), dtype=bool) for i, coord in enumerate(coords[cells_b]): neighbors = tree_a.query_ball_point(coord, radius) if len(neighbors) > 0: boundary_b_mask[i] = True - + boundary_a = cells_a[boundary_a_mask] boundary_b = cells_b[boundary_b_mask] - + return boundary_a, boundary_b @@ -334,13 +336,13 @@ def topic_boundary_cells( # ----------------------------------------------------------------------------- def compare_topics_across_samples( - model: 'SpatialLDA', - samples: List['SpatialTissueData'], + model: SpatialLDA, + samples: List[SpatialTissueData], sample_ids: Optional[List[str]] = None ) -> pd.DataFrame: """ Compare topic prevalence across multiple samples. - + Parameters ---------- model : SpatialLDA @@ -349,7 +351,7 @@ def compare_topics_across_samples( Samples to compare. sample_ids : list of str, optional Names for samples. - + Returns ------- pd.DataFrame @@ -357,42 +359,42 @@ def compare_topics_across_samples( """ if sample_ids is None: sample_ids = [f'sample_{i}' for i in range(len(samples))] - + results = [] - + for sample_id, sample in zip(sample_ids, samples): topic_weights = model.transform(sample) dominant = np.argmax(topic_weights, axis=1) - + row = {'sample_id': sample_id, 'n_cells': sample.n_cells} - + # Mean topic weights for i in range(model.n_topics): row[f'topic_{i}_mean'] = np.mean(topic_weights[:, i]) row[f'topic_{i}_dominant_count'] = np.sum(dominant == i) row[f'topic_{i}_dominant_frac'] = np.mean(dominant == i) - + results.append(row) - + return pd.DataFrame(results) def topic_prevalence_by_cell_type( - model: 'SpatialLDA', - data: 'SpatialTissueData' + model: SpatialLDA, + data: SpatialTissueData ) -> pd.DataFrame: """ Analyze topic prevalence stratified by cell type. - + For each cell type, compute the distribution of topic assignments. - + Parameters ---------- model : SpatialLDA Fitted model. data : SpatialTissueData Data to analyze. - + Returns ------- pd.DataFrame @@ -401,37 +403,37 @@ def topic_prevalence_by_cell_type( topic_weights = model.transform(data) cell_types = data._cell_types unique_types = data.cell_types_unique - + results = [] - + for cell_type in unique_types: mask = cell_types == cell_type type_weights = topic_weights[mask] - + row = { 'cell_type': cell_type, 'n_cells': np.sum(mask), } - + for i in range(model.n_topics): row[f'topic_{i}_mean'] = np.mean(type_weights[:, i]) row[f'topic_{i}_std'] = np.std(type_weights[:, i]) - + results.append(row) - + return pd.DataFrame(results) def topic_transition_matrix( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, radius: float = 50.0 ) -> pd.DataFrame: """ Compute topic co-occurrence/transition matrix. - + For each pair of topics, count how often they appear in adjacent cells. - + Parameters ---------- model : SpatialLDA @@ -440,41 +442,41 @@ def topic_transition_matrix( Data to analyze. radius : float, default 50.0 Neighborhood radius. - + Returns ------- pd.DataFrame Topic co-occurrence matrix. """ from scipy.spatial import cKDTree - + topic_weights = model.transform(data) dominant = np.argmax(topic_weights, axis=1) coords = data._coordinates - + # Build spatial neighbors tree = cKDTree(coords) - + # Count co-occurrences n_topics = model.n_topics co_occurrence = np.zeros((n_topics, n_topics)) - + for i in range(data.n_cells): neighbors = tree.query_ball_point(coords[i], radius) neighbors = [j for j in neighbors if j != i] - + topic_i = dominant[i] - + for j in neighbors: topic_j = dominant[j] co_occurrence[topic_i, topic_j] += 1 - + # Normalize by row sums row_sums = co_occurrence.sum(axis=1, keepdims=True) with np.errstate(divide='ignore', invalid='ignore'): normalized = co_occurrence / row_sums normalized = np.nan_to_num(normalized, nan=0) - + return pd.DataFrame( normalized, index=[f'Topic_{i}' for i in range(n_topics)], diff --git a/spatialtissuepy/lda/metrics.py b/spatialtissuepy/lda/metrics.py index 0d79950..4a39400 100644 --- a/spatialtissuepy/lda/metrics.py +++ b/spatialtissuepy/lda/metrics.py @@ -6,13 +6,16 @@ """ from __future__ import annotations -from typing import Optional, List, Dict, TYPE_CHECKING + +from typing import TYPE_CHECKING, Dict, List, Optional, Union + import numpy as np import pandas as pd from scipy.spatial import cKDTree if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData + from .spatial_lda import SpatialLDA @@ -21,18 +24,18 @@ # ----------------------------------------------------------------------------- def topic_coherence( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, n_top_types: int = 5, method: str = 'pmi', return_aggregate: bool = True ) -> Union[float, Dict[int, float]]: """ Compute topic coherence scores. - + Measures how semantically coherent the top cell types in each topic are, based on their co-occurrence in neighborhoods. - + Parameters ---------- model : SpatialLDA @@ -46,18 +49,21 @@ def topic_coherence( return_aggregate : bool, default True If True, return mean coherence across all topics. If False, return dict of per-topic scores. - + Returns ------- float or dict Mean coherence score or mapping from topic index to score. - + Notes ----- Higher coherence indicates more interpretable topics where the top cell types frequently co-occur in neighborhoods. """ - from spatialtissuepy.spatial.neighborhood import compute_neighborhoods, neighborhood_counts + from spatialtissuepy.spatial.neighborhood import ( + compute_neighborhoods, + neighborhood_counts, + ) # Get neighborhoods present neighborhoods = compute_neighborhoods( @@ -67,61 +73,61 @@ def topic_coherence( k=model.neighborhood_k, include_self=True ) - + # Get neighborhood counts counts = neighborhood_counts( data, neighborhoods ) - + n_cells = counts.shape[0] cell_types = list(data.cell_types_unique) n_types = len(cell_types) - + # Compute co-occurrence matrix cooccur = np.zeros((n_types, n_types)) marginal = np.zeros(n_types) - + for i in range(n_cells): present = counts[i] > 0 present_idx = np.where(present)[0] - + for idx in present_idx: marginal[idx] += 1 for idx2 in present_idx: cooccur[idx, idx2] += 1 - + # Normalize p_joint = cooccur / n_cells p_marginal = marginal / n_cells - + # Compute coherence for each topic coherence_scores = {} - + for topic_idx in range(model.n_topics): # Get top cell types for this topic topic_weights = model.topic_cell_type_matrix_[topic_idx] - + # Map to data's cell type indices type_to_idx = {ct: i for i, ct in enumerate(cell_types)} model_weights = np.zeros(n_types) - + for i, ct in enumerate(model.cell_types_): if ct in type_to_idx: model_weights[type_to_idx[ct]] = topic_weights[i] - + top_indices = np.argsort(model_weights)[::-1][:n_top_types] - + # Compute pairwise coherence coherence_sum = 0.0 n_pairs = 0 - + for i, idx1 in enumerate(top_indices): for idx2 in top_indices[i+1:]: p_xy = p_joint[idx1, idx2] p_x = p_marginal[idx1] p_y = p_marginal[idx2] - + if p_x > 0 and p_y > 0 and p_xy > 0: if method == 'pmi': # Pointwise mutual information @@ -131,92 +137,92 @@ def topic_coherence( pmi = np.log(p_xy / (p_x * p_y)) npmi = pmi / (-np.log(p_xy)) coherence_sum += npmi - + n_pairs += 1 - + if n_pairs > 0: coherence_scores[topic_idx] = coherence_sum / n_pairs else: coherence_scores[topic_idx] = 0.0 - + if return_aggregate: return float(np.mean(list(coherence_scores.values()))) - + return coherence_scores def topic_diversity( - model: 'SpatialLDA', + model: SpatialLDA, n_top_types: int = 10 ) -> float: """ Compute topic diversity score. - + Measures how distinct topics are from each other by looking at overlap in their top cell types. - + Parameters ---------- model : SpatialLDA Fitted model. n_top_types : int, default 10 Number of top cell types per topic to consider. - + Returns ------- float Diversity score between 0 (all topics identical) and 1 (no overlap). - + Notes ----- Higher diversity indicates more distinct, interpretable topics. """ if not model._is_fitted: raise RuntimeError("Model not fitted.") - + # Get top types for each topic top_types_per_topic = [] - + for topic_idx in range(model.n_topics): weights = model.topic_cell_type_matrix_[topic_idx] top_indices = np.argsort(weights)[::-1][:n_top_types] top_types = set(top_indices) top_types_per_topic.append(top_types) - + # Compute pairwise Jaccard distances distances = [] - + for i in range(model.n_topics): for j in range(i + 1, model.n_topics): intersection = len(top_types_per_topic[i] & top_types_per_topic[j]) union = len(top_types_per_topic[i] | top_types_per_topic[j]) - + if union > 0: jaccard = intersection / union distances.append(1 - jaccard) # Distance = 1 - similarity - + if len(distances) == 0: return 1.0 - + return np.mean(distances) def topic_exclusivity( - model: 'SpatialLDA', + model: SpatialLDA, n_top_types: int = 10 ) -> Dict[int, float]: """ Compute exclusivity score for each topic. - + Measures how exclusive the top cell types are to each topic. - + Parameters ---------- model : SpatialLDA Fitted model. n_top_types : int, default 10 Number of top cell types to consider. - + Returns ------- dict @@ -224,29 +230,29 @@ def topic_exclusivity( """ if not model._is_fitted: raise RuntimeError("Model not fitted.") - + topic_matrix = model.topic_cell_type_matrix_ n_topics, n_types = topic_matrix.shape - + exclusivity_scores = {} - + for topic_idx in range(n_topics): weights = topic_matrix[topic_idx] top_indices = np.argsort(weights)[::-1][:n_top_types] - + # Compute exclusivity for top types excl_sum = 0.0 - + for type_idx in top_indices: # Weight in this topic vs sum across all topics weight_in_topic = topic_matrix[topic_idx, type_idx] total_weight = np.sum(topic_matrix[:, type_idx]) - + if total_weight > 0: excl_sum += weight_in_topic / total_weight - + exclusivity_scores[topic_idx] = excl_sum / n_top_types - + return exclusivity_scores @@ -255,15 +261,15 @@ def topic_exclusivity( # ----------------------------------------------------------------------------- def spatial_topic_consistency( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, radius: float = 50.0 ) -> Dict[str, float]: """ Measure spatial consistency of topic assignments. - + Checks if spatially nearby cells have similar topic assignments. - + Parameters ---------- model : SpatialLDA @@ -272,7 +278,7 @@ def spatial_topic_consistency( Data to analyze. radius : float, default 50.0 Neighborhood radius. - + Returns ------- dict @@ -284,43 +290,43 @@ def spatial_topic_consistency( topic_weights = model.transform(data) dominant = np.argmax(topic_weights, axis=1) coords = data._coordinates - + # Build KD-tree tree = cKDTree(coords) - + # Compute agreement rate agreements = 0 total_pairs = 0 - + for i in range(data.n_cells): neighbors = tree.query_ball_point(coords[i], radius) neighbors = [j for j in neighbors if j != i] - + for j in neighbors: if dominant[i] == dominant[j]: agreements += 1 total_pairs += 1 - + agreement_rate = agreements / total_pairs if total_pairs > 0 else 0 - + # Compute autocorrelation for each topic morans_i_values = [] - + for topic_idx in range(model.n_topics): from spatialtissuepy.statistics.colocalization import morans_i result = morans_i(data, topic_weights[:, topic_idx], radius) morans_i_values.append(result['I']) - + topic_autocorrelation = np.mean(morans_i_values) - + # Compute entropy def entropy(weights): with np.errstate(divide='ignore', invalid='ignore'): log_w = np.log2(weights + 1e-10) return -np.sum(weights * log_w, axis=1) - + cell_entropy = entropy(topic_weights) - + # Compute neighborhood-averaged entropy neighbor_entropy = np.zeros(data.n_cells) for i in range(data.n_cells): @@ -328,9 +334,9 @@ def entropy(weights): if len(neighbors) > 0: avg_weights = np.mean(topic_weights[neighbors], axis=0) neighbor_entropy[i] = -np.sum(avg_weights * np.log2(avg_weights + 1e-10)) - + entropy_reduction = np.mean(cell_entropy - neighbor_entropy) - + return { 'agreement_rate': agreement_rate, 'topic_autocorrelation': topic_autocorrelation, @@ -339,21 +345,21 @@ def entropy(weights): def topic_concentration_index( - model: 'SpatialLDA', - data: 'SpatialTissueData' + model: SpatialLDA, + data: SpatialTissueData ) -> Dict[int, float]: """ Compute spatial concentration index for each topic. - + Measures how spatially concentrated each topic is (vs uniformly spread). - + Parameters ---------- model : SpatialLDA Fitted model. data : SpatialTissueData Data to analyze. - + Returns ------- dict @@ -361,32 +367,32 @@ def topic_concentration_index( """ topic_weights = model.transform(data) coords = data._coordinates - + concentration = {} - + for topic_idx in range(model.n_topics): weights = topic_weights[:, topic_idx] - + if np.sum(weights) < 1e-10: concentration[topic_idx] = 0.0 continue - + # Weighted centroid centroid = np.average(coords, weights=weights, axis=0) - + # Weighted distance from centroid distances = np.linalg.norm(coords - centroid, axis=1) weighted_dist = np.average(distances, weights=weights) - + # Compare to uniform spread uniform_dist = np.mean(distances) - + # Concentration = 1 - (weighted_dist / uniform_dist) if uniform_dist > 0: concentration[topic_idx] = max(0, 1 - weighted_dist / uniform_dist) else: concentration[topic_idx] = 0.0 - + return concentration @@ -395,15 +401,15 @@ def topic_concentration_index( # ----------------------------------------------------------------------------- def compute_model_selection_metrics( - model: Union['SpatialLDA', List[int]], - data: Optional['SpatialTissueData'] = None, + model: Union[SpatialLDA, List[int]], + data: Optional[SpatialTissueData] = None, n_topics_range: Optional[List[int]] = None, neighborhood_radius: float = 50.0, random_state: Optional[int] = None ) -> pd.DataFrame: """ Compute metrics for model selection (choosing number of topics). - + Parameters ---------- model : SpatialLDA or list of int @@ -417,14 +423,14 @@ def compute_model_selection_metrics( Radius for neighborhoods. random_state : int, optional Random seed for reproducibility. - + Returns ------- pd.DataFrame DataFrame with metrics for each n_topics value. """ from .spatial_lda import SpatialLDA - + # Handle argument polymorphic signature from tests if isinstance(model, list): n_topics_range = model @@ -435,24 +441,24 @@ def compute_model_selection_metrics( if n_topics_range is None: n_topics_range = [3, 5, 7, 10] - + results = [] - + for n_topics in n_topics_range: m = SpatialLDA( n_topics=n_topics, neighborhood_radius=neighborhood_radius, random_state=random_state, ) - + m.fit(data) - + # Compute metrics perplexity = m.perplexity(data) log_likelihood = m.score(data) diversity = topic_diversity(m) mean_coherence = topic_coherence(m, data, return_aggregate=True) - + results.append({ 'n_topics': n_topics, 'perplexity': perplexity, @@ -460,5 +466,5 @@ def compute_model_selection_metrics( 'diversity': diversity, 'mean_coherence': mean_coherence, }) - + return pd.DataFrame(results) diff --git a/spatialtissuepy/lda/sampling.py b/spatialtissuepy/lda/sampling.py index 0eea07e..8baee90 100644 --- a/spatialtissuepy/lda/sampling.py +++ b/spatialtissuepy/lda/sampling.py @@ -14,7 +14,9 @@ """ from __future__ import annotations -from typing import Optional, List, Tuple, TYPE_CHECKING + +from typing import TYPE_CHECKING, Optional + import numpy as np from scipy.spatial import cKDTree @@ -23,17 +25,17 @@ def poisson_disk_sample( - data: 'SpatialTissueData', + data: SpatialTissueData, min_distance: float, max_samples: Optional[int] = None, seed: Optional[int] = None ) -> np.ndarray: """ Sample cells using Poisson disk sampling. - + This ensures a minimum distance between sampled cells, providing even spatial coverage while avoiding redundant nearby samples. - + Parameters ---------- data : SpatialTissueData @@ -44,20 +46,20 @@ def poisson_disk_sample( Maximum number of samples. If None, sample as many as possible. seed : int, optional Random seed. - + Returns ------- np.ndarray Indices of sampled cells. - + Notes ----- Uses a greedy dart-throwing algorithm: 1. Randomly shuffle cells 2. For each cell, accept if at least min_distance from all accepted - + This provides approximately uniform spatial coverage. - + Examples -------- >>> # Sample cells at least 100 µm apart @@ -65,26 +67,26 @@ def poisson_disk_sample( >>> print(f"Sampled {len(indices)} cells") """ rng = np.random.default_rng(seed) - + coords = data._coordinates n_cells = len(coords) - + # Shuffle order for random selection order = rng.permutation(n_cells) - + # Accepted samples accepted = [] accepted_coords = [] - + # Build KD-tree incrementally for efficiency tree = None - + for idx in order: if max_samples is not None and len(accepted) >= max_samples: break - + coord = coords[idx] - + # Check distance to all accepted if len(accepted) == 0: # First sample always accepted @@ -94,18 +96,18 @@ def poisson_disk_sample( else: # Check if far enough from all accepted dist, _ = tree.query(coord) - + if dist >= min_distance: accepted.append(idx) accepted_coords.append(coord) # Rebuild tree (could be optimized) tree = cKDTree(accepted_coords) - + return np.array(accepted) def grid_sample( - data: 'SpatialTissueData', + data: SpatialTissueData, spacing: float = 50.0, jitter: float = 0.0, seed: Optional[int] = None, @@ -113,9 +115,9 @@ def grid_sample( ) -> np.ndarray: """ Sample cells on a regular grid. - + Selects cells closest to regular grid points. - + Parameters ---------- data : SpatialTissueData @@ -128,29 +130,29 @@ def grid_sample( Random seed for jitter. **kwargs Additional arguments, including grid_size (alias for spacing). - + Returns ------- np.ndarray Indices of sampled cells. - + Examples -------- >>> indices = grid_sample(data, spacing=50, jitter=0.1) """ rng = np.random.default_rng(seed) - + if 'grid_size' in kwargs: spacing = kwargs.pop('grid_size') - + coords = data._coordinates bounds = data.bounds - + # Determine dimensionality n_dims = 2 if coords.shape[1] <= 2 else 3 if coords.shape[1] >= 3 and np.std(coords[:, 2]) < 1e-6: n_dims = 2 # Effectively 2D - + # Create grid points if n_dims == 2: x_grid = np.arange(bounds['x'][0], bounds['x'][1] + spacing, spacing) @@ -163,30 +165,30 @@ def grid_sample( z_grid = np.arange(bounds['z'][0], bounds['z'][1] + spacing, spacing) xx, yy, zz = np.meshgrid(x_grid, y_grid, z_grid) grid_points = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) - + # Add jitter if jitter > 0: noise = rng.uniform(-jitter * spacing, jitter * spacing, grid_points.shape) grid_points = grid_points + noise - + # Find nearest cell to each grid point tree = cKDTree(coords[:, :n_dims]) _, indices = tree.query(grid_points[:, :n_dims]) - + # Remove duplicates (multiple grid points may map to same cell) unique_indices = np.unique(indices) - + return unique_indices def random_sample( - data: 'SpatialTissueData', + data: SpatialTissueData, n_samples: int, seed: Optional[int] = None ) -> np.ndarray: """ Uniform random sampling of cells. - + Parameters ---------- data : SpatialTissueData @@ -195,22 +197,22 @@ def random_sample( Number of samples to select. seed : int, optional Random seed. - + Returns ------- np.ndarray Indices of sampled cells. """ rng = np.random.default_rng(seed) - + n_cells = data.n_cells n_samples = min(n_samples, n_cells) - + return rng.choice(n_cells, size=n_samples, replace=False) def stratified_sample( - data: 'SpatialTissueData', + data: SpatialTissueData, n_samples: int = 100, by: str = 'cell_type', seed: Optional[int] = None, @@ -218,7 +220,7 @@ def stratified_sample( ) -> np.ndarray: """ Stratified sampling to maintain cell type proportions. - + Parameters ---------- data : SpatialTissueData @@ -231,43 +233,43 @@ def stratified_sample( Random seed. **kwargs Additional arguments, including n_per_type (multiplies by n_types). - + Returns ------- np.ndarray Indices of sampled cells. - + Notes ----- Samples proportionally from each cell type to maintain the original composition in the sample. """ rng = np.random.default_rng(seed) - + if 'n_per_type' in kwargs: n_samples = kwargs.pop('n_per_type') * len(data.cell_types_unique) - + cell_types = data._cell_types unique_types = data.cell_types_unique - + n_cells = data.n_cells n_samples = min(n_samples, n_cells) - + # Calculate samples per type type_counts = { ct: np.sum(cell_types == ct) for ct in unique_types } - + samples_per_type = { ct: max(1, int(np.round(n_samples * count / n_cells))) for ct, count in type_counts.items() } - + # Adjust to match total total = sum(samples_per_type.values()) if total > n_samples: # Reduce from largest groups - sorted_types = sorted(samples_per_type.keys(), + sorted_types = sorted(samples_per_type.keys(), key=lambda x: samples_per_type[x], reverse=True) for ct in sorted_types: if total <= n_samples: @@ -275,32 +277,32 @@ def stratified_sample( if samples_per_type[ct] > 1: samples_per_type[ct] -= 1 total -= 1 - + # Sample from each type all_indices = [] - + for ct in unique_types: type_indices = np.where(cell_types == ct)[0] n_to_sample = min(samples_per_type[ct], len(type_indices)) - + sampled = rng.choice(type_indices, size=n_to_sample, replace=False) all_indices.extend(sampled) - + return np.array(all_indices) def spatial_stratified_sample( - data: 'SpatialTissueData', + data: SpatialTissueData, n_samples: int, n_regions: int = 4, seed: Optional[int] = None ) -> np.ndarray: """ Stratified sampling by spatial region. - + Divides the tissue into spatial regions and samples proportionally from each region to ensure spatial coverage. - + Parameters ---------- data : SpatialTissueData @@ -311,44 +313,44 @@ def spatial_stratified_sample( Number of spatial regions (will create n_regions x n_regions grid). seed : int, optional Random seed. - + Returns ------- np.ndarray Indices of sampled cells. """ rng = np.random.default_rng(seed) - + coords = data._coordinates bounds = data.bounds - + # Create region grid x_edges = np.linspace(bounds['x'][0], bounds['x'][1], n_regions + 1) y_edges = np.linspace(bounds['y'][0], bounds['y'][1], n_regions + 1) - + # Assign cells to regions x_bins = np.digitize(coords[:, 0], x_edges) - 1 y_bins = np.digitize(coords[:, 1], y_edges) - 1 - + # Clip to valid range x_bins = np.clip(x_bins, 0, n_regions - 1) y_bins = np.clip(y_bins, 0, n_regions - 1) - + region_ids = x_bins * n_regions + y_bins - + # Sample from each region samples_per_region = max(1, n_samples // (n_regions * n_regions)) - + all_indices = [] - + for region_id in range(n_regions * n_regions): region_cells = np.where(region_ids == region_id)[0] - + if len(region_cells) > 0: n_to_sample = min(samples_per_region, len(region_cells)) sampled = rng.choice(region_cells, size=n_to_sample, replace=False) all_indices.extend(sampled) - + # If we need more samples, randomly add from all cells if len(all_indices) < n_samples: remaining = set(range(data.n_cells)) - set(all_indices) @@ -356,5 +358,5 @@ def spatial_stratified_sample( n_extra = min(n_samples - len(all_indices), len(remaining)) extra = rng.choice(list(remaining), size=n_extra, replace=False) all_indices.extend(extra) - + return np.array(all_indices[:n_samples]) diff --git a/spatialtissuepy/lda/spatial_lda.py b/spatialtissuepy/lda/spatial_lda.py index 93093a1..357f550 100644 --- a/spatialtissuepy/lda/spatial_lda.py +++ b/spatialtissuepy/lda/spatial_lda.py @@ -6,14 +6,12 @@ """ from __future__ import annotations + from dataclasses import dataclass, field -from typing import ( - Optional, Dict, List, Any, Union, Tuple, - TYPE_CHECKING -) +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + import numpy as np import pandas as pd -from scipy.spatial import cKDTree if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -24,7 +22,7 @@ # ----------------------------------------------------------------------------- def compute_neighborhood_features( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: float = 50.0, k: int = 30, @@ -34,10 +32,10 @@ def compute_neighborhood_features( ) -> np.ndarray: """ Compute neighborhood composition features for each cell. - + This creates a "document" representation of each cell's neighborhood, where the "words" are the cell types present in the neighborhood. - + Parameters ---------- data : SpatialTissueData @@ -55,18 +53,18 @@ def compute_neighborhood_features( If False, return raw counts. pseudocount : float, default 0.0 Pseudocount to add for smoothing (useful for sparse neighborhoods). - + Returns ------- np.ndarray Neighborhood composition matrix of shape (n_cells, n_cell_types). Each row represents a cell's neighborhood composition. - + Notes ----- This is equivalent to treating each cell's neighborhood as a "document" in the LDA framework, where the cell types are the "vocabulary". - + Examples -------- >>> features = compute_neighborhood_features(data, method='radius', radius=50) @@ -74,7 +72,7 @@ def compute_neighborhood_features( """ # Use the existing neighborhood_composition function from spatial module from spatialtissuepy.spatial.neighborhood import neighborhood_composition - + return neighborhood_composition( data, method=method, @@ -87,7 +85,7 @@ def compute_neighborhood_features( def compute_neighborhood_counts( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: float = 50.0, k: int = 30, @@ -95,7 +93,7 @@ def compute_neighborhood_counts( ) -> np.ndarray: """ Compute raw neighborhood counts (integer counts for LDA). - + Parameters ---------- data : SpatialTissueData @@ -108,13 +106,16 @@ def compute_neighborhood_counts( Number of neighbors for 'knn' method. include_self : bool, default True Whether to include focal cell. - + Returns ------- np.ndarray Integer count matrix of shape (n_cells, n_cell_types). """ - from spatialtissuepy.spatial.neighborhood import compute_neighborhoods, neighborhood_counts + from spatialtissuepy.spatial.neighborhood import ( + compute_neighborhoods, + neighborhood_counts, + ) neighborhoods = compute_neighborhoods( data, @@ -123,7 +124,7 @@ def compute_neighborhood_counts( k=k, include_self=include_self ) - + return neighborhood_counts( data, neighborhoods @@ -138,10 +139,10 @@ def compute_neighborhood_counts( class SpatialLDA: """ Spatial Latent Dirichlet Allocation for cellular neighborhood analysis. - + This class wraps scikit-learn's LDA implementation with spatial tissue-specific functionality for discovering recurrent cellular microenvironment patterns. - + Attributes ---------- n_topics : int @@ -156,19 +157,19 @@ class SpatialLDA: Names of cell types (vocabulary). topic_cell_type_matrix_ : np.ndarray Fitted topic-cell type matrix (n_topics, n_cell_types). - + Examples -------- >>> from spatialtissuepy.lda import SpatialLDA - >>> + >>> >>> # Create and fit model >>> slda = SpatialLDA(n_topics=5, neighborhood_radius=50) >>> slda.fit(data) - >>> + >>> >>> # Get topic assignments for cells >>> topic_weights = slda.transform(data) >>> dominant_topics = slda.predict(data) - >>> + >>> >>> # Analyze topics >>> print(slda.topic_summary()) """ @@ -177,49 +178,49 @@ class SpatialLDA: neighborhood_radius: float = 50.0 neighborhood_k: int = 30 include_self: bool = True - + # LDA hyperparameters doc_topic_prior: Optional[float] = None # Alpha (Dirichlet prior on topics) topic_word_prior: Optional[float] = None # Beta (Dirichlet prior on words) learning_method: str = 'batch' # 'batch' or 'online' max_iter: int = 100 random_state: Optional[int] = None - + # Fitted attributes cell_types_: List[str] = field(default_factory=list) topic_cell_type_matrix_: Optional[np.ndarray] = field(default=None, repr=False) _lda_model: Any = field(default=None, repr=False) _is_fitted: bool = field(default=False, repr=False) - + def fit( self, - data: Union['SpatialTissueData', List['SpatialTissueData']], + data: Union[SpatialTissueData, List[SpatialTissueData]], sample_indices: Optional[np.ndarray] = None - ) -> 'SpatialLDA': + ) -> SpatialLDA: """ Fit the Spatial LDA model to tissue data. - + Parameters ---------- data : SpatialTissueData or list of SpatialTissueData Single sample or multiple samples to fit jointly. sample_indices : np.ndarray, optional If provided, fit only on these cell indices. - + Returns ------- self Fitted model. """ from sklearn.decomposition import LatentDirichletAllocation - + # Handle multiple samples if isinstance(data, list): return self._fit_multi_sample(data) - + # Store cell types self.cell_types_ = list(data.cell_types_unique) - + # Compute neighborhood features features = compute_neighborhood_counts( data, @@ -228,11 +229,11 @@ def fit( k=self.neighborhood_k, include_self=self.include_self ) - + # Subset if indices provided if sample_indices is not None: features = features[sample_indices] - + # Create and fit LDA model self._lda_model = LatentDirichletAllocation( n_components=self.n_topics, @@ -242,40 +243,40 @@ def fit( max_iter=self.max_iter, random_state=self.random_state, ) - + self._lda_model.fit(features) - + # Store topic-cell type matrix (normalized) self.topic_cell_type_matrix_ = self._lda_model.components_ # Normalize rows to sum to 1 row_sums = self.topic_cell_type_matrix_.sum(axis=1, keepdims=True) self.topic_cell_type_matrix_ = self.topic_cell_type_matrix_ / row_sums - + self._is_fitted = True - + return self - + def _fit_multi_sample( self, - samples: List['SpatialTissueData'] - ) -> 'SpatialLDA': + samples: List[SpatialTissueData] + ) -> SpatialLDA: """Fit model on multiple samples jointly.""" from sklearn.decomposition import LatentDirichletAllocation - + # Get union of cell types all_cell_types = set() for sample in samples: all_cell_types.update(sample.cell_types_unique) self.cell_types_ = sorted(list(all_cell_types)) - + # Compute features for all samples all_features = [] - + for sample in samples: # Create mapping for this sample's cell types sample_types = list(sample.cell_types_unique) type_to_idx = {ct: i for i, ct in enumerate(self.cell_types_)} - + # Compute neighborhood counts counts = compute_neighborhood_counts( sample, @@ -284,20 +285,20 @@ def _fit_multi_sample( k=self.neighborhood_k, include_self=self.include_self ) - + # Remap to unified cell type ordering n_cells = counts.shape[0] unified_counts = np.zeros((n_cells, len(self.cell_types_))) - + for i, ct in enumerate(sample_types): unified_idx = type_to_idx[ct] unified_counts[:, unified_idx] = counts[:, i] - + all_features.append(unified_counts) - + # Concatenate all features features = np.vstack(all_features) - + # Fit LDA self._lda_model = LatentDirichletAllocation( n_components=self.n_topics, @@ -307,30 +308,30 @@ def _fit_multi_sample( max_iter=self.max_iter, random_state=self.random_state, ) - + self._lda_model.fit(features) - + # Store normalized topic-cell type matrix self.topic_cell_type_matrix_ = self._lda_model.components_ row_sums = self.topic_cell_type_matrix_.sum(axis=1, keepdims=True) self.topic_cell_type_matrix_ = self.topic_cell_type_matrix_ / row_sums - + self._is_fitted = True - + return self - + def transform( self, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: """ Get topic weights for each cell in the data. - + Parameters ---------- data : SpatialTissueData Spatial tissue data. - + Returns ------- np.ndarray @@ -339,51 +340,51 @@ def transform( """ if not self._is_fitted: raise RuntimeError("Model not fitted. Call fit() first.") - + # Compute neighborhood features features = self._prepare_features(data) - + # Transform using LDA topic_weights = self._lda_model.transform(features) - + return topic_weights - + def fit_transform( self, - data: Union['SpatialTissueData', List['SpatialTissueData']] + data: Union[SpatialTissueData, List[SpatialTissueData]] ) -> np.ndarray: """ Fit the model and return topic weights. - + Parameters ---------- data : SpatialTissueData or list Data to fit. - + Returns ------- np.ndarray Topic weights for the fitted data. """ self.fit(data) - + if isinstance(data, list): # Return weights for first sample return self.transform(data[0]) return self.transform(data) - + def predict( self, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: """ Get dominant topic assignment for each cell. - + Parameters ---------- data : SpatialTissueData Spatial tissue data. - + Returns ------- np.ndarray @@ -391,14 +392,14 @@ def predict( """ topic_weights = self.transform(data) return np.argmax(topic_weights, axis=1) - + def _prepare_features( self, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: """Prepare features with consistent cell type ordering.""" sample_types = list(data.cell_types_unique) - + # Compute counts counts = compute_neighborhood_counts( data, @@ -407,28 +408,28 @@ def _prepare_features( k=self.neighborhood_k, include_self=self.include_self ) - + # Check if cell types match if set(sample_types) == set(self.cell_types_) and \ list(sample_types) == list(self.cell_types_): return counts - + # Remap to fitted cell type ordering type_to_idx = {ct: i for i, ct in enumerate(self.cell_types_)} n_cells = counts.shape[0] unified_counts = np.zeros((n_cells, len(self.cell_types_))) - + for i, ct in enumerate(sample_types): if ct in type_to_idx: unified_idx = type_to_idx[ct] unified_counts[:, unified_idx] = counts[:, i] - + return unified_counts - + def topic_summary(self) -> pd.DataFrame: """ Get a summary of topic-cell type associations. - + Returns ------- pd.DataFrame @@ -436,25 +437,25 @@ def topic_summary(self) -> pd.DataFrame: """ if not self._is_fitted: raise RuntimeError("Model not fitted.") - + return pd.DataFrame( self.topic_cell_type_matrix_, index=[f'Topic_{i}' for i in range(self.n_topics)], columns=self.cell_types_ ) - + def top_cell_types_per_topic( self, n_top: int = 5 ) -> Dict[int, List[Tuple[str, float]]]: """ Get the top cell types for each topic. - + Parameters ---------- n_top : int, default 5 Number of top cell types to return per topic. - + Returns ------- dict @@ -462,34 +463,34 @@ def top_cell_types_per_topic( """ if not self._is_fitted: raise RuntimeError("Model not fitted.") - + result = {} - + for topic_idx in range(self.n_topics): weights = self.topic_cell_type_matrix_[topic_idx] top_indices = np.argsort(weights)[::-1][:n_top] - + result[topic_idx] = [ (self.cell_types_[i], float(weights[i])) for i in top_indices ] - + return result - + def perplexity( self, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> float: """ Compute perplexity on held-out data. - + Lower perplexity indicates better fit. - + Parameters ---------- data : SpatialTissueData Data to evaluate. - + Returns ------- float @@ -497,24 +498,24 @@ def perplexity( """ if not self._is_fitted: raise RuntimeError("Model not fitted.") - + features = self._prepare_features(data) return self._lda_model.perplexity(features) - + def score( self, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> float: """ Compute log-likelihood on data. - + Higher is better. - + Parameters ---------- data : SpatialTissueData Data to evaluate. - + Returns ------- float @@ -522,50 +523,50 @@ def score( """ if not self._is_fitted: raise RuntimeError("Model not fitted.") - + features = self._prepare_features(data) return self._lda_model.score(features) - + def add_topics_to_data( self, - data: 'SpatialTissueData', + data: SpatialTissueData, prefix: str = 'topic' - ) -> 'SpatialTissueData': + ) -> SpatialTissueData: """ Add topic weights as custom data to SpatialTissueData. - + Parameters ---------- data : SpatialTissueData Data to annotate. prefix : str, default 'topic' Prefix for topic column names. - + Returns ------- SpatialTissueData Data with topic weights added to markers/custom data. """ topic_weights = self.transform(data) - + # Create DataFrame with topic weights topic_df = pd.DataFrame( topic_weights, columns=[f'{prefix}_{i}' for i in range(self.n_topics)] ) - + # Add dominant topic topic_df[f'{prefix}_dominant'] = np.argmax(topic_weights, axis=1) - + # Merge with existing markers if data.markers is not None: new_markers = pd.concat([data.markers.reset_index(drop=True), topic_df], axis=1) else: new_markers = topic_df - + # Create new data object from spatialtissuepy.core import SpatialTissueData - + return SpatialTissueData( coordinates=data._coordinates.copy(), cell_types=data._cell_types.copy(), @@ -579,7 +580,7 @@ def add_topics_to_data( # ----------------------------------------------------------------------------- def fit_spatial_lda( - data: Union['SpatialTissueData', List['SpatialTissueData']], + data: Union[SpatialTissueData, List[SpatialTissueData]], n_topics: int = 5, neighborhood_radius: float = 50.0, neighborhood_method: str = 'radius', @@ -587,9 +588,9 @@ def fit_spatial_lda( ) -> SpatialLDA: """ Fit a Spatial LDA model to tissue data. - + Convenience function for quick model fitting. - + Parameters ---------- data : SpatialTissueData or list @@ -602,12 +603,12 @@ def fit_spatial_lda( Method for computing neighborhoods. **kwargs Additional arguments passed to SpatialLDA. - + Returns ------- SpatialLDA Fitted model. - + Examples -------- >>> model = fit_spatial_lda(data, n_topics=8, neighborhood_radius=30) @@ -619,7 +620,7 @@ def fit_spatial_lda( neighborhood_method=neighborhood_method, **kwargs ) - + model.fit(data) - + return model diff --git a/spatialtissuepy/lda/summary_metrics.py b/spatialtissuepy/lda/summary_metrics.py index 4414081..4955b4d 100644 --- a/spatialtissuepy/lda/summary_metrics.py +++ b/spatialtissuepy/lda/summary_metrics.py @@ -5,7 +5,8 @@ for standardized computation across samples. """ -from typing import Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict + import numpy as np from spatialtissuepy.summary.registry import register_metric @@ -13,6 +14,8 @@ if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData + from .spatial_lda import SpatialLDA + # Store fitted models for reuse within a session _model_cache: Dict[str, Any] = {} @@ -26,10 +29,10 @@ def _get_or_fit_model( ) -> 'SpatialLDA': """Get cached model or fit a new one.""" from .spatial_lda import SpatialLDA - + if cache_key in _model_cache: return _model_cache[cache_key] - + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, @@ -37,7 +40,7 @@ def _get_or_fit_model( ) model.fit(data) _model_cache[cache_key] = model - + return model @@ -58,20 +61,20 @@ def _lda_topic_proportions( ) -> Dict[str, float]: """Compute topic proportions based on dominant assignment.""" from .spatial_lda import SpatialLDA - + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + dominant = model.predict(data) - + result = {} for i in range(n_topics): result[f'topic_{i}_proportion'] = float(np.mean(dominant == i)) - + return result @@ -87,18 +90,18 @@ def _lda_topic_entropy( radius: float = 50.0 ) -> Dict[str, float]: """Compute mean topic assignment entropy.""" - from .spatial_lda import SpatialLDA from .analysis import topic_assignment_uncertainty - + from .spatial_lda import SpatialLDA + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + entropy = topic_assignment_uncertainty(model, data) - + return { 'lda_mean_entropy': float(np.mean(entropy)), 'lda_max_entropy': float(np.max(entropy)), @@ -118,17 +121,17 @@ def _lda_dominant_confidence( ) -> Dict[str, float]: """Compute mean confidence of dominant topic.""" from .spatial_lda import SpatialLDA - + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + weights = model.transform(data) confidence = np.max(weights, axis=1) - + return { 'lda_mean_confidence': float(np.mean(confidence)), 'lda_min_confidence': float(np.min(confidence)), @@ -151,18 +154,18 @@ def _lda_diversity( radius: float = 50.0 ) -> Dict[str, float]: """Compute topic diversity.""" - from .spatial_lda import SpatialLDA from .metrics import topic_diversity - + from .spatial_lda import SpatialLDA + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + diversity = topic_diversity(model) - + return {'lda_diversity': diversity} @@ -178,18 +181,18 @@ def _lda_spatial_consistency( radius: float = 50.0 ) -> Dict[str, float]: """Compute spatial consistency metrics.""" - from .spatial_lda import SpatialLDA from .metrics import spatial_topic_consistency - + from .spatial_lda import SpatialLDA + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + consistency = spatial_topic_consistency(model, data, radius) - + return { 'lda_agreement_rate': consistency['agreement_rate'], 'lda_topic_autocorrelation': consistency['topic_autocorrelation'], @@ -209,16 +212,16 @@ def _lda_perplexity( ) -> Dict[str, float]: """Compute model perplexity.""" from .spatial_lda import SpatialLDA - + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + perplexity = model.perplexity(data) - + return {'lda_perplexity': perplexity} @@ -239,23 +242,23 @@ def _lda_max_enrichment( radius: float = 50.0 ) -> Dict[str, float]: """Compute max enrichment of a cell type across topics.""" - from .spatial_lda import SpatialLDA from .analysis import topic_enrichment - + from .spatial_lda import SpatialLDA + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + enrichment = topic_enrichment(model) - + if cell_type in enrichment.columns: max_enrich = enrichment[cell_type].max() else: max_enrich = np.nan - + return {f'lda_max_enrichment_{cell_type}': float(max_enrich)} @@ -271,22 +274,22 @@ def _lda_topic_concentration( radius: float = 50.0 ) -> Dict[str, float]: """Compute topic concentration indices.""" - from .spatial_lda import SpatialLDA from .metrics import topic_concentration_index - + from .spatial_lda import SpatialLDA + model = SpatialLDA( n_topics=n_topics, neighborhood_radius=radius, random_state=42 ) model.fit(data) - + concentration = topic_concentration_index(model, data) - + result = {} for topic_idx, conc in concentration.items(): result[f'lda_topic_{topic_idx}_concentration'] = float(conc) - + result['lda_mean_concentration'] = float(np.mean(list(concentration.values()))) - + return result diff --git a/spatialtissuepy/mcp/serialization.py b/spatialtissuepy/mcp/serialization.py index 854f3a4..a20a7f8 100644 --- a/spatialtissuepy/mcp/serialization.py +++ b/spatialtissuepy/mcp/serialization.py @@ -9,15 +9,15 @@ import base64 import io -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Union import numpy as np import pandas as pd if TYPE_CHECKING: from spatialtissuepy.lda import SpatialLDA - from spatialtissuepy.topology import MapperResult from spatialtissuepy.network import CellGraph + from spatialtissuepy.topology import MapperResult class MCPSerializer: @@ -219,7 +219,7 @@ def serialize_result(self, obj: Any) -> Any: def serialize_graph( - graph: "CellGraph", + graph: CellGraph, params: Optional[Dict] = None, ) -> Dict[str, Any]: """ @@ -240,7 +240,7 @@ def serialize_graph( JSON-serializable graph representation. """ try: - import networkx as nx + import networkx as nx # noqa: F401 (optional dependency probe) except ImportError: raise ImportError("networkx required for graph serialization") @@ -299,7 +299,7 @@ def serialize_graph( } -def deserialize_graph(data: Dict[str, Any]) -> "CellGraph": +def deserialize_graph(data: Dict[str, Any]) -> CellGraph: """ Reconstruct NetworkX graph from serialized data. @@ -344,7 +344,7 @@ def deserialize_graph(data: Dict[str, Any]) -> "CellGraph": def serialize_model( - model: Union["SpatialLDA", "MapperResult", Any], + model: Union[SpatialLDA, MapperResult, Any], model_type: str, ) -> Dict[str, Any]: """ @@ -377,10 +377,10 @@ def serialize_model( components = getattr(model, "topic_cell_type_matrix_", None) if components is None and hasattr(model, "_lda_model") and model._lda_model is not None: components = getattr(model._lda_model, "components_", None) - + if components is not None: result["components"] = components.tolist() - + cell_types = getattr(model, "cell_types_", None) if cell_types is not None: result["cell_types"] = list(cell_types) @@ -426,7 +426,7 @@ def serialize_model( def deserialize_model( data: Dict[str, Any], -) -> Union["SpatialLDA", "MapperResult", Dict]: +) -> Union[SpatialLDA, MapperResult, Dict]: """ Reconstruct model from serialized data. @@ -444,9 +444,10 @@ def deserialize_model( if model_type == "spatial_lda": try: - from spatialtissuepy.lda import SpatialLDA from sklearn.decomposition import LatentDirichletAllocation + from spatialtissuepy.lda import SpatialLDA + model = SpatialLDA( n_topics=data["n_topics"], neighborhood_radius=data.get("neighborhood_radius", 50.0), @@ -457,7 +458,7 @@ def deserialize_model( if data.get("components"): components = np.array(data["components"]) model.topic_cell_type_matrix_ = components - + # Reconstruct sklearn model for transformation lda_model = LatentDirichletAllocation( n_components=data["n_topics"], @@ -465,10 +466,10 @@ def deserialize_model( ) lda_model.components_ = components model._lda_model = lda_model - + if data.get("cell_types"): model.cell_types_ = list(data["cell_types"]) - + if data.get("is_fitted"): model._is_fitted = True @@ -478,7 +479,7 @@ def deserialize_model( elif model_type == "mapper_result": try: - from spatialtissuepy.topology import MapperResult, MapperNode, MapperEdge + from spatialtissuepy.topology import MapperEdge, MapperNode, MapperResult nodes = [] for n in data.get("nodes", []): diff --git a/spatialtissuepy/mcp/server.py b/spatialtissuepy/mcp/server.py index f8b64a5..b2d0220 100644 --- a/spatialtissuepy/mcp/server.py +++ b/spatialtissuepy/mcp/server.py @@ -8,12 +8,12 @@ import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from fastmcp import FastMCP -from .session import SessionManager from .serialization import MCPSerializer +from .session import SessionManager if TYPE_CHECKING: pass diff --git a/spatialtissuepy/mcp/session.py b/spatialtissuepy/mcp/session.py index b596948..4115d85 100644 --- a/spatialtissuepy/mcp/session.py +++ b/spatialtissuepy/mcp/session.py @@ -30,8 +30,8 @@ if TYPE_CHECKING: from spatialtissuepy import SpatialTissueData - from spatialtissuepy.network import CellGraph from spatialtissuepy.lda import SpatialLDA + from spatialtissuepy.network import CellGraph from spatialtissuepy.topology import MapperResult @@ -175,7 +175,7 @@ def store_data( self, session_id: str, key: str, - data: "SpatialTissueData", + data: SpatialTissueData, ) -> None: """ Store SpatialTissueData object. @@ -203,7 +203,7 @@ def load_data( self, session_id: str, key: str, - ) -> Optional["SpatialTissueData"]: + ) -> Optional[SpatialTissueData]: """ Load SpatialTissueData object. @@ -249,7 +249,7 @@ def store_graph( self, session_id: str, key: str, - graph: "CellGraph", + graph: CellGraph, params: Optional[Dict] = None, ) -> None: """ @@ -283,7 +283,7 @@ def load_graph( self, session_id: str, key: str, - ) -> Optional["CellGraph"]: + ) -> Optional[CellGraph]: """ Load graph from storage. @@ -303,7 +303,7 @@ def load_graph( path = self.base_dir / session_id / "graphs" / f"{key}.json" if path.exists(): - with open(path, "r") as f: + with open(path) as f: data = json.load(f) self._touch_session(session_id) return deserialize_graph(data) @@ -320,7 +320,7 @@ def store_model( self, session_id: str, key: str, - model: Union["SpatialLDA", "MapperResult"], + model: Union[SpatialLDA, MapperResult], model_type: str, ) -> None: """ @@ -354,7 +354,7 @@ def load_model( self, session_id: str, key: str, - ) -> Optional[Union["SpatialLDA", "MapperResult", Dict]]: + ) -> Optional[Union[SpatialLDA, MapperResult, Dict]]: """ Load model from storage. @@ -374,7 +374,7 @@ def load_model( path = self.base_dir / session_id / "models" / f"{key}.json" if path.exists(): - with open(path, "r") as f: + with open(path) as f: data = json.load(f) self._touch_session(session_id) return deserialize_model(data) @@ -492,7 +492,7 @@ def _load_metadata(self, session_id: str) -> Optional[SessionMetadata]: path = self.base_dir / session_id / "metadata.json" if path.exists(): - with open(path, "r") as f: + with open(path) as f: data = json.load(f) # Handle missing fields for backwards compatibility data.setdefault("panel_keys", []) diff --git a/spatialtissuepy/mcp/tools/__init__.py b/spatialtissuepy/mcp/tools/__init__.py index 0b35f02..a67e24a 100644 --- a/spatialtissuepy/mcp/tools/__init__.py +++ b/spatialtissuepy/mcp/tools/__init__.py @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -def register_all_tools(mcp: "FastMCP") -> None: +def register_all_tools(mcp: FastMCP) -> None: """ Register all spatialtissuepy tools with the MCP server. diff --git a/spatialtissuepy/mcp/tools/data.py b/spatialtissuepy/mcp/tools/data.py index 7e405c6..83f6ac7 100644 --- a/spatialtissuepy/mcp/tools/data.py +++ b/spatialtissuepy/mcp/tools/data.py @@ -112,7 +112,7 @@ class MarkerInfo(BaseModel): # --- Tool Registration --- -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register data tools with the MCP server.""" @mcp.tool() @@ -171,6 +171,7 @@ def data_load_csv( data_load_csv("/path/to/cohort.csv", sample_col="patient_id") """ from spatialtissuepy.io import read_csv + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() @@ -254,6 +255,7 @@ def data_load_json( Summary of the loaded dataset. """ from spatialtissuepy.io import read_json + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() @@ -317,6 +319,7 @@ def data_save_csv( Confirmation with file path and row count. """ from spatialtissuepy.io import write_csv + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() @@ -358,6 +361,7 @@ def data_save_json( Confirmation with file path and cell count. """ from spatialtissuepy.io import write_json + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() @@ -609,7 +613,7 @@ def data_subset_by_type( raise ValueError(f"No data found with key '{data_key}'") # Get indices for specified cell types - mask = data.cell_types.isin(cell_types) if hasattr(data.cell_types, 'isin') else [ct in cell_types for ct in data.cell_types] + data.cell_types.isin(cell_types) if hasattr(data.cell_types, 'isin') else [ct in cell_types for ct in data.cell_types] subset = data.subset(cell_types=cell_types) out_key = output_key or data_key diff --git a/spatialtissuepy/mcp/tools/lda.py b/spatialtissuepy/mcp/tools/lda.py index 204c2ae..0f485f1 100644 --- a/spatialtissuepy/mcp/tools/lda.py +++ b/spatialtissuepy/mcp/tools/lda.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional import numpy as np from pydantic import BaseModel, Field @@ -106,7 +106,7 @@ class ModelSelectionResult(BaseModel): best_coherence: float -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register LDA tools with the MCP server.""" @mcp.tool() @@ -146,6 +146,7 @@ def lda_fit( Information about the fitted model. """ from spatialtissuepy.lda import SpatialLDA + from ..server import get_session_manager session_mgr = get_session_manager() @@ -342,6 +343,7 @@ def lda_topic_coherence( Coherence score. """ from spatialtissuepy.lda import topic_coherence + from ..server import get_session_manager session_mgr = get_session_manager() @@ -385,6 +387,7 @@ def lda_topic_diversity( Diversity score. """ from spatialtissuepy.lda import topic_diversity + from ..server import get_session_manager session_mgr = get_session_manager() @@ -430,6 +433,7 @@ def lda_topic_spatial_consistency( Consistency score overall and per topic. """ from spatialtissuepy.lda import spatial_topic_consistency + from ..server import get_session_manager session_mgr = get_session_manager() @@ -489,6 +493,7 @@ def lda_select_n_topics( Best n_topics and coherence scores. """ from spatialtissuepy.lda import SpatialLDA, topic_coherence + from ..server import get_session_manager session_mgr = get_session_manager() diff --git a/spatialtissuepy/mcp/tools/network.py b/spatialtissuepy/mcp/tools/network.py index a363a0a..551e76d 100644 --- a/spatialtissuepy/mcp/tools/network.py +++ b/spatialtissuepy/mcp/tools/network.py @@ -112,6 +112,7 @@ class ComponentsResult(BaseModel): def _compute_clustering(session_id: str, graph_key: str) -> ClusteringResult: """Internal helper to compute clustering coefficients.""" import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -138,7 +139,7 @@ def _compute_clustering(session_id: str, graph_key: str) -> ClusteringResult: # --- Tool Registration --- -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register network tools with the MCP server.""" @mcp.tool() @@ -170,6 +171,7 @@ def network_build_proximity_graph( Information about the constructed graph. """ from spatialtissuepy.network import build_proximity_graph + from ..server import get_session_manager session_mgr = get_session_manager() @@ -238,6 +240,7 @@ def network_build_knn_graph( Information about the constructed graph. """ from spatialtissuepy.network import build_knn_graph + from ..server import get_session_manager session_mgr = get_session_manager() @@ -300,6 +303,7 @@ def network_build_delaunay_graph( Information about the constructed graph. """ from spatialtissuepy.network import build_delaunay_graph + from ..server import get_session_manager session_mgr = get_session_manager() @@ -361,6 +365,7 @@ def network_build_gabriel_graph( Information about the constructed graph. """ from spatialtissuepy.network import build_gabriel_graph + from ..server import get_session_manager session_mgr = get_session_manager() @@ -418,6 +423,7 @@ def network_degree_centrality( Centrality statistics and top nodes. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -472,6 +478,7 @@ def network_betweenness_centrality( Centrality statistics and top nodes. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -522,6 +529,7 @@ def network_closeness_centrality( Centrality statistics and top nodes. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -575,6 +583,7 @@ def network_eigenvector_centrality( Centrality statistics and top nodes. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -684,6 +693,7 @@ def network_type_assortativity( Assortativity coefficient and interpretation. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -746,6 +756,7 @@ def network_degree_assortativity( Degree assortativity coefficient. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -806,6 +817,7 @@ def network_attribute_mixing_matrix( Mixing matrix between cell types. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() @@ -857,6 +869,7 @@ def network_connected_components( Component count and sizes. """ import networkx as nx + from ..server import get_session_manager session_mgr = get_session_manager() diff --git a/spatialtissuepy/mcp/tools/spatial.py b/spatialtissuepy/mcp/tools/spatial.py index d06c1d9..e899212 100644 --- a/spatialtissuepy/mcp/tools/spatial.py +++ b/spatialtissuepy/mcp/tools/spatial.py @@ -15,7 +15,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from pydantic import BaseModel, Field @@ -118,7 +118,7 @@ class VoronoiResult(BaseModel): # --- Tool Registration --- -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register spatial tools with the MCP server.""" @mcp.tool() @@ -151,6 +151,7 @@ def spatial_pairwise_distances( """ import numpy as np from scipy.spatial.distance import pdist, squareform + from ..server import get_session_manager session_mgr = get_session_manager() @@ -216,7 +217,9 @@ def spatial_nearest_neighbors( Summary of nearest neighbor distances. """ import numpy as np + from spatialtissuepy.spatial import nearest_neighbors + from ..server import get_session_manager session_mgr = get_session_manager() @@ -279,7 +282,9 @@ def spatial_radius_neighbors( Summary of neighbor counts per cell. """ import numpy as np + from spatialtissuepy.spatial import radius_neighbors + from ..server import get_session_manager session_mgr = get_session_manager() @@ -338,7 +343,9 @@ def spatial_density( Summary statistics of cell densities. """ import numpy as np + from spatialtissuepy.spatial import radius_neighbors + from ..server import get_session_manager session_mgr = get_session_manager() @@ -392,7 +399,9 @@ def spatial_boundary_cells( Information about boundary cells. """ import numpy as np + from spatialtissuepy.spatial import boundary_cells + from ..server import get_session_manager session_mgr = get_session_manager() @@ -437,8 +446,8 @@ def spatial_convex_hull( ConvexHullResult Hull area, perimeter, and vertices. """ - import numpy as np from scipy.spatial import ConvexHull + from ..server import get_session_manager session_mgr = get_session_manager() @@ -495,6 +504,7 @@ def spatial_voronoi_areas( """ import numpy as np from scipy.spatial import Voronoi + from ..server import get_session_manager session_mgr = get_session_manager() diff --git a/spatialtissuepy/mcp/tools/statistics.py b/spatialtissuepy/mcp/tools/statistics.py index 2acef8a..32fa5de 100644 --- a/spatialtissuepy/mcp/tools/statistics.py +++ b/spatialtissuepy/mcp/tools/statistics.py @@ -18,7 +18,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, List, Optional import numpy as np from pydantic import BaseModel, Field @@ -149,7 +149,9 @@ def _compute_ripleys( ) -> RipleysResult: """Internal helper to compute Ripley's K/L/H functions.""" import numpy as np - from spatialtissuepy.statistics import ripleys_k, ripleys_l, ripleys_h + + from spatialtissuepy.statistics import ripleys_h, ripleys_k, ripleys_l + from ..server import get_session_manager session_mgr = get_session_manager() @@ -221,7 +223,7 @@ def _compute_ripleys( # --- Tool Registration --- -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register statistics tools with the MCP server.""" @mcp.tool() @@ -394,6 +396,7 @@ def statistics_colocalization_quotient( CLQ value and interpretation. """ from spatialtissuepy.statistics import colocalization_quotient + from ..server import get_session_manager session_mgr = get_session_manager() @@ -469,6 +472,7 @@ def statistics_cross_k( Cross-K values and interpretation. """ from spatialtissuepy.statistics import cross_k + from ..server import get_session_manager session_mgr = get_session_manager() @@ -554,8 +558,10 @@ def statistics_getis_ord_gi_star( HotspotResult Hotspot and coldspot counts. """ - from spatialtissuepy.statistics import getis_ord_gi_star from scipy import stats + + from spatialtissuepy.statistics import getis_ord_gi_star + from ..server import get_session_manager session_mgr = get_session_manager() @@ -627,6 +633,7 @@ def statistics_morans_i( Moran's I statistic and significance. """ from spatialtissuepy.statistics import morans_i + from ..server import get_session_manager session_mgr = get_session_manager() @@ -645,12 +652,12 @@ def statistics_morans_i( # 'I', 'expected', 'variance', 'zscore', 'pvalue'. Fall back to # legacy aliases for forward/backward compatibility. if isinstance(result, dict): - I = result.get("I", result.get("morans_i", 0)) + I = result.get("I", result.get("morans_i", 0)) # noqa: E741 (Moran's I) E_I = result.get("expected", result.get("expected_I", -1 / (data.n_cells - 1))) z = result.get("zscore", result.get("z_score", 0)) p = result.get("pvalue", result.get("p_value", 1)) else: - I = float(result) + I = float(result) # noqa: E741 (Moran's I) E_I = -1 / (data.n_cells - 1) z = 0 p = 1 @@ -715,6 +722,7 @@ def statistics_pair_correlation( g(r) values and interpretation. """ from spatialtissuepy.statistics import pair_correlation_function + from ..server import get_session_manager session_mgr = get_session_manager() @@ -798,8 +806,9 @@ def statistics_nearest_neighbor_g( GFunctionResult G function values and mean NN distance. """ - from spatialtissuepy.statistics import g_function from spatialtissuepy.spatial import nearest_neighbors + from spatialtissuepy.statistics import g_function + from ..server import get_session_manager session_mgr = get_session_manager() @@ -886,5 +895,5 @@ def statistics_mark_correlation( raise NotImplementedError( "mark_correlation is not yet implemented in the spatialtissuepy " "library. As an alternative, use statistics_morans_i to measure " - "spatial autocorrelation of marker '%s'." % marker + f"spatial autocorrelation of marker '{marker}'." ) diff --git a/spatialtissuepy/mcp/tools/summary.py b/spatialtissuepy/mcp/tools/summary.py index 4ed18ae..97ecadd 100644 --- a/spatialtissuepy/mcp/tools/summary.py +++ b/spatialtissuepy/mcp/tools/summary.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel if TYPE_CHECKING: from fastmcp import FastMCP @@ -73,7 +73,7 @@ class MultiSampleResult(BaseModel): feature_names: List[str] -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register summary tools with the MCP server.""" @mcp.tool() @@ -105,6 +105,7 @@ def summary_create_panel( Information about the created panel. """ from spatialtissuepy.summary import StatisticsPanel, load_panel + from ..server import get_session_manager session_mgr = get_session_manager() @@ -189,7 +190,7 @@ def summary_list_available_metrics( MetricsList Available metrics and categories. """ - from spatialtissuepy.summary import list_metrics, list_categories, get_metric + from spatialtissuepy.summary import get_metric, list_categories, list_metrics categories = list_categories() all_metrics = list_metrics(category=category) @@ -242,6 +243,7 @@ def summary_compute( Computed features as dictionary. """ from spatialtissuepy.summary import SpatialSummary + from ..server import get_session_manager session_mgr = get_session_manager() @@ -292,6 +294,7 @@ def summary_to_dict( # summary_compute() tool -- FastMCP wraps @mcp.tool() functions in # a FunctionTool object that is not directly callable. from spatialtissuepy.summary import SpatialSummary + from ..server import get_session_manager session_mgr = get_session_manager() @@ -334,6 +337,7 @@ def summary_to_array( Feature array and names. """ from spatialtissuepy.summary import SpatialSummary + from ..server import get_session_manager session_mgr = get_session_manager() @@ -382,6 +386,7 @@ def summary_multi_sample( Summary of computed features. """ from spatialtissuepy.summary import MultiSampleSummary + from ..server import get_session_manager session_mgr = get_session_manager() @@ -441,7 +446,8 @@ def summary_multi_sample_to_dataframe( DataFrame-like structure with samples as rows. """ from spatialtissuepy.summary import MultiSampleSummary - from ..server import get_session_manager, get_serializer + + from ..server import get_serializer, get_session_manager session_mgr = get_session_manager() serializer = get_serializer() diff --git a/spatialtissuepy/mcp/tools/synthetic.py b/spatialtissuepy/mcp/tools/synthetic.py index 60e3c4b..40f7cf9 100644 --- a/spatialtissuepy/mcp/tools/synthetic.py +++ b/spatialtissuepy/mcp/tools/synthetic.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel if TYPE_CHECKING: from fastmcp import FastMCP @@ -95,7 +95,7 @@ class ExperimentInfo(BaseModel): simulation_names: List[str] -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register synthetic tools with the MCP server.""" @mcp.tool() @@ -121,9 +121,11 @@ def synthetic_load_physicell_simulation( SimulationInfo Information about the loaded simulation. """ + import pickle + from spatialtissuepy.synthetic import PhysiCellSimulation + from ..server import get_session_manager, resolve_data_path - import pickle session_mgr = get_session_manager() session_id = session_mgr.get_or_create_session(session_id) @@ -178,6 +180,7 @@ def synthetic_load_physicell_timestep( Information about the timestep. """ from spatialtissuepy.synthetic import read_physicell_timestep + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() @@ -219,9 +222,10 @@ def synthetic_list_physicell_timesteps( TimestepList Available timesteps with basic info. """ - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -273,9 +277,10 @@ def synthetic_get_timestep( TimestepInfo Timestep information. """ - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -329,9 +334,10 @@ def synthetic_timestep_to_spatial_data( # Call the underlying logic directly rather than the decorated # synthetic_get_timestep() tool -- FastMCP wraps @mcp.tool() functions # in a FunctionTool object that is not directly callable. - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -374,9 +380,10 @@ def synthetic_cell_count_trajectory( TrajectoryResult Cell counts over time, total and by type. """ - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -426,9 +433,10 @@ def synthetic_type_proportions_trajectory( ProportionsResult Proportions over time by type. """ - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -486,9 +494,10 @@ def synthetic_summarize_simulation( SummarizeResult Summary statistics over time. """ - from ..server import get_session_manager import pickle + from ..server import get_session_manager + session_mgr = get_session_manager() panel = session_mgr.load_panel(session_id, panel_key) @@ -537,9 +546,11 @@ def synthetic_load_physicell_experiment( ExperimentInfo Information about the experiment. """ + import pickle + from spatialtissuepy.synthetic import PhysiCellExperiment + from ..server import get_session_manager, resolve_data_path - import pickle session_mgr = get_session_manager() session_id = session_mgr.get_or_create_session(session_id) diff --git a/spatialtissuepy/mcp/tools/topology.py b/spatialtissuepy/mcp/tools/topology.py index c709cdc..58653c8 100644 --- a/spatialtissuepy/mcp/tools/topology.py +++ b/spatialtissuepy/mcp/tools/topology.py @@ -89,7 +89,7 @@ class FilterResult(BaseModel): mean_value: float -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register topology tools with the MCP server.""" @mcp.tool() @@ -137,6 +137,7 @@ def topology_run_mapper( Information about the Mapper graph. """ from spatialtissuepy.topology import spatial_mapper + from ..server import get_session_manager session_mgr = get_session_manager() @@ -195,6 +196,7 @@ def topology_density_filter( Statistics of filter values. """ from spatialtissuepy.topology import density_filter + from ..server import get_session_manager session_mgr = get_session_manager() @@ -238,6 +240,7 @@ def topology_eccentricity_filter( Statistics of filter values. """ from spatialtissuepy.topology import eccentricity_filter + from ..server import get_session_manager session_mgr = get_session_manager() @@ -284,6 +287,7 @@ def topology_pca_filter( Statistics of filter values. """ from spatialtissuepy.topology import pca_filter + from ..server import get_session_manager session_mgr = get_session_manager() @@ -330,6 +334,7 @@ def topology_distance_to_type_filter( Statistics of filter values. """ from spatialtissuepy.topology import distance_to_type_filter + from ..server import get_session_manager session_mgr = get_session_manager() @@ -374,6 +379,7 @@ def topology_radial_filter( Statistics of filter values. """ from spatialtissuepy.topology import radial_filter + from ..server import get_session_manager session_mgr = get_session_manager() diff --git a/spatialtissuepy/mcp/tools/viz.py b/spatialtissuepy/mcp/tools/viz.py index 4bb88c6..afd96b7 100644 --- a/spatialtissuepy/mcp/tools/viz.py +++ b/spatialtissuepy/mcp/tools/viz.py @@ -26,9 +26,10 @@ from __future__ import annotations import matplotlib + matplotlib.use("Agg") # Non-interactive backend -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, List, Optional import matplotlib.pyplot as plt import numpy as np @@ -56,7 +57,7 @@ class SaveResult(BaseModel): success: bool -def register_tools(mcp: "FastMCP") -> None: +def register_tools(mcp: FastMCP) -> None: """Register visualization tools with the MCP server.""" @mcp.tool() @@ -99,8 +100,9 @@ def viz_plot_spatial_scatter( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_spatial_scatter - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -160,8 +162,9 @@ def viz_plot_cell_types( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_cell_types - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -213,8 +216,9 @@ def viz_plot_density_map( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_density_map - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -270,8 +274,9 @@ def viz_plot_marker_expression( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_marker_expression - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -324,8 +329,9 @@ def viz_plot_voronoi( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_voronoi - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -384,8 +390,9 @@ def viz_plot_ripleys_curve( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_ripleys_curve - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -447,8 +454,9 @@ def viz_plot_colocalization_heatmap( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_colocalization_heatmap - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -501,8 +509,9 @@ def viz_plot_hotspot_map( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_hotspot_map - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -552,8 +561,9 @@ def viz_plot_neighborhood_enrichment( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_neighborhood_enrichment - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() data = session_mgr.load_data(session_id, data_key) @@ -609,8 +619,9 @@ def viz_plot_network( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_cell_graph - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() graph = session_mgr.load_graph(session_id, graph_key) @@ -665,8 +676,9 @@ def viz_plot_degree_distribution( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_degree_distribution - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() graph = session_mgr.load_graph(session_id, graph_key) @@ -716,8 +728,9 @@ def viz_plot_mixing_matrix( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_mixing_matrix - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() graph = session_mgr.load_graph(session_id, graph_key) @@ -767,8 +780,9 @@ def viz_plot_topic_composition( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_topic_composition - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() model = session_mgr.load_model(session_id, model_key) @@ -821,8 +835,9 @@ def viz_plot_topic_spatial( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_topic_spatial - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() model = session_mgr.load_model(session_id, model_key) @@ -875,8 +890,9 @@ def viz_plot_mapper_graph( Base64-encoded PNG image. """ from spatialtissuepy.viz import plot_mapper_graph - from ..server import get_session_manager + from ..serialization import figure_to_base64 + from ..server import get_session_manager session_mgr = get_session_manager() result = session_mgr.load_model(session_id, model_key) @@ -925,11 +941,11 @@ def viz_plot_trajectory( PlotResult Base64-encoded PNG image. """ - from spatialtissuepy.viz import plot_trajectory - from ..server import get_session_manager - from ..serialization import figure_to_base64 import pickle + from ..serialization import figure_to_base64 + from ..server import get_session_manager + session_mgr = get_session_manager() sim_path = session_mgr.base_dir / session_id / "models" / f"{simulation_key}.pkl" @@ -999,6 +1015,7 @@ def viz_save_figure( File path and success status. """ from spatialtissuepy.viz import plot_spatial_scatter + from ..server import get_session_manager, resolve_data_path session_mgr = get_session_manager() diff --git a/spatialtissuepy/network/__init__.py b/spatialtissuepy/network/__init__.py index 122e35c..f764de5 100644 --- a/spatialtissuepy/network/__init__.py +++ b/spatialtissuepy/network/__init__.py @@ -27,36 +27,49 @@ Example ------- >>> from spatialtissuepy.network import CellGraph ->>> +>>> >>> # Build graph from spatial data >>> graph = CellGraph.from_spatial_data( ... data, ... method='proximity', # or 'knn', 'delaunay', 'gabriel' ... radius=30.0 ... ) ->>> +>>> >>> # Analyze centrality by cell type >>> from spatialtissuepy.network import centrality_by_type >>> stats = centrality_by_type(graph, metric='betweenness') >>> print(stats['CD8_T_cell']['mean']) ->>> +>>> >>> # Communicability between types >>> from spatialtissuepy.network import communicability_between_types >>> comm = communicability_between_types(graph, 'T_cell', 'Tumor') ->>> +>>> >>> # Mixing patterns >>> from spatialtissuepy.network import attribute_mixing_matrix >>> mixing = attribute_mixing_matrix(graph) """ # Graph construction -from .graph_construction import ( - GraphMethod, - build_graph, - build_proximity_graph, - build_knn_graph, - build_delaunay_graph, - build_gabriel_graph, +# Import metrics to register them with summary module +from . import metrics + +# Assortativity and mixing +from .assortativity import ( + attribute_mixing_dict, + attribute_mixing_matrix, + average_degree_connectivity, + average_neighbor_degree, + average_neighbor_degree_by_type, + average_node_degree, + degree_assortativity, + heterophily_ratio, + homophily_ratio, + homophily_ratio_by_cell_type, + neighbor_type_distribution, + neighbor_type_matrix, + numeric_assortativity, + type_assortativity, + type_pair_edge_fraction, ) # CellGraph class @@ -64,78 +77,64 @@ # Centrality metrics from .centrality import ( - degree_centrality, betweenness_centrality, + centrality_by_type, closeness_centrality, + degree_centrality, eigenvector_centrality, - pagerank, harmonic_centrality, katz_centrality, load_centrality, - subgraph_centrality, - centrality_by_type, mean_centrality_by_type, + pagerank, + subgraph_centrality, top_central_nodes, ) # Clustering metrics from .clustering import ( - clustering_coefficient, + articulation_points, + articulation_points_by_type, average_clustering, - transitivity, - square_clustering, - triangles, + bridges, + bridges_by_type_pair, clustering_by_type, - mean_clustering_by_type, - triangles_by_type, + clustering_coefficient, connected_components, - n_connected_components, largest_component_size, - bridges, - articulation_points, - articulation_points_by_type, - bridges_by_type_pair, + mean_clustering_by_type, + n_connected_components, + square_clustering, + transitivity, + triangles, + triangles_by_type, ) # Communicability and path metrics from .communicability import ( + average_shortest_path_length, communicability, - communicability_exp, - communicability_betweenness, communicability_between_types, + communicability_betweenness, + communicability_exp, communicability_matrix_by_type, - shortest_path_length_between_types, - average_shortest_path_length, diameter, - radius, eccentricity, global_efficiency, local_efficiency, nodal_efficiency, + radius, + shortest_path_length_between_types, ) - -# Assortativity and mixing -from .assortativity import ( - degree_assortativity, - type_assortativity, - numeric_assortativity, - attribute_mixing_matrix, - attribute_mixing_dict, - homophily_ratio, - heterophily_ratio, - type_pair_edge_fraction, - average_neighbor_degree, - average_neighbor_degree_by_type, - neighbor_type_distribution, - neighbor_type_matrix, - average_degree_connectivity, - average_node_degree, - homophily_ratio_by_cell_type, +from .graph_construction import ( + GraphMethod, + build_delaunay_graph, + build_gabriel_graph, + build_graph, + build_knn_graph, + build_proximity_graph, ) -# Import metrics to register them with summary module -from . import metrics - __all__ = [ # Graph construction 'GraphMethod', diff --git a/spatialtissuepy/network/assortativity.py b/spatialtissuepy/network/assortativity.py index 8a19d3b..463970f 100644 --- a/spatialtissuepy/network/assortativity.py +++ b/spatialtissuepy/network/assortativity.py @@ -6,14 +6,17 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Any, Union + +from typing import TYPE_CHECKING, Dict, Tuple, Union + import numpy as np if TYPE_CHECKING: - from .cell_graph import CellGraph import networkx as nx import pandas as pd + from .cell_graph import CellGraph + try: import networkx as nx HAS_NETWORKX = True @@ -21,7 +24,7 @@ HAS_NETWORKX = False -def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': +def _get_nx_graph(graph: Union[CellGraph, nx.Graph]) -> nx.Graph: """Helper to extract NetworkX graph from CellGraph or return nx.Graph.""" if hasattr(graph, 'G'): return graph.G @@ -32,18 +35,18 @@ def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': # Assortativity Coefficients # ============================================================================ -def degree_assortativity(graph: Union['CellGraph', 'nx.Graph']) -> float: +def degree_assortativity(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute degree assortativity coefficient. - + Measures whether high-degree nodes connect preferentially to other high-degree nodes (positive) or low-degree nodes (negative). - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -52,18 +55,18 @@ def degree_assortativity(graph: Union['CellGraph', 'nx.Graph']) -> float: return nx.degree_assortativity_coefficient(_get_nx_graph(graph)) -def type_assortativity(graph: 'CellGraph') -> float: +def type_assortativity(graph: CellGraph) -> float: """ Compute cell type assortativity coefficient. - + Measures whether cells of the same type preferentially connect to each other (positive) or to different types (negative). - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- float @@ -73,19 +76,19 @@ def type_assortativity(graph: 'CellGraph') -> float: def numeric_assortativity( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], attribute: str ) -> float: """ Compute numeric assortativity for a node attribute. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. attribute : str Node attribute name (must be numeric). - + Returns ------- float @@ -99,27 +102,27 @@ def numeric_assortativity( # ============================================================================ def attribute_mixing_matrix( - graph: 'CellGraph', + graph: CellGraph, normalized: bool = True, -) -> 'pd.DataFrame': +) -> pd.DataFrame: """ Compute the cell type mixing matrix. - + The mixing matrix M[i,j] represents the fraction (or count) of edges connecting cell type i to cell type j. - + Parameters ---------- graph : CellGraph Input cell graph. normalized : bool, default True If True, normalize so values sum to 1. - + Returns ------- pd.DataFrame Mixing matrix with cell types as row and column labels. - + Examples -------- >>> mixing = attribute_mixing_matrix(graph) @@ -127,70 +130,70 @@ def attribute_mixing_matrix( 0.15 """ import pandas as pd - + cell_types = graph.cell_types_unique n_types = len(cell_types) type_to_idx = {t: i for i, t in enumerate(cell_types)} - + # Count edges by type pair matrix = np.zeros((n_types, n_types)) - + for i, j in graph.G.edges(): type_i = graph.cell_types[i] type_j = graph.cell_types[j] - + idx_i = type_to_idx[type_i] idx_j = type_to_idx[type_j] - + matrix[idx_i, idx_j] += 1 if idx_i != idx_j: matrix[idx_j, idx_i] += 1 # Symmetric for undirected - + if normalized and matrix.sum() > 0: matrix = matrix / matrix.sum() - + return pd.DataFrame(matrix, index=cell_types, columns=cell_types) def attribute_mixing_dict( - graph: 'CellGraph', + graph: CellGraph, normalized: bool = True, ) -> Dict[Tuple[str, str], float]: """ Compute mixing as a dictionary of type pairs. - + Parameters ---------- graph : CellGraph Input cell graph. normalized : bool, default True If True, normalize so values sum to 1. - + Returns ------- dict (type_a, type_b) to mixing value. """ mixing = nx.attribute_mixing_dict(graph.G, 'cell_type', normalized=normalized) - + # Flatten nested dict result = {} for type_a, inner in mixing.items(): for type_b, value in inner.items(): result[(type_a, type_b)] = value - + return result -def homophily_ratio(graph: 'CellGraph') -> float: +def homophily_ratio(graph: CellGraph) -> float: """ Compute homophily ratio (fraction of same-type edges). - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- float @@ -198,26 +201,26 @@ def homophily_ratio(graph: 'CellGraph') -> float: """ same_type_edges = 0 total_edges = graph.n_edges - + if total_edges == 0: return np.nan - + for i, j in graph.G.edges(): if graph.cell_types[i] == graph.cell_types[j]: same_type_edges += 1 - + return same_type_edges / total_edges -def heterophily_ratio(graph: 'CellGraph') -> float: +def heterophily_ratio(graph: CellGraph) -> float: """ Compute heterophily ratio (fraction of different-type edges). - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- float @@ -226,15 +229,15 @@ def heterophily_ratio(graph: 'CellGraph') -> float: homo = homophily_ratio(graph) return 1 - homo if not np.isnan(homo) else np.nan -def homophily_ratio_by_cell_type(graph: 'CellGraph') -> dict: +def homophily_ratio_by_cell_type(graph: CellGraph) -> dict: """ Vectorized computation of homophily ratio per cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict @@ -243,7 +246,7 @@ def homophily_ratio_by_cell_type(graph: 'CellGraph') -> dict: # 1. Extract Edges # Shape: (N_edges, 2) edges = np.array(list(graph.G.edges())) - + if len(edges) == 0: return {} @@ -251,7 +254,7 @@ def homophily_ratio_by_cell_type(graph: 'CellGraph') -> dict: # We extract the source (u) and target (v) columns u_nodes = edges[:, 0] v_nodes = edges[:, 1] - + # Efficient lookup: fast iteration in list comp, then conversion to numpy array # This works for integer or string node IDs # Assumes graph.cell_types is a dict or supports __getitem__ @@ -269,11 +272,11 @@ def homophily_ratio_by_cell_type(graph: 'CellGraph') -> dict: # 4. Calculate Numerators (Same-type connections) # Boolean mask where types match mask = (u_types == v_types) - + # Filter for types involved in same-type edges # We only need one side (u_types) since u == v same_stubs = u_types[mask] - + unique_same, counts_same = np.unique(same_stubs, return_counts=True) # Multiply by 2 because each edge counts for both nodes same_map = dict(zip(unique_same, counts_same * 2)) @@ -284,17 +287,17 @@ def homophily_ratio_by_cell_type(graph: 'CellGraph') -> dict: for c_type, total in total_map.items(): same = same_map.get(c_type, 0) ratios[c_type] = same / total - + return ratios def type_pair_edge_fraction( - graph: 'CellGraph', + graph: CellGraph, type_a: str, type_b: str, ) -> float: """ Compute fraction of edges between two specific cell types. - + Parameters ---------- graph : CellGraph @@ -303,7 +306,7 @@ def type_pair_edge_fraction( First cell type. type_b : str Second cell type. - + Returns ------- float @@ -311,13 +314,13 @@ def type_pair_edge_fraction( """ if graph.n_edges == 0: return np.nan - + count = 0 for i, j in graph.G.edges(): ti, tj = graph.cell_types[i], graph.cell_types[j] if (ti == type_a and tj == type_b) or (ti == type_b and tj == type_a): count += 1 - + return count / graph.n_edges @@ -325,15 +328,15 @@ def type_pair_edge_fraction( # Average Neighbor Degree # ============================================================================ -def average_neighbor_degree(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def average_neighbor_degree(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute average neighbor degree for all nodes. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -343,29 +346,29 @@ def average_neighbor_degree(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, def average_neighbor_degree_by_type( - graph: 'CellGraph' + graph: CellGraph ) -> Dict[str, Dict[str, float]]: """ Compute average neighbor degree statistics by cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict Cell type to statistics dict. """ and_values = average_neighbor_degree(graph) - + result = {} - + for cell_type in graph.cell_types_unique: nodes = graph.get_nodes_by_type(cell_type) values = np.array([and_values[n] for n in nodes]) - + if len(values) > 0: result[cell_type] = { 'mean': float(np.mean(values)), @@ -378,55 +381,55 @@ def average_neighbor_degree_by_type( 'std': np.nan, 'median': np.nan, } - + return result def neighbor_type_distribution( - graph: 'CellGraph', + graph: CellGraph, cell_type: str, ) -> Dict[str, float]: """ Compute the distribution of neighbor types for a given cell type. - + Parameters ---------- graph : CellGraph Input cell graph. cell_type : str Cell type to analyze. - + Returns ------- dict Neighbor type to proportion. """ nodes = graph.get_nodes_by_type(cell_type) - + neighbor_counts = {t: 0 for t in graph.cell_types_unique} total = 0 - + for node in nodes: for neighbor in graph.G.neighbors(node): neighbor_type = graph.cell_types[neighbor] neighbor_counts[neighbor_type] += 1 total += 1 - + if total == 0: return {t: np.nan for t in graph.cell_types_unique} - + return {t: count / total for t, count in neighbor_counts.items()} -def neighbor_type_matrix(graph: 'CellGraph') -> 'pd.DataFrame': +def neighbor_type_matrix(graph: CellGraph) -> pd.DataFrame: """ Compute neighbor type distribution for all cell types. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- pd.DataFrame @@ -434,13 +437,13 @@ def neighbor_type_matrix(graph: 'CellGraph') -> 'pd.DataFrame': neighbors of type i cells. """ import pandas as pd - + cell_types = graph.cell_types_unique - + data = {} for ct in cell_types: data[ct] = neighbor_type_distribution(graph, ct) - + return pd.DataFrame(data).T @@ -448,17 +451,17 @@ def neighbor_type_matrix(graph: 'CellGraph') -> 'pd.DataFrame': # Degree Connectivity # ============================================================================ -def average_degree_connectivity(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def average_degree_connectivity(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute average degree connectivity. - + This gives the average neighbor degree for nodes of each degree. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -466,9 +469,9 @@ def average_degree_connectivity(graph: Union['CellGraph', 'nx.Graph']) -> Dict[i """ return nx.average_degree_connectivity(_get_nx_graph(graph)) -def average_node_degree(graph: 'CellGraph') -> Dict[int, float]: +def average_node_degree(graph: CellGraph) -> Dict[int, float]: """ - Compute the average degree across all nodes in the graph. + Compute the average degree across all nodes in the graph. This gives an overall measure of how connected nodes in the graph are. diff --git a/spatialtissuepy/network/cell_graph.py b/spatialtissuepy/network/cell_graph.py index c45f4e6..de726df 100644 --- a/spatialtissuepy/network/cell_graph.py +++ b/spatialtissuepy/network/cell_graph.py @@ -6,27 +6,26 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union -) + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + import numpy as np -from .graph_construction import ( - GraphMethod, build_graph, _check_networkx, HAS_NETWORKX -) +from .graph_construction import GraphMethod, _check_networkx, build_graph if TYPE_CHECKING: - from spatialtissuepy.core.spatial_data import SpatialTissueData import networkx as nx + from spatialtissuepy.core.spatial_data import SpatialTissueData + class CellGraph: """ A graph representation of spatial tissue data. - + CellGraph wraps a NetworkX graph with convenience methods for analyzing cell-cell relationships in tissue samples. - + Parameters ---------- graph : nx.Graph @@ -39,7 +38,7 @@ class CellGraph: Graph construction method used. params : dict Parameters used for graph construction. - + Attributes ---------- G : nx.Graph @@ -50,55 +49,55 @@ class CellGraph: Number of edges. cell_types_unique : list Unique cell types in the graph. - + Examples -------- >>> from spatialtissuepy.network import CellGraph - >>> + >>> >>> # From SpatialTissueData >>> graph = CellGraph.from_spatial_data( ... data, ... method='proximity', ... radius=30.0 ... ) - >>> + >>> >>> # Analyze >>> print(f"Nodes: {graph.n_nodes}, Edges: {graph.n_edges}") >>> centrality = graph.degree_centrality() """ - + def __init__( self, - graph: 'nx.Graph', + graph: nx.Graph, cell_types: np.ndarray, coordinates: np.ndarray, method: str = 'unknown', params: Optional[Dict[str, Any]] = None, ): _check_networkx() - + self._G = graph self._cell_types = np.asarray(cell_types) self._coordinates = np.asarray(coordinates) self._method = method self._params = params or {} - + # Cache for computed metrics self._cache: Dict[str, Any] = {} - + @classmethod def from_spatial_data( cls, - data: 'SpatialTissueData', + data: SpatialTissueData, method: Union[str, GraphMethod] = 'proximity', radius: float = 50.0, k: int = 6, mutual_knn: bool = False, max_edge_length: Optional[float] = None, - ) -> 'CellGraph': + ) -> CellGraph: """ Create a CellGraph from SpatialTissueData. - + Parameters ---------- data : SpatialTissueData @@ -117,7 +116,7 @@ def from_spatial_data( Use mutual kNN. max_edge_length : float, optional Maximum edge length for Delaunay pruning. - + Returns ------- CellGraph @@ -125,7 +124,7 @@ def from_spatial_data( """ coordinates = data.coordinates cell_types = data.cell_types - + # Build graph G = build_graph( coordinates=coordinates, @@ -136,7 +135,7 @@ def from_spatial_data( mutual_knn=mutual_knn, max_edge_length=max_edge_length, ) - + # Store parameters method_str = method.value if isinstance(method, GraphMethod) else method params = { @@ -145,9 +144,9 @@ def from_spatial_data( 'mutual_knn': mutual_knn, 'max_edge_length': max_edge_length, } - + return cls(G, cell_types, coordinates, method=method_str, params=params) - + @classmethod def from_coordinates( cls, @@ -155,10 +154,10 @@ def from_coordinates( cell_types: np.ndarray, method: Union[str, GraphMethod] = 'proximity', **kwargs - ) -> 'CellGraph': + ) -> CellGraph: """ Create a CellGraph directly from coordinates and cell types. - + Parameters ---------- coordinates : np.ndarray @@ -169,7 +168,7 @@ def from_coordinates( Graph construction method. **kwargs Additional parameters for graph construction. - + Returns ------- CellGraph @@ -181,18 +180,18 @@ def from_coordinates( cell_types=cell_types, **kwargs ) - + method_str = method.value if isinstance(method, GraphMethod) else method - + return cls(G, cell_types, coordinates, method=method_str, params=kwargs) - + @property - def G(self) -> 'nx.Graph': + def G(self) -> nx.Graph: """Access underlying NetworkX graph (alias for backward compatibility).""" return self._G @G.setter - def G(self, value: 'nx.Graph'): + def G(self, value: nx.Graph): self._G = value @property @@ -204,61 +203,61 @@ def n_nodes(self) -> int: def n_edges(self) -> int: """Number of edges.""" return self._G.number_of_edges() - + @property def cell_types(self) -> np.ndarray: """Cell type labels.""" return self._cell_types - + @property def coordinates(self) -> np.ndarray: """Spatial coordinates.""" return self._coordinates - + @property def cell_types_unique(self) -> List[str]: """List of unique cell types.""" return list(np.unique(self._cell_types)) - + @property def method(self) -> str: """Graph construction method used.""" return self._method - + @property def density(self) -> float: """Graph density.""" import networkx as nx return nx.density(self.G) - + def get_nodes_by_type(self, cell_type: str) -> np.ndarray: """ Get node indices for a specific cell type. - + Parameters ---------- cell_type : str Cell type to filter by. - + Returns ------- np.ndarray Array of node indices. """ return np.where(self._cell_types == cell_type)[0] - + def subgraph_by_type( self, cell_types: Union[str, List[str]] - ) -> 'CellGraph': + ) -> CellGraph: """ Extract subgraph containing only specified cell types. - + Parameters ---------- cell_types : str or list of str Cell type(s) to include. - + Returns ------- CellGraph @@ -266,28 +265,28 @@ def subgraph_by_type( """ if isinstance(cell_types, str): cell_types = [cell_types] - + # Find nodes to keep mask = np.isin(self._cell_types, cell_types) nodes_to_keep = np.where(mask)[0] - + # Create subgraph import networkx as nx subG = self.G.subgraph(nodes_to_keep).copy() - + # Reindex nodes to be contiguous mapping = {old: new for new, old in enumerate(sorted(subG.nodes()))} subG = nx.relabel_nodes(subG, mapping) - + # Filter cell types and coordinates sub_cell_types = self._cell_types[mask] sub_coordinates = self._coordinates[mask] - + return CellGraph( subG, sub_cell_types, sub_coordinates, method=self._method, params=self._params ) - + def neighbors_of_type( self, node: int, @@ -295,71 +294,71 @@ def neighbors_of_type( ) -> List[int]: """ Get neighbors of a node, optionally filtered by type. - + Parameters ---------- node : int Node index. cell_type : str, optional Filter neighbors by this cell type. - + Returns ------- list of int Neighbor node indices. """ neighbors = list(self.G.neighbors(node)) - + if cell_type is not None: neighbors = [n for n in neighbors if self._cell_types[n] == cell_type] - + return neighbors - + def edge_type_counts(self) -> Dict[Tuple[str, str], int]: """ Count edges by cell type pairs. - + Returns ------- dict Keys are (type_a, type_b) tuples, values are edge counts. """ counts: Dict[Tuple[str, str], int] = {} - + for i, j in self.G.edges(): type_i = self._cell_types[i] type_j = self._cell_types[j] - + # Canonical ordering key = tuple(sorted([type_i, type_j])) counts[key] = counts.get(key, 0) + 1 - + return counts - - def to_networkx(self) -> 'nx.Graph': + + def to_networkx(self) -> nx.Graph: """ Return the underlying NetworkX graph. - + Returns ------- nx.Graph Copy of the NetworkX graph. """ return self.G.copy() - + def clear_cache(self) -> None: """Clear cached computations.""" self._cache.clear() - + def __repr__(self) -> str: return ( f"CellGraph(n_nodes={self.n_nodes}, n_edges={self.n_edges}, " f"method={self._method!r})" ) - + def __str__(self) -> str: lines = [ - f"CellGraph", + "CellGraph", f" Nodes: {self.n_nodes}", f" Edges: {self.n_edges}", f" Density: {self.density:.4f}", diff --git a/spatialtissuepy/network/centrality.py b/spatialtissuepy/network/centrality.py index 3badd66..79c8f0f 100644 --- a/spatialtissuepy/network/centrality.py +++ b/spatialtissuepy/network/centrality.py @@ -6,15 +6,16 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union -) + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + import numpy as np if TYPE_CHECKING: - from .cell_graph import CellGraph import networkx as nx + from .cell_graph import CellGraph + try: import networkx as nx HAS_NETWORKX = True @@ -22,7 +23,7 @@ HAS_NETWORKX = False -def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': +def _get_nx_graph(graph: Union[CellGraph, nx.Graph]) -> nx.Graph: """Helper to extract NetworkX graph from CellGraph or return nx.Graph.""" if hasattr(graph, 'G'): return graph.G @@ -33,17 +34,17 @@ def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': # Core Centrality Functions # ============================================================================ -def degree_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def degree_centrality(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute degree centrality for all nodes. - + Degree centrality is the fraction of nodes a node is connected to. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -53,17 +54,17 @@ def degree_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float] def betweenness_centrality( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], k: Optional[int] = None, normalized: bool = True, seed: Optional[int] = None, ) -> Dict[int, float]: """ Compute betweenness centrality for all nodes. - + Betweenness centrality measures how often a node lies on shortest paths between other nodes. High betweenness indicates "bridge" cells. - + Parameters ---------- graph : CellGraph or nx.Graph @@ -75,7 +76,7 @@ def betweenness_centrality( Normalize by 2/((n-1)(n-2)) for undirected graphs. seed : int, optional Random seed for sampling (if k is specified). - + Returns ------- dict @@ -87,21 +88,21 @@ def betweenness_centrality( def closeness_centrality( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], wf_improved: bool = True, ) -> Dict[int, float]: """ Compute closeness centrality for all nodes. - + Closeness centrality measures how close a node is to all other nodes. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. wf_improved : bool, default True Use Wasserman-Faust improved formula for disconnected graphs. - + Returns ------- dict @@ -111,16 +112,16 @@ def closeness_centrality( def eigenvector_centrality( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], max_iter: int = 100, tol: float = 1e-6, ) -> Dict[int, float]: """ Compute eigenvector centrality for all nodes. - + A node has high eigenvector centrality if it is connected to other nodes that themselves have high centrality. - + Parameters ---------- graph : CellGraph or nx.Graph @@ -129,7 +130,7 @@ def eigenvector_centrality( Maximum iterations for power method. tol : float, default 1e-6 Convergence tolerance. - + Returns ------- dict @@ -144,15 +145,15 @@ def eigenvector_centrality( def pagerank( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], alpha: float = 0.85, max_iter: int = 100, ) -> Dict[int, float]: """ Compute PageRank centrality for all nodes. - + PageRank is a variant of eigenvector centrality with damping. - + Parameters ---------- graph : CellGraph or nx.Graph @@ -161,7 +162,7 @@ def pagerank( Damping factor. max_iter : int, default 100 Maximum iterations. - + Returns ------- dict @@ -170,18 +171,18 @@ def pagerank( return nx.pagerank(_get_nx_graph(graph), alpha=alpha, max_iter=max_iter) -def harmonic_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def harmonic_centrality(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute harmonic centrality for all nodes. - + Harmonic centrality is the sum of reciprocal distances, which handles disconnected components better than closeness centrality. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -191,16 +192,16 @@ def harmonic_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, floa def katz_centrality( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], alpha: float = 0.1, beta: float = 1.0, ) -> Dict[int, float]: """ Compute Katz centrality for all nodes. - + Katz centrality computes influence based on total walks, with attenuation factor alpha. - + Parameters ---------- graph : CellGraph or nx.Graph @@ -209,7 +210,7 @@ def katz_centrality( Attenuation factor (should be < 1/lambda_max). beta : float, default 1.0 Weight for immediate neighbors. - + Returns ------- dict @@ -219,22 +220,22 @@ def katz_centrality( def load_centrality( - graph: Union['CellGraph', 'nx.Graph'], + graph: Union[CellGraph, nx.Graph], normalized: bool = True, ) -> Dict[int, float]: """ Compute load centrality for all nodes. - + Load centrality counts the fraction of shortest paths that pass through a node, weighted by path endpoints. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. normalized : bool, default True Normalize values. - + Returns ------- dict @@ -243,18 +244,18 @@ def load_centrality( return nx.load_centrality(_get_nx_graph(graph), normalized=normalized) -def subgraph_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def subgraph_centrality(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute subgraph centrality for all nodes. - + Subgraph centrality counts closed walks of all lengths starting and ending at a node. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -268,13 +269,13 @@ def subgraph_centrality(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, floa # ============================================================================ def centrality_by_type( - graph: 'CellGraph', + graph: CellGraph, metric: str = 'degree', **kwargs ) -> Dict[str, Dict[str, float]]: """ Compute centrality statistics grouped by cell type. - + Parameters ---------- graph : CellGraph @@ -284,13 +285,13 @@ def centrality_by_type( 'eigenvector', 'pagerank', 'harmonic', 'katz', 'load'. **kwargs Additional arguments for the centrality function. - + Returns ------- dict Dictionary mapping cell type to statistics dict containing 'mean', 'std', 'median', 'min', 'max'. - + Examples -------- >>> stats = centrality_by_type(graph, metric='betweenness') @@ -309,23 +310,22 @@ def centrality_by_type( 'load': load_centrality, 'subgraph': subgraph_centrality, } - + if metric not in centrality_funcs: raise ValueError( f"Unknown centrality metric: {metric}. " f"Options: {list(centrality_funcs.keys())}" ) - + centrality = centrality_funcs[metric](graph, **kwargs) - + # Group by cell type result = {} - cell_types = graph.cell_types - + for cell_type in graph.cell_types_unique: nodes = graph.get_nodes_by_type(cell_type) values = np.array([centrality[n] for n in nodes]) - + if len(values) > 0: result[cell_type] = { 'mean': float(np.mean(values)), @@ -344,18 +344,18 @@ def centrality_by_type( 'max': np.nan, 'count': 0, } - + return result def mean_centrality_by_type( - graph: 'CellGraph', + graph: CellGraph, metric: str = 'degree', **kwargs ) -> Dict[str, float]: """ Compute mean centrality for each cell type. - + Parameters ---------- graph : CellGraph @@ -364,7 +364,7 @@ def mean_centrality_by_type( Centrality metric. **kwargs Additional arguments for the centrality function. - + Returns ------- dict @@ -375,7 +375,7 @@ def mean_centrality_by_type( def top_central_nodes( - graph: 'CellGraph', + graph: CellGraph, metric: str = 'degree', n: int = 10, cell_type: Optional[str] = None, @@ -383,7 +383,7 @@ def top_central_nodes( ) -> List[Dict[str, Any]]: """ Get the top N most central nodes. - + Parameters ---------- graph : CellGraph @@ -396,7 +396,7 @@ def top_central_nodes( Filter to specific cell type. **kwargs Additional arguments for the centrality function. - + Returns ------- list of dict @@ -410,20 +410,20 @@ def top_central_nodes( 'pagerank': pagerank, 'harmonic': harmonic_centrality, } - + if metric not in centrality_funcs: raise ValueError(f"Unknown metric: {metric}") - + centrality = centrality_funcs[metric](graph, **kwargs) - + # Filter by cell type if specified if cell_type is not None: valid_nodes = set(graph.get_nodes_by_type(cell_type)) centrality = {k: v for k, v in centrality.items() if k in valid_nodes} - + # Sort and take top N sorted_nodes = sorted(centrality.items(), key=lambda x: x[1], reverse=True) - + result = [] for node, cent_value in sorted_nodes[:n]: result.append({ @@ -432,5 +432,5 @@ def top_central_nodes( 'centrality': cent_value, 'coordinates': tuple(graph.coordinates[node]), }) - + return result diff --git a/spatialtissuepy/network/clustering.py b/spatialtissuepy/network/clustering.py index 4306b85..711724f 100644 --- a/spatialtissuepy/network/clustering.py +++ b/spatialtissuepy/network/clustering.py @@ -6,13 +6,16 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Any + +from typing import TYPE_CHECKING, Dict, List, Union + import numpy as np if TYPE_CHECKING: - from .cell_graph import CellGraph import networkx as nx + from .cell_graph import CellGraph + try: import networkx as nx HAS_NETWORKX = True @@ -20,7 +23,7 @@ HAS_NETWORKX = False -def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': +def _get_nx_graph(graph: Union[CellGraph, nx.Graph]) -> nx.Graph: """Helper to extract NetworkX graph from CellGraph or return nx.Graph.""" if hasattr(graph, 'G'): return graph.G @@ -31,18 +34,18 @@ def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': # Clustering Coefficients # ============================================================================ -def clustering_coefficient(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def clustering_coefficient(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute local clustering coefficient for all nodes. - + The clustering coefficient of a node measures the fraction of possible triangles through that node that exist. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -51,15 +54,15 @@ def clustering_coefficient(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, f return nx.clustering(_get_nx_graph(graph)) -def average_clustering(graph: Union['CellGraph', 'nx.Graph']) -> float: +def average_clustering(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute average clustering coefficient for the graph. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -71,17 +74,17 @@ def average_clustering(graph: Union['CellGraph', 'nx.Graph']) -> float: return nx.average_clustering(G) -def transitivity(graph: Union['CellGraph', 'nx.Graph']) -> float: +def transitivity(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute graph transitivity (global clustering coefficient). - + Transitivity is the fraction of all possible triangles that exist. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -90,18 +93,18 @@ def transitivity(graph: Union['CellGraph', 'nx.Graph']) -> float: return nx.transitivity(_get_nx_graph(graph)) -def square_clustering(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def square_clustering(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute square clustering coefficient for all nodes. - + Square clustering measures the fraction of possible squares (4-cycles) through a node that exist. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -110,15 +113,15 @@ def square_clustering(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float] return nx.square_clustering(_get_nx_graph(graph)) -def triangles(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, int]: +def triangles(graph: Union[CellGraph, nx.Graph]) -> Dict[int, int]: """ Count triangles for each node. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -131,15 +134,15 @@ def triangles(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, int]: # Clustering by Cell Type # ============================================================================ -def clustering_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: +def clustering_by_type(graph: CellGraph) -> Dict[str, Dict[str, float]]: """ Compute clustering coefficient statistics grouped by cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict @@ -147,13 +150,13 @@ def clustering_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: 'mean', 'std', 'median', 'min', 'max'. """ clustering = clustering_coefficient(graph) - + result = {} - + for cell_type in graph.cell_types_unique: nodes = graph.get_nodes_by_type(cell_type) values = np.array([clustering[n] for n in nodes]) - + if len(values) > 0: result[cell_type] = { 'mean': float(np.mean(values)), @@ -172,19 +175,19 @@ def clustering_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: 'max': np.nan, 'count': 0, } - + return result -def mean_clustering_by_type(graph: 'CellGraph') -> Dict[str, float]: +def mean_clustering_by_type(graph: CellGraph) -> Dict[str, float]: """ Compute mean clustering coefficient for each cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict @@ -194,28 +197,28 @@ def mean_clustering_by_type(graph: 'CellGraph') -> Dict[str, float]: return {ct: s['mean'] for ct, s in stats.items()} -def triangles_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: +def triangles_by_type(graph: CellGraph) -> Dict[str, Dict[str, float]]: """ Compute triangle count statistics by cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict Cell type to triangle statistics. """ tri = triangles(graph) - + result = {} - + for cell_type in graph.cell_types_unique: nodes = graph.get_nodes_by_type(cell_type) values = np.array([tri[n] for n in nodes]) - + if len(values) > 0: result[cell_type] = { 'mean': float(np.mean(values)), @@ -230,7 +233,7 @@ def triangles_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: 'max': 0, 'count': 0, } - + return result @@ -238,15 +241,15 @@ def triangles_by_type(graph: 'CellGraph') -> Dict[str, Dict[str, float]]: # Graph Structure # ============================================================================ -def connected_components(graph: Union['CellGraph', 'nx.Graph']) -> List[set]: +def connected_components(graph: Union[CellGraph, nx.Graph]) -> List[set]: """ Find connected components in the graph. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- list of set @@ -256,15 +259,15 @@ def connected_components(graph: Union['CellGraph', 'nx.Graph']) -> List[set]: return sorted(components, key=len, reverse=True) -def n_connected_components(graph: Union['CellGraph', 'nx.Graph']) -> int: +def n_connected_components(graph: Union[CellGraph, nx.Graph]) -> int: """ Count connected components. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- int @@ -273,15 +276,15 @@ def n_connected_components(graph: Union['CellGraph', 'nx.Graph']) -> int: return nx.number_connected_components(_get_nx_graph(graph)) -def largest_component_size(graph: Union['CellGraph', 'nx.Graph']) -> int: +def largest_component_size(graph: Union[CellGraph, nx.Graph]) -> int: """ Get size of the largest connected component. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- int @@ -294,15 +297,15 @@ def largest_component_size(graph: Union['CellGraph', 'nx.Graph']) -> int: return len(components[0]) if components else 0 -def bridges(graph: Union['CellGraph', 'nx.Graph']) -> List[tuple]: +def bridges(graph: Union[CellGraph, nx.Graph]) -> List[tuple]: """ Find bridge edges whose removal disconnects the graph. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- list of tuple @@ -311,17 +314,17 @@ def bridges(graph: Union['CellGraph', 'nx.Graph']) -> List[tuple]: return list(nx.bridges(_get_nx_graph(graph))) -def articulation_points(graph: Union['CellGraph', 'nx.Graph']) -> List[int]: +def articulation_points(graph: Union[CellGraph, nx.Graph]) -> List[int]: """ Find articulation points (cut vertices). - + An articulation point is a node whose removal disconnects the graph. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- list of int @@ -330,53 +333,53 @@ def articulation_points(graph: Union['CellGraph', 'nx.Graph']) -> List[int]: return list(nx.articulation_points(_get_nx_graph(graph))) -def articulation_points_by_type(graph: 'CellGraph') -> Dict[str, int]: +def articulation_points_by_type(graph: CellGraph) -> Dict[str, int]: """ Count articulation points by cell type. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict Cell type to count of articulation points. """ ap = articulation_points(graph) - + counts = {ct: 0 for ct in graph.cell_types_unique} - + for node in ap: cell_type = graph.cell_types[node] counts[cell_type] += 1 - + return counts -def bridges_by_type_pair(graph: 'CellGraph') -> Dict[tuple, int]: +def bridges_by_type_pair(graph: CellGraph) -> Dict[tuple, int]: """ Count bridge edges by cell type pairs. - + Parameters ---------- graph : CellGraph Input cell graph. - + Returns ------- dict (type_a, type_b) to count of bridges. """ bridge_edges = bridges(graph) - + counts: Dict[tuple, int] = {} - + for i, j in bridge_edges: type_i = graph.cell_types[i] type_j = graph.cell_types[j] key = tuple(sorted([type_i, type_j])) counts[key] = counts.get(key, 0) + 1 - + return counts diff --git a/spatialtissuepy/network/communicability.py b/spatialtissuepy/network/communicability.py index 6f91d0f..b1e5bfd 100644 --- a/spatialtissuepy/network/communicability.py +++ b/spatialtissuepy/network/communicability.py @@ -6,13 +6,16 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Any + +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + import numpy as np if TYPE_CHECKING: - from .cell_graph import CellGraph import networkx as nx + from .cell_graph import CellGraph + try: import networkx as nx HAS_NETWORKX = True @@ -20,7 +23,7 @@ HAS_NETWORKX = False -def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': +def _get_nx_graph(graph: Union[CellGraph, nx.Graph]) -> nx.Graph: """Helper to extract NetworkX graph from CellGraph or return nx.Graph.""" if hasattr(graph, 'G'): return graph.G @@ -31,23 +34,23 @@ def _get_nx_graph(graph: Union['CellGraph', 'nx.Graph']) -> 'nx.Graph': # Communicability # ============================================================================ -def communicability(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, Dict[int, float]]: +def communicability(graph: Union[CellGraph, nx.Graph]) -> Dict[int, Dict[int, float]]: """ Compute communicability between all pairs of nodes. - + Communicability measures the sum of all walks of different lengths between two nodes, weighted by the inverse factorial of the length. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict of dict Nested dict: comm[i][j] = communicability between nodes i and j. - + Notes ----- This can be memory-intensive for large graphs. @@ -55,17 +58,17 @@ def communicability(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, Dict[int return nx.communicability(_get_nx_graph(graph)) -def communicability_exp(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, Dict[int, float]]: +def communicability_exp(graph: Union[CellGraph, nx.Graph]) -> Dict[int, Dict[int, float]]: """ Compute communicability using matrix exponential. - + More efficient implementation using spectral decomposition. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict of dict @@ -74,18 +77,18 @@ def communicability_exp(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, Dict return nx.communicability_exp(_get_nx_graph(graph)) -def communicability_betweenness(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def communicability_betweenness(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute communicability betweenness centrality. - + This measures how much a node contributes to the communicability between all pairs of other nodes. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -95,7 +98,7 @@ def communicability_betweenness(graph: Union['CellGraph', 'nx.Graph']) -> Dict[i def communicability_between_types( - graph: 'CellGraph', + graph: CellGraph, type_a: str, type_b: str, sample_size: Optional[int] = None, @@ -103,7 +106,7 @@ def communicability_between_types( ) -> Dict[str, float]: """ Compute communicability statistics between two cell types. - + Parameters ---------- graph : CellGraph @@ -116,12 +119,12 @@ def communicability_between_types( If specified, sample this many pairs to reduce computation. seed : int, optional Random seed for sampling. - + Returns ------- dict Statistics: 'mean', 'std', 'median', 'min', 'max', 'n_pairs'. - + Examples -------- >>> comm = communicability_between_types(graph, 'T_cell', 'Tumor') @@ -129,7 +132,7 @@ def communicability_between_types( """ nodes_a = graph.get_nodes_by_type(type_a) nodes_b = graph.get_nodes_by_type(type_b) - + if len(nodes_a) == 0 or len(nodes_b) == 0: return { 'mean': np.nan, @@ -139,14 +142,14 @@ def communicability_between_types( 'max': np.nan, 'n_pairs': 0, } - + # Get communicability matrix comm = communicability_exp(graph) - + # Sample pairs if needed if sample_size is not None: rng = np.random.default_rng(seed) - + n_possible = len(nodes_a) * len(nodes_b) if sample_size < n_possible: # Sample pairs @@ -157,15 +160,15 @@ def communicability_between_types( pairs = [(a, b) for a in nodes_a for b in nodes_b] else: pairs = [(a, b) for a in nodes_a for b in nodes_b] - + # Collect communicability values values = [] for a, b in pairs: if a in comm and b in comm[a]: values.append(comm[a][b]) - + values = np.array(values) - + if len(values) == 0: return { 'mean': np.nan, @@ -175,7 +178,7 @@ def communicability_between_types( 'max': np.nan, 'n_pairs': 0, } - + return { 'mean': float(np.mean(values)), 'std': float(np.std(values)), @@ -187,13 +190,13 @@ def communicability_between_types( def communicability_matrix_by_type( - graph: 'CellGraph', + graph: CellGraph, sample_size: Optional[int] = None, seed: Optional[int] = None, ) -> Dict[Tuple[str, str], float]: """ Compute mean communicability for all cell type pairs. - + Parameters ---------- graph : CellGraph @@ -202,16 +205,16 @@ def communicability_matrix_by_type( Sample size per pair for large graphs. seed : int, optional Random seed. - + Returns ------- dict (type_a, type_b) to mean communicability. """ cell_types = graph.cell_types_unique - + result = {} - + for i, type_a in enumerate(cell_types): for type_b in cell_types[i:]: # Upper triangle including diagonal stats = communicability_between_types( @@ -221,7 +224,7 @@ def communicability_matrix_by_type( result[(type_a, type_b)] = stats['mean'] if type_a != type_b: result[(type_b, type_a)] = stats['mean'] # Symmetric - + return result @@ -230,7 +233,7 @@ def communicability_matrix_by_type( # ============================================================================ def shortest_path_length_between_types( - graph: 'CellGraph', + graph: CellGraph, type_a: str, type_b: str, sample_size: Optional[int] = None, @@ -238,7 +241,7 @@ def shortest_path_length_between_types( ) -> Dict[str, float]: """ Compute shortest path length statistics between two cell types. - + Parameters ---------- graph : CellGraph @@ -251,7 +254,7 @@ def shortest_path_length_between_types( Number of pairs to sample. seed : int, optional Random seed. - + Returns ------- dict @@ -259,7 +262,7 @@ def shortest_path_length_between_types( """ nodes_a = graph.get_nodes_by_type(type_a) nodes_b = graph.get_nodes_by_type(type_b) - + if len(nodes_a) == 0 or len(nodes_b) == 0: return { 'mean': np.nan, @@ -270,11 +273,11 @@ def shortest_path_length_between_types( 'n_pairs': 0, 'n_unreachable': 0, } - + # Sample pairs if needed if sample_size is not None: rng = np.random.default_rng(seed) - + n_possible = len(nodes_a) * len(nodes_b) if sample_size < n_possible: pairs_a = rng.choice(nodes_a, size=sample_size, replace=True) @@ -284,20 +287,20 @@ def shortest_path_length_between_types( pairs = [(a, b) for a in nodes_a for b in nodes_b] else: pairs = [(a, b) for a in nodes_a for b in nodes_b] - + # Compute shortest paths lengths = [] n_unreachable = 0 - + for a, b in pairs: try: length = nx.shortest_path_length(graph.G, source=a, target=b) lengths.append(length) except nx.NetworkXNoPath: n_unreachable += 1 - + lengths = np.array(lengths) - + if len(lengths) == 0: return { 'mean': np.nan, @@ -308,7 +311,7 @@ def shortest_path_length_between_types( 'n_pairs': len(pairs), 'n_unreachable': n_unreachable, } - + return { 'mean': float(np.mean(lengths)), 'std': float(np.std(lengths)), @@ -320,15 +323,15 @@ def shortest_path_length_between_types( } -def average_shortest_path_length(graph: Union['CellGraph', 'nx.Graph']) -> float: +def average_shortest_path_length(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute average shortest path length for the graph. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -341,19 +344,19 @@ def average_shortest_path_length(graph: Union['CellGraph', 'nx.Graph']) -> float largest_cc = max(nx.connected_components(G), key=len) subG = G.subgraph(largest_cc) return nx.average_shortest_path_length(subG) - + return nx.average_shortest_path_length(G) -def diameter(graph: Union['CellGraph', 'nx.Graph']) -> int: +def diameter(graph: Union[CellGraph, nx.Graph]) -> int: """ Compute graph diameter (maximum eccentricity). - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- int @@ -364,19 +367,19 @@ def diameter(graph: Union['CellGraph', 'nx.Graph']) -> int: largest_cc = max(nx.connected_components(G), key=len) subG = G.subgraph(largest_cc) return nx.diameter(subG) - + return nx.diameter(G) -def radius(graph: Union['CellGraph', 'nx.Graph']) -> int: +def radius(graph: Union[CellGraph, nx.Graph]) -> int: """ Compute graph radius (minimum eccentricity). - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- int @@ -387,21 +390,21 @@ def radius(graph: Union['CellGraph', 'nx.Graph']) -> int: largest_cc = max(nx.connected_components(G), key=len) subG = G.subgraph(largest_cc) return nx.radius(subG) - + return nx.radius(G) -def eccentricity(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, int]: +def eccentricity(graph: Union[CellGraph, nx.Graph]) -> Dict[int, int]: """ Compute eccentricity for all nodes. - + Eccentricity is the maximum distance from a node to any other. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -413,12 +416,12 @@ def eccentricity(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, int]: largest_cc = max(nx.connected_components(G), key=len) subG = G.subgraph(largest_cc) ecc = nx.eccentricity(subG) - + # Fill in inf for disconnected nodes result = {n: np.inf for n in G.nodes()} result.update(ecc) return result - + return nx.eccentricity(G) @@ -426,18 +429,18 @@ def eccentricity(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, int]: # Efficiency Metrics # ============================================================================ -def global_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> float: +def global_efficiency(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute global efficiency of the graph. - + Global efficiency is the average inverse shortest path length. Higher efficiency means better "communication" in the network. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -446,17 +449,17 @@ def global_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> float: return nx.global_efficiency(_get_nx_graph(graph)) -def local_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> float: +def local_efficiency(graph: Union[CellGraph, nx.Graph]) -> float: """ Compute local efficiency of the graph. - + Local efficiency is the average efficiency of node neighborhoods. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- float @@ -465,15 +468,15 @@ def local_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> float: return nx.local_efficiency(_get_nx_graph(graph)) -def nodal_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: +def nodal_efficiency(graph: Union[CellGraph, nx.Graph]) -> Dict[int, float]: """ Compute local efficiency for each node. - + Parameters ---------- graph : CellGraph or nx.Graph Input graph. - + Returns ------- dict @@ -482,16 +485,16 @@ def nodal_efficiency(graph: Union['CellGraph', 'nx.Graph']) -> Dict[int, float]: # NetworkX doesn't have per-node local efficiency, so compute manually result = {} G = _get_nx_graph(graph) - + for node in G.nodes(): neighbors = list(G.neighbors(node)) - + if len(neighbors) < 2: result[node] = 0.0 continue - + # Subgraph of neighbors subG = G.subgraph(neighbors) result[node] = nx.global_efficiency(subG) - + return result diff --git a/spatialtissuepy/network/graph_construction.py b/spatialtissuepy/network/graph_construction.py index cbff339..7e57859 100644 --- a/spatialtissuepy/network/graph_construction.py +++ b/spatialtissuepy/network/graph_construction.py @@ -9,10 +9,12 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + from enum import Enum +from typing import TYPE_CHECKING, Optional, Union + import numpy as np -from scipy.spatial import cKDTree, Delaunay +from scipy.spatial import Delaunay, cKDTree if TYPE_CHECKING: import networkx as nx @@ -46,10 +48,10 @@ def build_proximity_graph( radius: float, cell_types: Optional[np.ndarray] = None, cell_ids: Optional[np.ndarray] = None, -) -> 'nx.Graph': +) -> nx.Graph: """ Build a graph connecting cells within a distance threshold. - + Parameters ---------- coordinates : np.ndarray @@ -60,17 +62,17 @@ def build_proximity_graph( Cell type labels for node attributes. cell_ids : np.ndarray, optional Cell identifiers. If None, uses integer indices. - + Returns ------- nx.Graph Graph with cells as nodes and proximity edges. """ _check_networkx() - + n_cells = len(coordinates) G = nx.Graph() - + # Add nodes for i in range(n_cells): node_attrs = {'pos': tuple(coordinates[i])} @@ -79,18 +81,18 @@ def build_proximity_graph( if cell_ids is not None: node_attrs['cell_id'] = cell_ids[i] G.add_node(i, **node_attrs) - + # Build KD-tree for efficient neighbor search tree = cKDTree(coordinates) - + # Find all pairs within radius pairs = tree.query_pairs(radius) - + # Add edges with distance as weight for i, j in pairs: dist = np.linalg.norm(coordinates[i] - coordinates[j]) G.add_edge(i, j, weight=dist, distance=dist) - + return G @@ -101,10 +103,10 @@ def build_knn_graph( cell_ids: Optional[np.ndarray] = None, mutual: bool = False, **kwargs -) -> 'nx.Graph': +) -> nx.Graph: """ Build a k-nearest neighbor graph. - + Parameters ---------- coordinates : np.ndarray @@ -120,21 +122,21 @@ def build_knn_graph( k-nearest neighbors (mutual kNN graph). **kwargs Additional arguments, including mutual_knn (alias for mutual). - + Returns ------- nx.Graph k-NN graph. """ _check_networkx() - + # Handle mutual_knn alias if 'mutual_knn' in kwargs: mutual = kwargs.pop('mutual_knn') - + n_cells = len(coordinates) G = nx.Graph() - + # Add nodes for i in range(n_cells): node_attrs = {'pos': tuple(coordinates[i])} @@ -143,17 +145,17 @@ def build_knn_graph( if cell_ids is not None: node_attrs['cell_id'] = cell_ids[i] G.add_node(i, **node_attrs) - + # Build KD-tree tree = cKDTree(coordinates) - + # Query k+1 neighbors (first is self) distances, indices = tree.query(coordinates, k=min(k + 1, n_cells)) - + if mutual: # Build neighbor sets for mutual check neighbor_sets = [set(indices[i, 1:]) for i in range(n_cells)] - + for i in range(n_cells): for j_idx, j in enumerate(indices[i, 1:]): if i in neighbor_sets[j]: # Mutual neighbors @@ -167,7 +169,7 @@ def build_knn_graph( dist = distances[i, j_idx + 1] if not G.has_edge(i, j): G.add_edge(i, j, weight=dist, distance=dist) - + return G @@ -176,13 +178,13 @@ def build_delaunay_graph( cell_types: Optional[np.ndarray] = None, cell_ids: Optional[np.ndarray] = None, max_edge_length: Optional[float] = None, -) -> 'nx.Graph': +) -> nx.Graph: """ Build a Delaunay triangulation graph. - + The Delaunay triangulation connects cells such that no cell lies inside the circumcircle of any triangle. - + Parameters ---------- coordinates : np.ndarray @@ -194,24 +196,24 @@ def build_delaunay_graph( max_edge_length : float, optional Maximum edge length to include. Useful for removing long edges at tissue boundaries. - + Returns ------- nx.Graph Delaunay triangulation graph. """ _check_networkx() - + n_cells = len(coordinates) - + if coordinates.shape[1] > 2: # Use only x, y for 2D Delaunay coords_2d = coordinates[:, :2] else: coords_2d = coordinates - + G = nx.Graph() - + # Add nodes for i in range(n_cells): node_attrs = {'pos': tuple(coordinates[i])} @@ -220,17 +222,17 @@ def build_delaunay_graph( if cell_ids is not None: node_attrs['cell_id'] = cell_ids[i] G.add_node(i, **node_attrs) - + # Compute Delaunay triangulation if n_cells < 3: return G # Not enough points for triangulation - + try: tri = Delaunay(coords_2d) except Exception: # Fall back to proximity if Delaunay fails (e.g., collinear points) return G - + # Extract edges from simplices edges = set() for simplex in tri.simplices: @@ -238,16 +240,16 @@ def build_delaunay_graph( for j in range(i + 1, len(simplex)): edge = (min(simplex[i], simplex[j]), max(simplex[i], simplex[j])) edges.add(edge) - + # Add edges for i, j in edges: dist = np.linalg.norm(coordinates[i] - coordinates[j]) - + if max_edge_length is not None and dist > max_edge_length: continue - + G.add_edge(i, j, weight=dist, distance=dist) - + return G @@ -255,14 +257,14 @@ def build_gabriel_graph( coordinates: np.ndarray, cell_types: Optional[np.ndarray] = None, cell_ids: Optional[np.ndarray] = None, -) -> 'nx.Graph': +) -> nx.Graph: """ Build a Gabriel graph. - + The Gabriel graph is a subgraph of the Delaunay triangulation where an edge (i, j) exists only if no other point lies within the circle having (i, j) as diameter. - + Parameters ---------- coordinates : np.ndarray @@ -271,46 +273,46 @@ def build_gabriel_graph( Cell type labels for node attributes. cell_ids : np.ndarray, optional Cell identifiers. - + Returns ------- nx.Graph Gabriel graph. """ _check_networkx() - + n_cells = len(coordinates) - + # Start with Delaunay graph G = build_delaunay_graph(coordinates, cell_types, cell_ids) - + if n_cells < 3: return G - + # Build KD-tree for point queries tree = cKDTree(coordinates) - + # Check each edge for Gabriel property edges_to_remove = [] - + for i, j in G.edges(): # Midpoint of edge midpoint = (coordinates[i] + coordinates[j]) / 2 - + # Radius of circle with (i, j) as diameter radius = np.linalg.norm(coordinates[i] - coordinates[j]) / 2 - + # Find points within this circle points_in_circle = tree.query_ball_point(midpoint, radius - 1e-10) - + # Remove i and j from the list other_points = [p for p in points_in_circle if p != i and p != j] - + if len(other_points) > 0: edges_to_remove.append((i, j)) - + G.remove_edges_from(edges_to_remove) - + return G @@ -323,10 +325,10 @@ def build_graph( k: int = 6, mutual_knn: bool = False, max_edge_length: Optional[float] = None, -) -> 'nx.Graph': +) -> nx.Graph: """ Build a cell graph using the specified method. - + Parameters ---------- coordinates : np.ndarray @@ -349,12 +351,12 @@ def build_graph( Use mutual kNN (both nodes must be in each other's k-NN). max_edge_length : float, optional Maximum edge length (for Delaunay graph pruning). - + Returns ------- nx.Graph Cell graph. - + Examples -------- >>> G = build_graph(coordinates, method='proximity', radius=30) @@ -364,7 +366,7 @@ def build_graph( """ if isinstance(method, str): method = GraphMethod(method.lower()) - + if method == GraphMethod.PROXIMITY: return build_proximity_graph( coordinates, radius, cell_types, cell_ids diff --git a/spatialtissuepy/network/metrics.py b/spatialtissuepy/network/metrics.py index 34fe7ac..dfcd692 100644 --- a/spatialtissuepy/network/metrics.py +++ b/spatialtissuepy/network/metrics.py @@ -5,7 +5,9 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Optional, Union + +from typing import TYPE_CHECKING, Dict + import numpy as np if TYPE_CHECKING: @@ -29,14 +31,14 @@ def decorator(func): # ============================================================================ def _build_graph_from_data( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ): """Build a CellGraph from SpatialTissueData.""" from .cell_graph import CellGraph - + return CellGraph.from_spatial_data( data, method=method, radius=radius, k=k ) @@ -53,7 +55,7 @@ def _build_graph_from_data( parameters={'method': str, 'radius': float, 'k': int} ) def graph_density( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, @@ -70,14 +72,14 @@ def graph_density( parameters={'method': str, 'radius': float, 'k': int} ) def average_clustering_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute average clustering coefficient.""" from .clustering import average_clustering - + graph = _build_graph_from_data(data, method, radius, k) return {'average_clustering': average_clustering(graph)} @@ -89,14 +91,14 @@ def average_clustering_metric( parameters={'method': str, 'radius': float, 'k': int} ) def transitivity_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute graph transitivity.""" from .clustering import transitivity - + graph = _build_graph_from_data(data, method, radius, k) return {'transitivity': transitivity(graph)} @@ -109,17 +111,17 @@ def transitivity_metric( dynamic_columns=True ) def mean_clustering_by_type_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute mean clustering coefficient per cell type.""" from .clustering import mean_clustering_by_type - + graph = _build_graph_from_data(data, method, radius, k) stats = mean_clustering_by_type(graph) - + return {f'clustering_{ct}': val for ct, val in stats.items()} @@ -130,14 +132,14 @@ def mean_clustering_by_type_metric( parameters={'method': str, 'radius': float, 'k': int} ) def type_assortativity_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute cell type assortativity.""" from .assortativity import type_assortativity - + graph = _build_graph_from_data(data, method, radius, k) return {'type_assortativity': type_assortativity(graph)} @@ -149,14 +151,14 @@ def type_assortativity_metric( parameters={'method': str, 'radius': float, 'k': int} ) def degree_assortativity_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute degree assortativity.""" from .assortativity import degree_assortativity - + graph = _build_graph_from_data(data, method, radius, k) return {'degree_assortativity': degree_assortativity(graph)} @@ -168,14 +170,14 @@ def degree_assortativity_metric( parameters={'method': str, 'radius': float, 'k': int} ) def homophily_ratio_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute homophily ratio.""" from .assortativity import homophily_ratio - + graph = _build_graph_from_data(data, method, radius, k) return {'homophily_ratio': homophily_ratio(graph)} @@ -188,17 +190,17 @@ def homophily_ratio_metric( dynamic_columns=True ) def mean_degree_centrality_by_type_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute mean degree centrality per cell type.""" from .centrality import mean_centrality_by_type - + graph = _build_graph_from_data(data, method, radius, k) stats = mean_centrality_by_type(graph, metric='degree') - + return {f'degree_centrality_{ct}': val for ct, val in stats.items()} @@ -210,17 +212,17 @@ def mean_degree_centrality_by_type_metric( dynamic_columns=True ) def mean_betweenness_centrality_by_type_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute mean betweenness centrality per cell type.""" from .centrality import mean_centrality_by_type - + graph = _build_graph_from_data(data, method, radius, k) stats = mean_centrality_by_type(graph, metric='betweenness') - + return {f'betweenness_centrality_{ct}': val for ct, val in stats.items()} @@ -232,17 +234,17 @@ def mean_betweenness_centrality_by_type_metric( dynamic_columns=True ) def mean_closeness_centrality_by_type_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute mean closeness centrality per cell type.""" from .centrality import mean_centrality_by_type - + graph = _build_graph_from_data(data, method, radius, k) stats = mean_centrality_by_type(graph, metric='closeness') - + return {f'closeness_centrality_{ct}': val for ct, val in stats.items()} @@ -253,14 +255,14 @@ def mean_closeness_centrality_by_type_metric( parameters={'method': str, 'radius': float, 'k': int} ) def global_efficiency_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute global efficiency.""" from .communicability import global_efficiency - + graph = _build_graph_from_data(data, method, radius, k) return {'global_efficiency': global_efficiency(graph)} @@ -272,14 +274,14 @@ def global_efficiency_metric( parameters={'method': str, 'radius': float, 'k': int} ) def local_efficiency_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Compute local efficiency.""" from .communicability import local_efficiency - + graph = _build_graph_from_data(data, method, radius, k) return {'local_efficiency': local_efficiency(graph)} @@ -291,14 +293,14 @@ def local_efficiency_metric( parameters={'method': str, 'radius': float, 'k': int} ) def n_connected_components_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Count connected components.""" from .clustering import n_connected_components - + graph = _build_graph_from_data(data, method, radius, k) return {'n_connected_components': float(n_connected_components(graph))} @@ -310,19 +312,19 @@ def n_connected_components_metric( parameters={'method': str, 'radius': float, 'k': int} ) def largest_component_fraction_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Fraction of cells in largest component.""" from .clustering import largest_component_size - + graph = _build_graph_from_data(data, method, radius, k) - + if graph.n_nodes == 0: return {'largest_component_fraction': np.nan} - + return { 'largest_component_fraction': largest_component_size(graph) / graph.n_nodes } @@ -335,14 +337,14 @@ def largest_component_fraction_metric( parameters={'method': str, 'radius': float, 'k': int} ) def n_articulation_points_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'proximity', radius: float = 50.0, k: int = 6, ) -> Dict[str, float]: """Count articulation points.""" from .clustering import articulation_points - + graph = _build_graph_from_data(data, method, radius, k) return {'n_articulation_points': float(len(articulation_points(graph)))} @@ -354,7 +356,7 @@ def n_articulation_points_metric( parameters={'type_a': str, 'type_b': str, 'method': str, 'radius': float} ) def communicability_between_types_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, method: str = 'proximity', @@ -363,12 +365,12 @@ def communicability_between_types_metric( ) -> Dict[str, float]: """Compute communicability between two cell types.""" from .communicability import communicability_between_types - + graph = _build_graph_from_data(data, method, radius, k=6) stats = communicability_between_types( graph, type_a, type_b, sample_size=sample_size ) - + return {f'communicability_{type_a}_{type_b}': stats['mean']} @@ -379,7 +381,7 @@ def communicability_between_types_metric( parameters={'type_a': str, 'type_b': str, 'method': str, 'radius': float} ) def shortest_path_between_types_metric( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, method: str = 'proximity', @@ -388,10 +390,10 @@ def shortest_path_between_types_metric( ) -> Dict[str, float]: """Compute mean shortest path between two cell types.""" from .communicability import shortest_path_length_between_types - + graph = _build_graph_from_data(data, method, radius, k=6) stats = shortest_path_length_between_types( graph, type_a, type_b, sample_size=sample_size ) - + return {f'shortest_path_{type_a}_{type_b}': stats['mean']} diff --git a/spatialtissuepy/spatial/__init__.py b/spatialtissuepy/spatial/__init__.py index 7bad028..d376498 100644 --- a/spatialtissuepy/spatial/__init__.py +++ b/spatialtissuepy/spatial/__init__.py @@ -19,88 +19,87 @@ ... neighborhood_composition, ... dbscan_clustering, ... ) ->>> +>>> >>> # Find k-nearest neighbors >>> distances, indices = nearest_neighbors(data.coordinates, k=10) ->>> +>>> >>> # Compute neighborhood composition >>> composition = neighborhood_composition(data, method='radius', radius=50) ->>> +>>> >>> # Cluster cells spatially >>> labels = dbscan_clustering(data, eps=30, min_samples=5) """ # Distance module +# Clustering module +from .clustering import ( + # Methods enum + ClusteringMethod, + cluster_purity, + # Utilities + cluster_statistics, + connected_components_spatial, + cut_dendrogram, + dbscan_by_type, + # DBSCAN + dbscan_clustering, + # HDBSCAN (optional) + hdbscan_clustering, + # Hierarchical + hierarchical_clustering, + hierarchical_linkage, + kmeans_by_type, + # K-means + kmeans_spatial, + # Graph-based + leiden_clustering, + louvain_clustering, + silhouette_spatial, + # Spatial regions + spatial_regions, +) from .distance import ( # Distance metrics DistanceMetric, - pairwise_distances, - pairwise_distances_between, - condensed_distances, + bounding_box, # Nearest neighbors build_kdtree, - nearest_neighbors, - radius_neighbors, - nearest_neighbor_distances, - mean_nearest_neighbor_distance, - # Distance to types - distance_to_type, - distance_to_nearest_different_type, - distance_matrix_by_type, # Utilities centroid, centroid_by_type, - bounding_box, + condensed_distances, convex_hull_area, + distance_matrix_by_type, + distance_to_nearest_different_type, + # Distance to types + distance_to_type, + mean_nearest_neighbor_distance, + nearest_neighbor_distances, + nearest_neighbors, + pairwise_distances, + pairwise_distances_between, point_density, + radius_neighbors, ) # Neighborhood module from .neighborhood import ( # Neighborhood computation NeighborhoodMethod, - compute_neighborhoods, - neighborhood_counts, - neighborhood_composition, - window_composition, # Adjacency adjacency_matrix, - type_adjacency_matrix, - # Statistics - neighborhood_size, + compute_neighborhoods, + interface_cells, + neighborhood_composition, + neighborhood_counts, neighborhood_diversity, neighborhood_enrichment, - interface_cells, + # Statistics + neighborhood_size, # DataFrame conversion neighborhood_to_dataframe, -) - -# Clustering module -from .clustering import ( - # Methods enum - ClusteringMethod, - # DBSCAN - dbscan_clustering, - dbscan_by_type, - # HDBSCAN (optional) - hdbscan_clustering, - # K-means - kmeans_spatial, - kmeans_by_type, - # Hierarchical - hierarchical_clustering, - hierarchical_linkage, - cut_dendrogram, - # Graph-based - leiden_clustering, - louvain_clustering, - # Utilities - cluster_statistics, - cluster_purity, - silhouette_spatial, - # Spatial regions - spatial_regions, - connected_components_spatial, + type_adjacency_matrix, + window_composition, ) __all__ = [ diff --git a/spatialtissuepy/spatial/clustering.py b/spatialtissuepy/spatial/clustering.py index 781fe9e..6eeaf64 100644 --- a/spatialtissuepy/spatial/clustering.py +++ b/spatialtissuepy/spatial/clustering.py @@ -22,15 +22,15 @@ """ from __future__ import annotations -from typing import Optional, Dict, Tuple, Union, List, TYPE_CHECKING + from enum import Enum +from typing import TYPE_CHECKING, Dict, List, Optional, Union + import numpy as np -from scipy.spatial import cKDTree -from scipy.cluster.hierarchy import linkage, fcluster, dendrogram +from scipy.cluster.hierarchy import fcluster, linkage from scipy.spatial.distance import pdist -from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN +from sklearn.cluster import DBSCAN, AgglomerativeClustering, KMeans from sklearn.preprocessing import StandardScaler -import warnings if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -60,7 +60,7 @@ class ClusteringMethod(Enum): # ----------------------------------------------------------------------------- def dbscan_clustering( - data: 'SpatialTissueData', + data: SpatialTissueData, eps: float, min_samples: int = 5, cell_types: Optional[List[str]] = None, @@ -103,21 +103,21 @@ def dbscan_clustering( else: coords = data._coordinates mask = np.ones(data.n_cells, dtype=bool) - + clusterer = DBSCAN(eps=eps, min_samples=min_samples, metric=metric) cluster_labels = clusterer.fit_predict(coords) - + # Map back to full array if subset was used if cell_types is not None: full_labels = np.full(data.n_cells, -1, dtype=int) full_labels[mask] = cluster_labels return full_labels - + return cluster_labels def dbscan_by_type( - data: 'SpatialTissueData', + data: SpatialTissueData, eps: float, min_samples: int = 5, metric: str = 'euclidean' @@ -146,19 +146,19 @@ def dbscan_by_type( Useful for finding spatial clusters within each cell population. """ results = {} - + for cell_type in data.cell_types_unique: idx = data.get_cells_by_type(cell_type) coords = data._coordinates[idx] - + if len(coords) >= min_samples: clusterer = DBSCAN(eps=eps, min_samples=min_samples, metric=metric) labels = clusterer.fit_predict(coords) else: labels = np.zeros(len(idx), dtype=int) - + results[cell_type] = labels - + return results @@ -167,7 +167,7 @@ def dbscan_by_type( # ----------------------------------------------------------------------------- def hdbscan_clustering( - data: 'SpatialTissueData', + data: SpatialTissueData, min_cluster_size: int = 10, min_samples: Optional[int] = None, cell_types: Optional[List[str]] = None, @@ -207,29 +207,29 @@ def hdbscan_clustering( raise ImportError( "hdbscan package required. Install with: pip install hdbscan" ) - + if min_samples is None: min_samples = min_cluster_size - + if cell_types is not None: mask = np.isin(data._cell_types, cell_types) coords = data._coordinates[mask] else: coords = data._coordinates mask = np.ones(data.n_cells, dtype=bool) - + clusterer = hdbscan_lib.HDBSCAN( min_cluster_size=min_cluster_size, min_samples=min_samples, cluster_selection_method=cluster_selection_method ) cluster_labels = clusterer.fit_predict(coords) - + if cell_types is not None: full_labels = np.full(data.n_cells, -1, dtype=int) full_labels[mask] = cluster_labels return full_labels - + return cluster_labels @@ -238,7 +238,7 @@ def hdbscan_clustering( # ----------------------------------------------------------------------------- def kmeans_spatial( - data: 'SpatialTissueData', + data: SpatialTissueData, n_clusters: int, include_coords: bool = True, include_composition: bool = False, @@ -275,22 +275,22 @@ def kmeans_spatial( -------- >>> # Pure spatial clustering >>> labels = kmeans_spatial(data, n_clusters=10) - >>> + >>> >>> # Spatial + neighborhood composition >>> labels = kmeans_spatial( - ... data, n_clusters=10, - ... include_composition=True, + ... data, n_clusters=10, + ... include_composition=True, ... neighborhood_radius=50 ... ) """ features = [] - + if include_coords: # Scale coordinates scaler = StandardScaler() coords_scaled = scaler.fit_transform(data._coordinates) * coord_weight features.append(coords_scaled) - + if include_composition: if neighborhood_radius is None: raise ValueError( @@ -301,18 +301,18 @@ def kmeans_spatial( data, method='radius', radius=neighborhood_radius ) features.append(composition) - + if not features: raise ValueError("At least one feature type must be enabled") - + X = np.hstack(features) - + kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=10) return kmeans.fit_predict(X) def kmeans_by_type( - data: 'SpatialTissueData', + data: SpatialTissueData, n_clusters: int, random_state: Optional[int] = None ) -> Dict[str, np.ndarray]: @@ -334,23 +334,23 @@ def kmeans_by_type( Dictionary mapping cell type to cluster labels. """ results = {} - + for cell_type in data.cell_types_unique: idx = data.get_cells_by_type(cell_type) coords = data._coordinates[idx] - + if len(coords) >= n_clusters: kmeans = KMeans( - n_clusters=n_clusters, + n_clusters=n_clusters, random_state=random_state, n_init=10 ) labels = kmeans.fit_predict(coords) else: labels = np.arange(len(idx)) # Each cell is own cluster - + results[cell_type] = labels - + return results @@ -359,7 +359,7 @@ def kmeans_by_type( # ----------------------------------------------------------------------------- def hierarchical_clustering( - data: 'SpatialTissueData', + data: SpatialTissueData, n_clusters: Optional[int] = None, distance_threshold: Optional[float] = None, linkage_method: str = 'ward', @@ -398,26 +398,26 @@ def hierarchical_clustering( """ if n_clusters is None and distance_threshold is None: raise ValueError("Specify either n_clusters or distance_threshold") - + if cell_types is not None: mask = np.isin(data._cell_types, cell_types) coords = data._coordinates[mask] else: coords = data._coordinates mask = np.ones(data.n_cells, dtype=bool) - + clusterer = AgglomerativeClustering( n_clusters=n_clusters, distance_threshold=distance_threshold, linkage=linkage_method ) cluster_labels = clusterer.fit_predict(coords) - + if cell_types is not None: full_labels = np.full(data.n_cells, -1, dtype=int) full_labels[mask] = cluster_labels return full_labels - + return cluster_labels @@ -484,7 +484,7 @@ def cut_dendrogram( # ----------------------------------------------------------------------------- def leiden_clustering( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: Optional[float] = None, k: Optional[int] = None, @@ -520,38 +520,38 @@ def leiden_clustering( by guaranteeing well-connected communities. """ try: - import leidenalg import igraph as ig + import leidenalg except ImportError: raise ImportError( "leidenalg and igraph required. Install with: " "pip install leidenalg python-igraph" ) - + # Build graph using network module from spatialtissuepy.network import CellGraph - + graph = CellGraph.from_spatial_data( data, method=method, radius=radius, k=k ) - + # Convert to igraph edges = list(graph.graph.edges()) g = ig.Graph(n=data.n_cells, edges=edges, directed=False) - + # Run Leiden partition = leidenalg.find_partition( - g, + g, leidenalg.RBConfigurationVertexPartition, resolution_parameter=resolution, seed=random_state ) - + return np.array(partition.membership) def louvain_clustering( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: Optional[float] = None, k: Optional[int] = None, @@ -585,28 +585,27 @@ def louvain_clustering( ----- Uses NetworkX's built-in Louvain implementation. """ - import networkx as nx from networkx.algorithms.community import louvain_communities - + from spatialtissuepy.network import CellGraph - + graph = CellGraph.from_spatial_data( data, method=method, radius=radius, k=k ) - + # Run Louvain communities = louvain_communities( - graph.graph, + graph.graph, resolution=resolution, seed=random_state ) - + # Convert to label array labels = np.zeros(data.n_cells, dtype=int) for i, community in enumerate(communities): for node in community: labels[node] = i - + return labels @@ -615,7 +614,7 @@ def louvain_clustering( # ----------------------------------------------------------------------------- def cluster_statistics( - data: 'SpatialTissueData', + data: SpatialTissueData, labels: np.ndarray ) -> Dict[str, Union[int, float, Dict]]: """ @@ -644,18 +643,18 @@ def cluster_statistics( """ unique_labels = np.unique(labels) n_clusters = len(unique_labels) - (1 if -1 in unique_labels else 0) - + # Noise statistics noise_mask = labels == -1 n_noise = np.sum(noise_mask) noise_fraction = n_noise / len(labels) - + # Cluster sizes cluster_sizes = {} for label in unique_labels: if label != -1: cluster_sizes[int(label)] = int(np.sum(labels == label)) - + # Type composition per cluster type_composition = {} for label in unique_labels: @@ -665,7 +664,7 @@ def cluster_statistics( data._cell_types[mask], return_counts=True ) type_composition[int(label)] = dict(zip(types, counts.astype(int))) - + # Spatial statistics per cluster cluster_centroids = {} cluster_radii = {} @@ -677,7 +676,7 @@ def cluster_statistics( radius = np.max(np.linalg.norm(coords - centroid, axis=1)) cluster_centroids[int(label)] = centroid.tolist() cluster_radii[int(label)] = float(radius) - + return { 'n_clusters': n_clusters, 'n_noise': n_noise, @@ -715,24 +714,24 @@ def cluster_purity( Purity = (1/N) * sum(max type count in each cluster) """ unique_labels = np.unique(labels[labels != -1]) - + if len(unique_labels) == 0: return 0.0 - + total_correct = 0 total_cells = 0 - + for label in unique_labels: mask = labels == label _, counts = np.unique(cell_types[mask], return_counts=True) total_correct += np.max(counts) total_cells += np.sum(mask) - + return total_correct / total_cells def silhouette_spatial( - data: 'SpatialTissueData', + data: SpatialTissueData, labels: np.ndarray, sample_size: Optional[int] = None, random_state: Optional[int] = None @@ -757,17 +756,17 @@ def silhouette_spatial( Mean silhouette score in [-1, 1]. Higher = better clustering. """ from sklearn.metrics import silhouette_score - + # Remove noise points valid_mask = labels != -1 coords = data._coordinates[valid_mask] valid_labels = labels[valid_mask] - + if len(np.unique(valid_labels)) < 2: return 0.0 - + return silhouette_score( - coords, + coords, valid_labels, sample_size=sample_size, random_state=random_state @@ -779,7 +778,7 @@ def silhouette_spatial( # ----------------------------------------------------------------------------- def spatial_regions( - data: 'SpatialTissueData', + data: SpatialTissueData, n_regions: int, method: str = 'kmeans', **kwargs @@ -818,33 +817,33 @@ def spatial_regions( def _grid_regions( - data: 'SpatialTissueData', + data: SpatialTissueData, n_regions: int ) -> np.ndarray: """Create grid-based regions.""" bounds = data.bounds - + # Determine grid dimensions (roughly square) n_x = int(np.ceil(np.sqrt(n_regions))) n_y = int(np.ceil(n_regions / n_x)) - + x_edges = np.linspace(bounds['x'][0], bounds['x'][1], n_x + 1) y_edges = np.linspace(bounds['y'][0], bounds['y'][1], n_y + 1) - + labels = np.zeros(data.n_cells, dtype=int) - + for i, (x, y) in enumerate(data._coordinates[:, :2]): x_bin = np.searchsorted(x_edges[1:], x, side='right') y_bin = np.searchsorted(y_edges[1:], y, side='right') x_bin = min(x_bin, n_x - 1) y_bin = min(y_bin, n_y - 1) labels[i] = y_bin * n_x + x_bin - + return labels def connected_components_spatial( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float, cell_types: Optional[List[str]] = None ) -> np.ndarray: @@ -865,30 +864,31 @@ def connected_components_spatial( np.ndarray Component labels. """ - from spatialtissuepy.network import CellGraph import networkx as nx - + + from spatialtissuepy.network import CellGraph + if cell_types is not None: subset = data.subset(cell_types=cell_types) graph = CellGraph.from_spatial_data(subset, method='radius', radius=radius) components = list(nx.connected_components(graph.graph)) - + # Map back to original indices mask = np.isin(data._cell_types, cell_types) idx_map = np.where(mask)[0] - + labels = np.full(data.n_cells, -1, dtype=int) for i, component in enumerate(components): for node in component: labels[idx_map[node]] = i return labels - + graph = CellGraph.from_spatial_data(data, method='proximity', radius=radius) components = list(nx.connected_components(graph.to_networkx())) - + labels = np.zeros(data.n_cells, dtype=int) for i, component in enumerate(components): for node in component: labels[node] = i - + return labels diff --git a/spatialtissuepy/spatial/distance.py b/spatialtissuepy/spatial/distance.py index d078452..6b737ed 100644 --- a/spatialtissuepy/spatial/distance.py +++ b/spatialtissuepy/spatial/distance.py @@ -7,16 +7,18 @@ References ---------- -.. [1] Bentley, J. L. (1975). Multidimensional binary search trees used for +.. [1] Bentley, J. L. (1975). Multidimensional binary search trees used for associative searching. Communications of the ACM. """ from __future__ import annotations -from typing import Optional, Tuple, Union, List, Dict, TYPE_CHECKING + from enum import Enum +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np from scipy.spatial import cKDTree -from scipy.spatial.distance import cdist, pdist, squareform +from scipy.spatial.distance import cdist, pdist if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -47,7 +49,7 @@ def pairwise_distances( coordinates : np.ndarray Point coordinates of shape (n_points, n_dims). metric : str, default 'euclidean' - Distance metric. Supported: 'euclidean', 'manhattan', 'chebyshev', + Distance metric. Supported: 'euclidean', 'manhattan', 'chebyshev', 'minkowski', or any metric supported by scipy.spatial.distance.cdist. **kwargs Additional arguments passed to cdist (e.g., p for Minkowski). @@ -227,23 +229,23 @@ def nearest_neighbors( (100, 5) """ tree = build_kdtree(coordinates) - + # Query k+1 if excluding self (first neighbor is self) k_query = k if include_self else k + 1 k_query = min(k_query, len(coordinates)) - + distances, indices = tree.query(coordinates, k=k_query) - + # Handle 1D case if k_query == 1: distances = distances.reshape(-1, 1) indices = indices.reshape(-1, 1) - + # Remove self if needed if not include_self and k_query > 1: distances = distances[:, 1:] indices = indices[:, 1:] - + if return_distances: return distances, indices return indices @@ -290,15 +292,15 @@ def radius_neighbors( """ tree = build_kdtree(coordinates) indices_list = tree.query_ball_tree(tree, radius) - + # Remove self from each neighborhood for i, neighbors in enumerate(indices_list): if i in neighbors: neighbors.remove(i) - + # Convert to numpy arrays indices = [np.array(idx, dtype=int) for idx in indices_list] - + if return_distances or sort_results: distances = [] for i, idx in enumerate(indices): @@ -313,10 +315,10 @@ def radius_neighbors( distances.append(dists) else: distances.append(np.array([], dtype=float)) - + if return_distances: return distances, indices - + return indices @@ -357,7 +359,7 @@ def mean_nearest_neighbor_distance( Compute mean distance to k-th nearest neighbor. This is a common spatial statistic for assessing point pattern regularity. - + Parameters ---------- coordinates : np.ndarray @@ -388,7 +390,7 @@ def mean_nearest_neighbor_distance( # ----------------------------------------------------------------------------- def distance_to_type( - data: 'SpatialTissueData', + data: SpatialTissueData, target_type: str, from_indices: Optional[np.ndarray] = None ) -> np.ndarray: @@ -414,30 +416,30 @@ def distance_to_type( -------- >>> # Distance from all cells to nearest tumor cell >>> distances = distance_to_type(data, 'Tumor') - >>> + >>> >>> # Distance from T cells to nearest tumor cell >>> t_cell_idx = data.get_cells_by_type('T_cell') >>> distances = distance_to_type(data, 'Tumor', from_indices=t_cell_idx) """ target_idx = data.get_cells_by_type(target_type) - + if len(target_idx) == 0: raise ValueError(f"No cells of type '{target_type}' found") - + target_coords = data._coordinates[target_idx] target_tree = build_kdtree(target_coords) - + if from_indices is None: query_coords = data._coordinates else: query_coords = data._coordinates[from_indices] - + distances, _ = target_tree.query(query_coords, k=1) return np.asarray(distances) def distance_to_nearest_different_type( - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: """ Compute distance from each cell to nearest cell of a different type. @@ -458,29 +460,29 @@ def distance_to_nearest_different_type( """ n_cells = data.n_cells distances = np.full(n_cells, np.inf) - + for cell_type in data.cell_types_unique: # Get indices of this type and other types this_type_idx = data.get_cells_by_type(cell_type) other_idx = np.where(data._cell_types != cell_type)[0] - + if len(other_idx) == 0: continue - + # Build tree for other types other_tree = build_kdtree(data._coordinates[other_idx]) - + # Query from this type to others this_coords = data._coordinates[this_type_idx] dists, _ = other_tree.query(this_coords, k=1) - + distances[this_type_idx] = dists - + return distances def distance_matrix_by_type( - data: 'SpatialTissueData', + data: SpatialTissueData, metric: str = 'mean' ) -> Dict[Tuple[str, str], float]: """ @@ -510,21 +512,21 @@ def distance_matrix_by_type( 'min': np.min, 'max': np.max, }.get(metric) - + if agg_func is None: raise ValueError(f"Unknown metric: {metric}. Use mean, median, min, max.") - + result = {} cell_types = data.cell_types_unique - + for type_a in cell_types: idx_a = data.get_cells_by_type(type_a) coords_a = data._coordinates[idx_a] - + for type_b in cell_types: idx_b = data.get_cells_by_type(type_b) coords_b = data._coordinates[idx_b] - + if type_a == type_b: # Exclude self-distances if len(idx_a) > 1: @@ -535,7 +537,7 @@ def distance_matrix_by_type( else: dists = cdist(coords_a, coords_b) result[(type_a, type_b)] = float(agg_func(dists)) - + return result @@ -560,7 +562,7 @@ def centroid(coordinates: np.ndarray) -> np.ndarray: return np.mean(coordinates, axis=0) -def centroid_by_type(data: 'SpatialTissueData') -> Dict[str, np.ndarray]: +def centroid_by_type(data: SpatialTissueData) -> Dict[str, np.ndarray]: """ Compute centroid for each cell type. @@ -620,12 +622,12 @@ def convex_hull_area(coordinates: np.ndarray) -> float: Requires at least 3 non-collinear points. """ from scipy.spatial import ConvexHull - + if coordinates.shape[1] != 2: raise ValueError("convex_hull_area requires 2D coordinates") if coordinates.shape[0] < 3: return 0.0 - + try: hull = ConvexHull(coordinates) return float(hull.volume) # In 2D, 'volume' is area @@ -653,7 +655,7 @@ def point_density( Point density. """ n_points = len(coordinates) - + if method == 'bounding_box': min_c, max_c = bounding_box(coordinates) ranges = max_c - min_c @@ -662,7 +664,7 @@ def point_density( area = convex_hull_area(coordinates) else: raise ValueError(f"Unknown method: {method}") - + if area == 0: return np.inf return n_points / area diff --git a/spatialtissuepy/spatial/metrics.py b/spatialtissuepy/spatial/metrics.py index d49eb13..4a52951 100644 --- a/spatialtissuepy/spatial/metrics.py +++ b/spatialtissuepy/spatial/metrics.py @@ -5,7 +5,8 @@ for standardized computation across samples. """ -from typing import Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict + import numpy as np from spatialtissuepy.summary.registry import register_metric @@ -39,7 +40,7 @@ def _mean_nnd(data: 'SpatialTissueData', k: int = 1) -> Dict[str, float]: def _mean_nnd_by_type(data: 'SpatialTissueData', k: int = 1) -> Dict[str, float]: """Compute mean NND for each cell type.""" from .distance import mean_nearest_neighbor_distance - + result = {} for cell_type in data.cell_types_unique: idx = data.get_cells_by_type(cell_type) @@ -59,12 +60,12 @@ def _mean_nnd_by_type(data: 'SpatialTissueData', k: int = 1) -> Dict[str, float] parameters={'target_type': str} ) def _mean_dist_to_type( - data: 'SpatialTissueData', + data: 'SpatialTissueData', target_type: str ) -> Dict[str, float]: """Compute mean distance to target cell type.""" from .distance import distance_to_type - + try: distances = distance_to_type(data, target_type) return {f'mean_distance_to_{target_type}': float(np.mean(distances))} @@ -99,7 +100,7 @@ def _point_density_by_type( ) -> Dict[str, float]: """Compute density for each cell type.""" from .distance import point_density - + result = {} for cell_type in data.cell_types_unique: idx = data.get_cells_by_type(cell_type) @@ -130,7 +131,7 @@ def _mean_neighborhood_size( ) -> Dict[str, float]: """Compute mean neighborhood size.""" from .neighborhood import compute_neighborhoods, neighborhood_size - + neighborhoods = compute_neighborhoods(data, method=method, radius=radius, k=k) sizes = neighborhood_size(neighborhoods) return { @@ -153,7 +154,7 @@ def _mean_neighborhood_diversity( ) -> Dict[str, float]: """Compute mean neighborhood diversity.""" from .neighborhood import compute_neighborhoods, neighborhood_diversity - + neighborhoods = compute_neighborhoods(data, method=method, radius=radius, k=k) diversity = neighborhood_diversity(data, neighborhoods, metric='shannon') return {'mean_neighborhood_diversity': float(np.mean(diversity))} @@ -173,7 +174,7 @@ def _type_enrichment( ) -> Dict[str, float]: """Compute mean neighborhood enrichment for target type.""" from .neighborhood import compute_neighborhoods, neighborhood_enrichment - + neighborhoods = compute_neighborhoods(data, method=method, radius=radius) enrichment = neighborhood_enrichment(data, neighborhoods, target_type) return { @@ -197,15 +198,15 @@ def _interface_fraction( ) -> Dict[str, float]: """Compute fraction of cells at type interface.""" from .neighborhood import interface_cells - + try: a_interface, b_interface = interface_cells( data, type_a, type_b, radius, min_neighbors ) - + n_a = len(data.get_cells_by_type(type_a)) n_b = len(data.get_cells_by_type(type_b)) - + return { f'interface_fraction_{type_a}': len(a_interface) / max(n_a, 1), f'interface_fraction_{type_b}': len(b_interface) / max(n_b, 1), @@ -233,11 +234,11 @@ def _n_spatial_clusters( min_samples: int = 5 ) -> Dict[str, float]: """Count number of spatial clusters.""" - from .clustering import dbscan_clustering, cluster_statistics - + from .clustering import cluster_statistics, dbscan_clustering + labels = dbscan_clustering(data, eps=eps, min_samples=min_samples) stats = cluster_statistics(data, labels) - + return { 'n_spatial_clusters': stats['n_clusters'], 'spatial_cluster_noise_fraction': stats['noise_fraction'], @@ -257,14 +258,14 @@ def _n_clusters_by_type( ) -> Dict[str, float]: """Count clusters per cell type.""" from .clustering import dbscan_by_type - + results = dbscan_by_type(data, eps=eps, min_samples=min_samples) - + output = {} for cell_type, labels in results.items(): n_clusters = len(set(labels)) - (1 if -1 in labels else 0) output[f'n_clusters_{cell_type}'] = n_clusters - + return output @@ -280,11 +281,11 @@ def _spatial_cluster_purity( min_samples: int = 5 ) -> Dict[str, float]: """Compute cluster purity.""" - from .clustering import dbscan_clustering, cluster_purity - + from .clustering import cluster_purity, dbscan_clustering + labels = dbscan_clustering(data, eps=eps, min_samples=min_samples) purity = cluster_purity(labels, data._cell_types) - + return {'spatial_cluster_purity': purity} @@ -302,15 +303,15 @@ def _silhouette_score( ) -> Dict[str, float]: """Compute silhouette score.""" from .clustering import dbscan_clustering, silhouette_spatial - + labels = dbscan_clustering(data, eps=eps, min_samples=min_samples) - + # Check if we have enough clusters unique_labels = set(labels) unique_labels.discard(-1) if len(unique_labels) < 2: return {'silhouette_score': np.nan} - + score = silhouette_spatial(data, labels, sample_size=sample_size) return {'silhouette_score': score} @@ -328,7 +329,7 @@ def _n_connected_components_type( ) -> Dict[str, float]: """Count connected components for a cell type.""" from .clustering import connected_components_spatial - + try: labels = connected_components_spatial( data, radius=radius, cell_types=[cell_type] diff --git a/spatialtissuepy/spatial/neighborhood.py b/spatialtissuepy/spatial/neighborhood.py index 8063bf9..169084b 100644 --- a/spatialtissuepy/spatial/neighborhood.py +++ b/spatialtissuepy/spatial/neighborhood.py @@ -19,12 +19,13 @@ """ from __future__ import annotations -from typing import Optional, Tuple, Union, Dict, List, TYPE_CHECKING + from enum import Enum +from typing import TYPE_CHECKING, List, Optional, Tuple, Union + import numpy as np -from scipy.spatial import cKDTree -from scipy.sparse import csr_matrix, lil_matrix import pandas as pd +from scipy.sparse import csr_matrix, lil_matrix if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -41,7 +42,7 @@ class NeighborhoodMethod(Enum): # ----------------------------------------------------------------------------- def compute_neighborhoods( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: Optional[float] = None, k: Optional[int] = None, @@ -75,20 +76,20 @@ def compute_neighborhoods( -------- >>> # Radius-based neighborhoods >>> neighborhoods = compute_neighborhoods(data, method='radius', radius=30) - >>> + >>> >>> # k-nearest neighbor neighborhoods >>> neighborhoods = compute_neighborhoods(data, method='knn', k=10) """ tree = data.kdtree coords = data._coordinates - + if method == 'radius': if radius is None: raise ValueError("radius required for method='radius'") - + # Query all neighbors within radius indices_list = tree.query_ball_tree(tree, radius) - + # Process results neighborhoods = [] for i, neighbors in enumerate(indices_list): @@ -96,33 +97,33 @@ def compute_neighborhoods( if not include_self: neighbors = neighbors[neighbors != i] neighborhoods.append(neighbors) - + elif method == 'knn': if k is None: raise ValueError("k required for method='knn'") - + k_query = k if include_self else k + 1 k_query = min(k_query, len(coords)) - + _, indices = tree.query(coords, k=k_query) - + if k_query == 1: indices = indices.reshape(-1, 1) - + neighborhoods = [] for i, neighbors in enumerate(indices): if not include_self: neighbors = neighbors[neighbors != i] neighborhoods.append(np.array(neighbors, dtype=int)) - + else: raise ValueError(f"Unknown method: {method}. Use 'radius' or 'knn'.") - + return neighborhoods def neighborhood_counts( - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhoods: List[np.ndarray] ) -> np.ndarray: """ @@ -151,23 +152,23 @@ def neighborhood_counts( cell_types = data._cell_types unique_types = data.cell_types_unique type_to_idx = {ct: i for i, ct in enumerate(unique_types)} - + n_cells = data.n_cells n_types = len(unique_types) - + counts = np.zeros((n_cells, n_types), dtype=int) - + for i, neighbors in enumerate(neighborhoods): if len(neighbors) > 0: neighbor_types = cell_types[neighbors] for ct in neighbor_types: counts[i, type_to_idx[ct]] += 1 - + return counts def neighborhood_composition( - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhoods: Optional[List[np.ndarray]] = None, method: str = 'radius', radius: Optional[float] = None, @@ -225,9 +226,9 @@ def neighborhood_composition( neighborhoods = compute_neighborhoods( data, method=method, radius=radius, k=k, include_self=include_self, **kwargs ) - + counts = neighborhood_counts(data, neighborhoods) - + if include_self: # Add focal cell type to counts unique_types = data.cell_types_unique @@ -235,21 +236,21 @@ def neighborhood_composition( for i in range(data.n_cells): ct = data._cell_types[i] counts[i, type_to_idx[ct]] += 1 - + if pseudocount > 0: counts = counts.astype(float) + pseudocount - + if normalize: row_sums = counts.sum(axis=1, keepdims=True) # Avoid division by zero for cells with no neighbors row_sums = np.where(row_sums == 0, 1, row_sums) return counts / row_sums - + return counts.astype(float) def window_composition( - data: 'SpatialTissueData', + data: SpatialTissueData, window_size: float, grid_step: Optional[float] = None, min_cells: int = 1 @@ -285,10 +286,10 @@ def window_composition( """ if grid_step is None: grid_step = window_size / 2 - + bounds = data.bounds half_window = window_size / 2 - + # Generate grid of window centers x_centers = np.arange( bounds['x'][0] + half_window, @@ -300,40 +301,40 @@ def window_composition( bounds['y'][1] - half_window + grid_step, grid_step ) - + xx, yy = np.meshgrid(x_centers, y_centers) grid_centers = np.column_stack([xx.ravel(), yy.ravel()]) - + # For each window, count cell types tree = data.kdtree unique_types = data.cell_types_unique type_to_idx = {ct: i for i, ct in enumerate(unique_types)} n_types = len(unique_types) - + compositions = [] valid_centers = [] - + for center in grid_centers: # Find cells in window (Chebyshev distance = infinity norm) # Use radius query with sqrt(2)*half_window for circumscribed circle # then filter to square window candidate_idx = tree.query_ball_point(center, half_window * np.sqrt(2)) - + # Filter to actual square window cell_coords = data._coordinates[candidate_idx] in_window = np.all(np.abs(cell_coords - center) <= half_window, axis=1) window_idx = np.array(candidate_idx)[in_window] - + if len(window_idx) >= min_cells: counts = np.zeros(n_types) for idx in window_idx: ct = data._cell_types[idx] counts[type_to_idx[ct]] += 1 - + # Normalize to proportions compositions.append(counts / counts.sum()) valid_centers.append(center) - + return np.array(compositions), np.array(valid_centers) @@ -342,7 +343,7 @@ def window_composition( # ----------------------------------------------------------------------------- def adjacency_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: Optional[float] = None, k: Optional[int] = None, @@ -376,23 +377,23 @@ def adjacency_matrix( -------- >>> # Binary radius-based adjacency >>> adj = adjacency_matrix(data, method='radius', radius=30) - >>> + >>> >>> # Distance-weighted k-NN adjacency >>> adj = adjacency_matrix(data, method='knn', k=10, weighted=True) """ n_cells = data.n_cells coords = data._coordinates tree = data.kdtree - + # Use lil_matrix for efficient construction adj = lil_matrix((n_cells, n_cells), dtype=float) - + if method == 'radius': if radius is None: raise ValueError("radius required for method='radius'") - + pairs = tree.query_pairs(radius, output_type='ndarray') - + if weighted: for i, j in pairs: dist = np.linalg.norm(coords[i] - coords[j]) @@ -403,18 +404,18 @@ def adjacency_matrix( for i, j in pairs: adj[i, j] = 1.0 adj[j, i] = 1.0 - + elif method == 'knn': if k is None: raise ValueError("k required for method='knn'") - + k_query = min(k + 1, n_cells) distances, indices = tree.query(coords, k=k_query) - + if k_query == 1: distances = distances.reshape(-1, 1) indices = indices.reshape(-1, 1) - + for i in range(n_cells): for j_idx in range(1, indices.shape[1]): # Skip self (index 0) j = indices[i, j_idx] @@ -424,19 +425,19 @@ def adjacency_matrix( adj[i, j] = w else: adj[i, j] = 1.0 - + else: raise ValueError(f"Unknown method: {method}") - + adj = adj.tocsr() - + if sparse: return adj return adj.toarray() def type_adjacency_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, method: str = 'radius', radius: Optional[float] = None, k: Optional[int] = None, @@ -477,21 +478,21 @@ def type_adjacency_matrix( neighborhoods = compute_neighborhoods( data, method=method, radius=radius, k=k ) - + cell_types = data._cell_types unique_types = list(data.cell_types_unique) n_types = len(unique_types) type_to_idx = {ct: i for i, ct in enumerate(unique_types)} - + # Count type-type edges counts = np.zeros((n_types, n_types), dtype=float) - + for i, neighbors in enumerate(neighborhoods): type_i = type_to_idx[cell_types[i]] for j in neighbors: type_j = type_to_idx[cell_types[j]] counts[type_i, type_j] += 1 - + # Normalize if requested if normalize == 'row': row_sums = counts.sum(axis=1, keepdims=True) @@ -508,12 +509,12 @@ def type_adjacency_matrix( ]) n_total = data.n_cells total_edges = counts.sum() - + expected = np.outer(type_counts, type_counts) / (n_total ** 2) * total_edges counts = counts / np.where(expected > 0, expected, 1) elif normalize != 'none': raise ValueError(f"Unknown normalize: {normalize}") - + return pd.DataFrame(counts, index=unique_types, columns=unique_types) @@ -541,7 +542,7 @@ def neighborhood_size( def neighborhood_diversity( - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhoods: List[np.ndarray], metric: str = 'shannon' ) -> np.ndarray: @@ -571,30 +572,30 @@ def neighborhood_diversity( composition = neighborhood_composition( data, neighborhoods=neighborhoods, normalize=True ) - + if metric == 'shannon': # Shannon entropy with np.errstate(divide='ignore', invalid='ignore'): log_p = np.log(composition) log_p = np.where(np.isfinite(log_p), log_p, 0) diversity = -np.sum(composition * log_p, axis=1) - + elif metric == 'simpson': # Simpson's diversity index (1 - dominance) diversity = 1 - np.sum(composition ** 2, axis=1) - + elif metric == 'richness': # Number of types present diversity = np.sum(composition > 0, axis=1).astype(float) - + else: raise ValueError(f"Unknown metric: {metric}") - + return diversity def neighborhood_enrichment( - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhoods: List[np.ndarray], target_type: str ) -> np.ndarray: @@ -620,24 +621,24 @@ def neighborhood_enrichment( composition = neighborhood_composition( data, neighborhoods=neighborhoods, normalize=True ) - + # Get column index for target type unique_types = list(data.cell_types_unique) if target_type not in unique_types: raise ValueError(f"Unknown cell type: {target_type}") - + type_idx = unique_types.index(target_type) observed = composition[:, type_idx] - + # Expected proportion (global) expected = np.sum(data._cell_types == target_type) / data.n_cells - + # Enrichment = observed / expected return observed / max(expected, 1e-10) def interface_cells( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float, @@ -673,28 +674,27 @@ def interface_cells( ... ) """ neighborhoods = compute_neighborhoods(data, method='radius', radius=radius) - cell_types = data._cell_types - + idx_a = data.get_cells_by_type(type_a) idx_b = data.get_cells_by_type(type_b) - + idx_b_set = set(idx_b) idx_a_set = set(idx_a) - + # Type A cells with >= min_neighbors of type B type_a_interface = [] for i in idx_a: n_type_b = sum(1 for j in neighborhoods[i] if j in idx_b_set) if n_type_b >= min_neighbors: type_a_interface.append(i) - + # Type B cells with >= min_neighbors of type A type_b_interface = [] for i in idx_b: n_type_a = sum(1 for j in neighborhoods[i] if j in idx_a_set) if n_type_a >= min_neighbors: type_b_interface.append(i) - + return np.array(type_a_interface), np.array(type_b_interface) @@ -703,7 +703,7 @@ def interface_cells( # ----------------------------------------------------------------------------- def neighborhood_to_dataframe( - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhoods: Optional[List[np.ndarray]] = None, method: str = 'radius', radius: Optional[float] = None, @@ -729,15 +729,15 @@ def neighborhood_to_dataframe( composition = neighborhood_composition( data, neighborhoods=neighborhoods, method=method, radius=radius, k=k ) - + df = pd.DataFrame( composition, columns=data.cell_types_unique ) - + # Add metadata columns df['cell_type'] = data._cell_types if data._sample_ids is not None: df['sample_id'] = data._sample_ids - + return df diff --git a/spatialtissuepy/statistics/__init__.py b/spatialtissuepy/statistics/__init__.py index 722e68a..739032d 100644 --- a/spatialtissuepy/statistics/__init__.py +++ b/spatialtissuepy/statistics/__init__.py @@ -24,16 +24,16 @@ ... colocalization_quotient, ... detect_hotspots, ... ) ->>> +>>> >>> # Test for clustering using Ripley's H >>> radii = np.linspace(0, 100, 50) >>> H = ripleys_h(data.coordinates, radii) >>> # H > 0 indicates clustering at that scale ->>> +>>> >>> # Test co-localization between cell types >>> clq = colocalization_quotient(data, 'T_cell', 'Tumor', radius=50) >>> # CLQ > 1 indicates attraction, CLQ < 1 indicates repulsion ->>> +>>> >>> # Find spatial hotspots >>> result = detect_hotspots(data, values, radius=50) >>> hotspot_cells = result['hotspot_idx'] @@ -47,61 +47,60 @@ """ # Spatial statistics (Ripley's K, etc.) -from .spatial_stats import ( - # K-function and variants - ripleys_k, - ripleys_l, - ripleys_h, - # Cross-type functions - cross_k, - cross_l, - cross_h, - # Nearest-neighbor functions - g_function, - g_function_cross, - f_function, - j_function, - # Pair correlation - pair_correlation_function, - # CSR envelope testing - csr_envelope, - # High-level functions - spatial_statistics, - cross_type_statistics, -) - # Co-localization analysis from .colocalization import ( + colocalization_matrix, # Co-localization quotient colocalization_quotient, - colocalization_matrix, + gearys_c, + morans_i, + neighborhood_enrichment_matrix, # Neighborhood enrichment neighborhood_enrichment_score, neighborhood_enrichment_test, - neighborhood_enrichment_matrix, - # Spatial interaction - spatial_interaction_matrix, # Spatial autocorrelation spatial_cross_correlation, - morans_i, - gearys_c, + # Spatial interaction + spatial_interaction_matrix, ) # Hotspot detection from .hotspots import ( - # Getis-Ord statistics - getis_ord_gi_star, - getis_ord_gi, - # Local Moran's I - local_morans_i, + cell_type_hotspots, # Detection functions detect_hotspots, - cell_type_hotspots, - marker_hotspots, + getis_ord_gi, + # Getis-Ord statistics + getis_ord_gi_star, + hotspot_regions, # Statistics and regions hotspot_statistics, - hotspot_regions, hotspot_summary_by_type, + # Local Moran's I + local_morans_i, + marker_hotspots, +) +from .spatial_stats import ( + cross_h, + # Cross-type functions + cross_k, + cross_l, + cross_type_statistics, + # CSR envelope testing + csr_envelope, + f_function, + # Nearest-neighbor functions + g_function, + g_function_cross, + j_function, + # Pair correlation + pair_correlation_function, + ripleys_h, + # K-function and variants + ripleys_k, + ripleys_l, + # High-level functions + spatial_statistics, ) __all__ = [ diff --git a/spatialtissuepy/statistics/colocalization.py b/spatialtissuepy/statistics/colocalization.py index b3006f8..8229bf5 100644 --- a/spatialtissuepy/statistics/colocalization.py +++ b/spatialtissuepy/statistics/colocalization.py @@ -20,11 +20,13 @@ """ from __future__ import annotations -from typing import Optional, Tuple, Dict, List, TYPE_CHECKING + +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + import numpy as np -from scipy.spatial import cKDTree -from scipy import stats import pandas as pd +from scipy import stats +from scipy.spatial import cKDTree if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -35,7 +37,7 @@ # ----------------------------------------------------------------------------- def colocalization_quotient( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float @@ -72,40 +74,40 @@ def colocalization_quotient( """ idx_a = data.get_cells_by_type(type_a) idx_b = data.get_cells_by_type(type_b) - + if len(idx_a) == 0 or len(idx_b) == 0: return np.nan - + coords_a = data._coordinates[idx_a] coords_b = data._coordinates[idx_b] - + # Build tree for type B tree_b = cKDTree(coords_b) - + # Count B neighbors for each A cell observed = 0 for coord in coords_a: neighbors = tree_b.query_ball_point(coord, radius) observed += len(neighbors) - + # Expected under random distribution # Expected = n_a * n_b * (π * r²) / area bounds = data.bounds area = (bounds['x'][1] - bounds['x'][0]) * (bounds['y'][1] - bounds['y'][0]) - + if area <= 0: return np.nan - + expected = len(idx_a) * len(idx_b) * (np.pi * radius**2) / area - + if expected <= 0: return np.nan - + return observed / expected def colocalization_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float, normalize: bool = True ) -> pd.DataFrame: @@ -133,9 +135,9 @@ def colocalization_matrix( """ cell_types = list(data.cell_types_unique) n_types = len(cell_types) - + matrix = np.zeros((n_types, n_types)) - + for i, type_a in enumerate(cell_types): for j, type_b in enumerate(cell_types): if normalize: @@ -144,19 +146,19 @@ def colocalization_matrix( # Raw counts idx_a = data.get_cells_by_type(type_a) idx_b = data.get_cells_by_type(type_b) - + if len(idx_a) == 0 or len(idx_b) == 0: matrix[i, j] = 0 else: coords_a = data._coordinates[idx_a] tree_b = cKDTree(data._coordinates[idx_b]) - + count = sum( len(tree_b.query_ball_point(c, radius)) for c in coords_a ) matrix[i, j] = count - + return pd.DataFrame(matrix, index=cell_types, columns=cell_types) @@ -165,7 +167,7 @@ def colocalization_matrix( # ----------------------------------------------------------------------------- def neighborhood_enrichment_score( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float @@ -193,47 +195,47 @@ def neighborhood_enrichment_score( """ idx_a = data.get_cells_by_type(type_a) idx_b = data.get_cells_by_type(type_b) - + if len(idx_a) == 0 or len(idx_b) == 0: return np.nan, np.nan - + coords_a = data._coordinates[idx_a] coords_b = data._coordinates[idx_b] n_b = len(idx_b) - + # Count B neighbors for each A cell tree_b = cKDTree(coords_b) counts = np.array([ len(tree_b.query_ball_point(c, radius)) for c in coords_a ]) - + observed_mean = np.mean(counts) - + # Expected under random: proportion * total possible neighbors bounds = data.bounds area = (bounds['x'][1] - bounds['x'][0]) * (bounds['y'][1] - bounds['y'][0]) - + if area <= 0: return np.nan, np.nan - + # Expected count based on density density_b = n_b / area expected_mean = density_b * np.pi * radius**2 - + # Variance under Poisson assumption expected_var = expected_mean - + if expected_mean <= 0: return np.nan, np.nan - + enrichment = observed_mean / expected_mean zscore = (observed_mean - expected_mean) / np.sqrt(expected_var / len(idx_a)) - + return enrichment, zscore def neighborhood_enrichment_test( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float, @@ -284,10 +286,10 @@ def neighborhood_enrichment_test( ... print("Significant segregation") """ rng = np.random.default_rng(seed) - + idx_a = data.get_cells_by_type(type_a) idx_b = data.get_cells_by_type(type_b) - + if len(idx_a) == 0 or len(idx_b) == 0: return { 'observed': np.nan, @@ -297,11 +299,11 @@ def neighborhood_enrichment_test( 'pvalue': np.nan, 'enrichment': np.nan, } - + coords = data._coordinates cell_types = data._cell_types.copy() - n_cells = len(cell_types) - + len(cell_types) + # Observed count coords_a = coords[idx_a] tree_b = cKDTree(coords[idx_b]) @@ -309,44 +311,44 @@ def neighborhood_enrichment_test( len(tree_b.query_ball_point(c, radius)) for c in coords_a ]) observed = np.sum(observed_counts) - + # Permutation distribution perm_counts = np.zeros(n_permutations) - + for i in range(n_permutations): # Shuffle cell type labels perm_types = rng.permutation(cell_types) - + # Get new indices perm_idx_a = np.where(perm_types == type_a)[0] perm_idx_b = np.where(perm_types == type_b)[0] - + if len(perm_idx_a) == 0 or len(perm_idx_b) == 0: perm_counts[i] = 0 continue - + # Count neighbors perm_tree_b = cKDTree(coords[perm_idx_b]) perm_counts[i] = sum( len(perm_tree_b.query_ball_point(coords[j], radius)) for j in perm_idx_a ) - + # Statistics expected = np.mean(perm_counts) std = np.std(perm_counts) - + if std > 0: zscore = (observed - expected) / std else: zscore = 0 if observed == expected else np.inf - + # Two-sided p-value more_extreme = np.sum(np.abs(perm_counts - expected) >= np.abs(observed - expected)) pvalue = (more_extreme + 1) / (n_permutations + 1) - + enrichment = observed / expected if expected > 0 else np.nan - + return { 'observed': float(observed), 'expected': float(expected), @@ -358,7 +360,7 @@ def neighborhood_enrichment_test( def neighborhood_enrichment_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float, n_permutations: int = 1000, seed: Optional[int] = None, @@ -389,28 +391,28 @@ def neighborhood_enrichment_matrix( """ cell_types = list(data.cell_types_unique) n_types = len(cell_types) - + enrichment = np.zeros((n_types, n_types)) pvalues = np.zeros((n_types, n_types)) - + rng = np.random.default_rng(seed) - + for i, type_a in enumerate(cell_types): for j, type_b in enumerate(cell_types): result = neighborhood_enrichment_test( - data, type_a, type_b, radius, - n_permutations, + data, type_a, type_b, radius, + n_permutations, seed=rng.integers(0, 2**31) ) enrichment[i, j] = result['enrichment'] pvalues[i, j] = result['pvalue'] - + enrichment_df = pd.DataFrame(enrichment, index=cell_types, columns=cell_types) - + if return_pvalues: pvalue_df = pd.DataFrame(pvalues, index=cell_types, columns=cell_types) return enrichment_df, pvalue_df - + return enrichment_df @@ -419,7 +421,7 @@ def neighborhood_enrichment_matrix( # ----------------------------------------------------------------------------- def spatial_interaction_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float, method: str = 'log_ratio' ) -> pd.DataFrame: @@ -451,24 +453,24 @@ def spatial_interaction_matrix( - Zero indicates random mixing """ cell_types = list(data.cell_types_unique) - n_types = len(cell_types) - + len(cell_types) + # Compute type adjacency counts from spatialtissuepy.spatial.neighborhood import type_adjacency_matrix - + observed = type_adjacency_matrix( data, method='radius', radius=radius, normalize='none' ).values - + # Expected under random mixing type_counts = np.array([ len(data.get_cells_by_type(ct)) for ct in cell_types ]) total_edges = observed.sum() n_total = data.n_cells - + expected = np.outer(type_counts, type_counts) / (n_total ** 2) * total_edges - + if method == 'count': matrix = observed elif method == 'log_ratio': @@ -483,7 +485,7 @@ def spatial_interaction_matrix( matrix = np.where(np.isfinite(matrix), matrix, 0) else: raise ValueError(f"Unknown method: {method}") - + return pd.DataFrame(matrix, index=cell_types, columns=cell_types) @@ -492,7 +494,7 @@ def spatial_interaction_matrix( # ----------------------------------------------------------------------------- def spatial_cross_correlation( - data: 'SpatialTissueData', + data: SpatialTissueData, marker_a: str, marker_b: str, radius: float, @@ -526,46 +528,46 @@ def spatial_cross_correlation( """ if data.markers is None: raise ValueError("No marker data available") - + if marker_a not in data.marker_names or marker_b not in data.marker_names: raise ValueError(f"Markers not found: {marker_a}, {marker_b}") - + expr_a = data.markers[marker_a].values expr_b = data.markers[marker_b].values - + # Build KD-tree tree = cKDTree(data._coordinates) - + # For each cell, compute mean neighbor expression of marker_b neighbor_mean_b = np.zeros(data.n_cells) - + for i in range(data.n_cells): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) > 0: neighbor_mean_b[i] = np.mean(expr_b[neighbors]) else: neighbor_mean_b[i] = np.nan - + # Remove cells with no neighbors valid = ~np.isnan(neighbor_mean_b) - + if np.sum(valid) < 3: return np.nan, np.nan - + if method == 'pearson': corr, pval = stats.pearsonr(expr_a[valid], neighbor_mean_b[valid]) elif method == 'spearman': corr, pval = stats.spearmanr(expr_a[valid], neighbor_mean_b[valid]) else: raise ValueError(f"Unknown method: {method}") - + return float(corr), float(pval) def morans_i( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float, permutations: int = 0, @@ -609,72 +611,72 @@ def morans_i( 'I': np.nan, 'expected': np.nan, 'variance': np.nan, 'zscore': np.nan, 'pvalue': np.nan } - + # Standardize values z = values - np.mean(values) - + # Build spatial weights tree = cKDTree(data._coordinates) - + # Compute Moran's I numerator = 0.0 W = 0.0 # Sum of weights - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + for j in neighbors: w = 1.0 # Binary weights numerator += w * z[i] * z[j] W += w - + if W == 0: return { 'I': np.nan, 'expected': np.nan, 'variance': np.nan, 'zscore': np.nan, 'pvalue': np.nan } - + denominator = np.sum(z**2) - + if denominator == 0: return { 'I': np.nan, 'expected': np.nan, 'variance': np.nan, 'zscore': np.nan, 'pvalue': np.nan } - - I = (n / W) * (numerator / denominator) - + + I = (n / W) * (numerator / denominator) # noqa: E741 (Moran's I) + # Expected value under null expected = -1.0 / (n - 1) - + # Analytical variance (simplified) variance = (n**2 * W - n * W + 3 * W**2) / ((n - 1) * (n + 1) * W**2) variance = max(variance - expected**2, 1e-10) - + zscore = (I - expected) / np.sqrt(variance) pvalue = 2 * (1 - stats.norm.cdf(abs(zscore))) - + # Permutation test if requested if permutations > 0: rng = np.random.default_rng(seed) perm_I = np.zeros(permutations) - + for p in range(permutations): perm_z = rng.permutation(z) perm_num = 0.0 - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + for j in neighbors: perm_num += perm_z[i] * perm_z[j] - + perm_I[p] = (n / W) * (perm_num / denominator) - + pvalue = (np.sum(np.abs(perm_I) >= np.abs(I)) + 1) / (permutations + 1) - + return { 'I': float(I), 'expected': float(expected), @@ -685,7 +687,7 @@ def morans_i( def gearys_c( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float ) -> Dict[str, float]: @@ -715,39 +717,39 @@ def gearys_c( n = len(values) if n < 3: return {'C': np.nan, 'expected': np.nan, 'zscore': np.nan, 'pvalue': np.nan} - + z = values - np.mean(values) tree = cKDTree(data._coordinates) - + # Compute Geary's C numerator = 0.0 W = 0.0 - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + for j in neighbors: numerator += (values[i] - values[j])**2 W += 1 - + if W == 0: return {'C': np.nan, 'expected': np.nan, 'zscore': np.nan, 'pvalue': np.nan} - + denominator = 2 * W * np.sum(z**2) / (n - 1) - + if denominator == 0: return {'C': np.nan, 'expected': np.nan, 'zscore': np.nan, 'pvalue': np.nan} - + C = numerator / denominator expected = 1.0 - + # Simplified variance variance = (2 * W + W**2) / (W**2 * (n - 1)) - + zscore = (C - expected) / np.sqrt(max(variance, 1e-10)) pvalue = 2 * (1 - stats.norm.cdf(abs(zscore))) - + return { 'C': float(C), 'expected': float(expected), diff --git a/spatialtissuepy/statistics/hotspots.py b/spatialtissuepy/statistics/hotspots.py index 483edb8..598303c 100644 --- a/spatialtissuepy/statistics/hotspots.py +++ b/spatialtissuepy/statistics/hotspots.py @@ -20,11 +20,13 @@ """ from __future__ import annotations -from typing import Optional, Dict, List, Tuple, TYPE_CHECKING + +from typing import TYPE_CHECKING, Dict, Optional, Union + import numpy as np -from scipy.spatial import cKDTree -from scipy import stats import pandas as pd +from scipy import stats +from scipy.spatial import cKDTree if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData @@ -35,7 +37,7 @@ # ----------------------------------------------------------------------------- def getis_ord_gi_star( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float, standardize: bool = True, @@ -80,55 +82,55 @@ def getis_ord_gi_star( """ n = len(values) values = np.asarray(values, dtype=float) - + # Global statistics x_bar = np.mean(values) s = np.std(values, ddof=0) - + if s == 0: return np.zeros(n) - + # Build KD-tree tree = cKDTree(data._coordinates) - + gi_star = np.zeros(n) - + for i in range(n): # Get neighbors including self neighbors = tree.query_ball_point(data._coordinates[i], radius) - + if len(neighbors) == 0: gi_star[i] = 0 continue - + # Sum of neighbor values (including self) neighbor_sum = np.sum(values[neighbors]) w_i = len(neighbors) # Number of neighbors (binary weights) - + # Gi* statistic numerator = neighbor_sum - x_bar * w_i - + # Denominator (standard error) denominator = s * np.sqrt((n * w_i - w_i**2) / (n - 1)) - + if denominator > 0: gi_star[i] = numerator / denominator else: gi_star[i] = 0 - + if not standardize: # Return raw Gi* (not z-score) result = gi_star * s / x_bar if x_bar != 0 else gi_star else: result = gi_star - + if return_dict: return {'gi_star': result} return result def getis_ord_gi( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float ) -> np.ndarray: @@ -157,36 +159,36 @@ def getis_ord_gi( """ n = len(values) values = np.asarray(values, dtype=float) - + x_bar = np.mean(values) s = np.std(values, ddof=0) - + if s == 0: return np.zeros(n) - + tree = cKDTree(data._coordinates) gi = np.zeros(n) - + for i in range(n): # Get neighbors excluding self neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: gi[i] = 0 continue - + neighbor_sum = np.sum(values[neighbors]) w_i = len(neighbors) - + numerator = neighbor_sum - x_bar * w_i denominator = s * np.sqrt((n * w_i - w_i**2) / (n - 1)) - + if denominator > 0: gi[i] = numerator / denominator else: gi[i] = 0 - + return gi @@ -195,7 +197,7 @@ def getis_ord_gi( # ----------------------------------------------------------------------------- def local_morans_i( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float, permutations: int = 0, @@ -236,85 +238,85 @@ def local_morans_i( """ n = len(values) values = np.asarray(values, dtype=float) - + # Standardize values z = (values - np.mean(values)) / np.std(values, ddof=0) z = np.nan_to_num(z, nan=0) - + tree = cKDTree(data._coordinates) - + I_local = np.zeros(n) lag = np.zeros(n) # Spatial lag - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: I_local[i] = 0 lag[i] = 0 continue - + # Spatial lag (mean of neighbors' standardized values) lag[i] = np.mean(z[neighbors]) - + # Local Moran's I I_local[i] = z[i] * lag[i] - + # Determine quadrants quadrant = np.zeros(n, dtype=int) quadrant[(z > 0) & (lag > 0)] = 1 # HH quadrant[(z < 0) & (lag > 0)] = 2 # LH quadrant[(z < 0) & (lag < 0)] = 3 # LL quadrant[(z > 0) & (lag < 0)] = 4 # HL - + # Analytical p-values (simplified) # Variance approximation E_I = -1 / (n - 1) - + zscore = np.zeros(n) pvalue = np.ones(n) - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: continue - + w_i = len(neighbors) var_I = (w_i * (n - 3)) / ((n - 1) * (n + 1)) - + if var_I > 0: zscore[i] = (I_local[i] - E_I) / np.sqrt(var_I) pvalue[i] = 2 * (1 - stats.norm.cdf(abs(zscore[i]))) - + # Permutation p-values if requested if permutations > 0: rng = np.random.default_rng(seed) pvalue = np.zeros(n) - + for i in range(n): neighbors = tree.query_ball_point(data._coordinates[i], radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: pvalue[i] = 1.0 continue - + observed_I = I_local[i] count_extreme = 0 - + for _ in range(permutations): perm_idx = rng.choice(n, size=len(neighbors), replace=False) perm_lag = np.mean(z[perm_idx]) perm_I = z[i] * perm_lag - + if abs(perm_I) >= abs(observed_I): count_extreme += 1 - + pvalue[i] = (count_extreme + 1) / (permutations + 1) - + return { 'I': I_local, 'zscore': zscore, @@ -329,7 +331,7 @@ def local_morans_i( # ----------------------------------------------------------------------------- def detect_hotspots( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float, method: str = 'gi_star', @@ -370,9 +372,9 @@ def detect_hotspots( """ if 'significance' in kwargs: alpha = kwargs.pop('significance') - + n = len(values) - + if method == 'gi_star': statistic = getis_ord_gi_star(data, values, radius) pvalue = 2 * (1 - stats.norm.cdf(np.abs(statistic))) @@ -385,7 +387,7 @@ def detect_hotspots( pvalue = result['pvalue'] else: raise ValueError(f"Unknown method: {method}") - + # Apply multiple testing correction if correction == 'bonferroni': adjusted_alpha = alpha / n @@ -393,26 +395,26 @@ def detect_hotspots( # Benjamini-Hochberg FDR sorted_idx = np.argsort(pvalue) sorted_pval = pvalue[sorted_idx] - + adjusted_pval = np.zeros(n) for i, idx in enumerate(sorted_idx): rank = i + 1 adjusted_pval[idx] = sorted_pval[i] * n / rank - + # Ensure monotonicity for i in range(n - 2, -1, -1): if adjusted_pval[sorted_idx[i]] > adjusted_pval[sorted_idx[i + 1]]: adjusted_pval[sorted_idx[i]] = adjusted_pval[sorted_idx[i + 1]] - + pvalue = np.minimum(adjusted_pval, 1.0) adjusted_alpha = alpha else: adjusted_alpha = alpha - + # Identify hotspots and coldspots is_hotspot = (statistic > 0) & (pvalue < adjusted_alpha) is_coldspot = (statistic < 0) & (pvalue < adjusted_alpha) - + return { 'statistic': statistic, 'pvalue': pvalue, @@ -424,7 +426,7 @@ def detect_hotspots( def cell_type_hotspots( - data: 'SpatialTissueData', + data: SpatialTissueData, cell_type: str, radius: float, method: str = 'gi_star', @@ -465,12 +467,12 @@ def cell_type_hotspots( """ # Create binary indicator for cell type indicator = (data._cell_types == cell_type).astype(float) - + return detect_hotspots(data, indicator, radius, method, alpha, **kwargs) def marker_hotspots( - data: 'SpatialTissueData', + data: SpatialTissueData, marker: str, radius: float, method: str = 'gi_star', @@ -502,12 +504,12 @@ def marker_hotspots( """ if data.markers is None: raise ValueError("No marker data available") - + if marker not in data.marker_names: raise ValueError(f"Marker not found: {marker}") - + values = data.markers[marker].values - + return detect_hotspots(data, values, radius, method, alpha, **kwargs) @@ -516,7 +518,7 @@ def marker_hotspots( # ----------------------------------------------------------------------------- def hotspot_statistics( - data: 'SpatialTissueData', + data: SpatialTissueData, hotspot_result: Dict[str, np.ndarray] ) -> Dict[str, float]: """ @@ -540,13 +542,13 @@ def hotspot_statistics( - hotspot_types: Cell type composition of hotspots """ n_total = data.n_cells - + hotspot_idx = hotspot_result.get('hotspot_idx', np.array([])) coldspot_idx = hotspot_result.get('coldspot_idx', np.array([])) - + n_hotspots = len(hotspot_idx) n_coldspots = len(coldspot_idx) - + # Cell type composition of hotspots if n_hotspots > 0: hotspot_types, counts = np.unique( @@ -555,7 +557,7 @@ def hotspot_statistics( hotspot_composition = dict(zip(hotspot_types, counts.astype(int))) else: hotspot_composition = {} - + # Cell type composition of coldspots if n_coldspots > 0: coldspot_types, counts = np.unique( @@ -564,7 +566,7 @@ def hotspot_statistics( coldspot_composition = dict(zip(coldspot_types, counts.astype(int))) else: coldspot_composition = {} - + return { 'n_hotspots': n_hotspots, 'n_coldspots': n_coldspots, @@ -576,7 +578,7 @@ def hotspot_statistics( def hotspot_regions( - data: 'SpatialTissueData', + data: SpatialTissueData, hotspot_result: Dict[str, np.ndarray], merge_radius: float ) -> np.ndarray: @@ -597,37 +599,37 @@ def hotspot_regions( np.ndarray Region labels for each cell (-1 for non-hotspot cells). """ - from scipy.sparse.csgraph import connected_components from scipy.sparse import csr_matrix - + from scipy.sparse.csgraph import connected_components + n_cells = data.n_cells hotspot_idx = hotspot_result.get('hotspot_idx', np.array([])) - + if len(hotspot_idx) == 0: return np.full(n_cells, -1, dtype=int) - + # Build graph of hotspot cells hotspot_coords = data._coordinates[hotspot_idx] tree = cKDTree(hotspot_coords) - + # Find connected pairs pairs = tree.query_pairs(merge_radius, output_type='ndarray') - + # Create adjacency matrix n_hotspots = len(hotspot_idx) row = np.concatenate([pairs[:, 0], pairs[:, 1]]) col = np.concatenate([pairs[:, 1], pairs[:, 0]]) data_vals = np.ones(len(row)) - + adj = csr_matrix((data_vals, (row, col)), shape=(n_hotspots, n_hotspots)) - + # Find connected components n_components, component_labels = connected_components(adj, directed=False) - + # Map back to full cell array region_labels = np.full(n_cells, -1, dtype=int) region_labels[hotspot_idx] = component_labels - + return region_labels @@ -636,7 +638,7 @@ def hotspot_regions( # ----------------------------------------------------------------------------- def hotspot_summary_by_type( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float, method: str = 'gi_star', alpha: float = 0.05 @@ -661,11 +663,11 @@ def hotspot_summary_by_type( Summary with columns for each cell type. """ results = [] - + for cell_type in data.cell_types_unique: result = cell_type_hotspots(data, cell_type, radius, method, alpha) stats = hotspot_statistics(data, result) - + results.append({ 'cell_type': cell_type, 'n_cells': len(data.get_cells_by_type(cell_type)), @@ -674,5 +676,5 @@ def hotspot_summary_by_type( 'hotspot_fraction': stats['hotspot_fraction'], 'coldspot_fraction': stats['coldspot_fraction'], }) - + return pd.DataFrame(results).set_index('cell_type') diff --git a/spatialtissuepy/statistics/metrics.py b/spatialtissuepy/statistics/metrics.py index 4dcad84..1e2db88 100644 --- a/spatialtissuepy/statistics/metrics.py +++ b/spatialtissuepy/statistics/metrics.py @@ -5,7 +5,8 @@ for standardized computation across samples. """ -from typing import Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict + import numpy as np from spatialtissuepy.summary.registry import register_metric @@ -31,13 +32,13 @@ def _ripleys_h_max( ) -> Dict[str, float]: """Compute maximum H(r) as summary of clustering.""" from .spatial_stats import ripleys_h - + radii = np.linspace(1, max_radius, n_radii) coords = data._coordinates[:, :2] - + if len(coords) < 2: return {'ripleys_h_max': np.nan} - + H = ripleys_h(coords, radii) return {'ripleys_h_max': float(np.max(H))} @@ -55,20 +56,20 @@ def _ripleys_h_by_type( ) -> Dict[str, float]: """Compute max H(r) per cell type.""" from .spatial_stats import ripleys_h - + radii = np.linspace(1, max_radius, n_radii) result = {} - + for cell_type in data.cell_types_unique: idx = data.get_cells_by_type(cell_type) coords = data._coordinates[idx, :2] - + if len(coords) >= 3: H = ripleys_h(coords, radii) result[f'ripleys_h_max_{cell_type}'] = float(np.max(H)) else: result[f'ripleys_h_max_{cell_type}'] = np.nan - + return result @@ -85,13 +86,13 @@ def _ripleys_h_auc( ) -> Dict[str, float]: """Compute AUC of H(r) curve.""" from .spatial_stats import ripleys_h - + radii = np.linspace(1, max_radius, n_radii) coords = data._coordinates[:, :2] - + if len(coords) < 2: return {'ripleys_h_auc': np.nan} - + H = ripleys_h(coords, radii) # Trapezoidal integration auc = np.trapz(H, radii) @@ -111,16 +112,16 @@ def _pcf_peak( ) -> Dict[str, float]: """Compute peak of pair correlation function.""" from .spatial_stats import pair_correlation_function - + radii = np.linspace(1, max_radius, n_radii) coords = data._coordinates[:, :2] - + if len(coords) < 2: return {'pcf_peak': np.nan, 'pcf_peak_radius': np.nan} - + g = pair_correlation_function(coords, radii) peak_idx = np.argmax(g) - + return { 'pcf_peak': float(g[peak_idx]), 'pcf_peak_radius': float(radii[peak_idx]), @@ -145,16 +146,16 @@ def _cross_h_max( ) -> Dict[str, float]: """Compute max cross-type H(r).""" from .spatial_stats import cross_h - + coords_a = data._coordinates[data.get_cells_by_type(type_a), :2] coords_b = data._coordinates[data.get_cells_by_type(type_b), :2] - + if len(coords_a) < 1 or len(coords_b) < 1: return {f'cross_h_max_{type_a}_{type_b}': np.nan} - + radii = np.linspace(1, max_radius, 20) H = cross_h(coords_a, coords_b, radii) - + return {f'cross_h_max_{type_a}_{type_b}': float(np.max(H))} @@ -172,15 +173,15 @@ def _cross_g_at_radius( ) -> Dict[str, float]: """Compute cross G-function at specific radius.""" from .spatial_stats import g_function_cross - + coords_a = data._coordinates[data.get_cells_by_type(type_a), :2] coords_b = data._coordinates[data.get_cells_by_type(type_b), :2] - + if len(coords_a) < 1 or len(coords_b) < 1: return {f'cross_g_{type_a}_{type_b}': np.nan} - + G = g_function_cross(coords_a, coords_b, np.array([radius])) - + return {f'cross_g_{type_a}_{type_b}': float(G[0])} @@ -202,7 +203,7 @@ def _coloc_quotient( ) -> Dict[str, float]: """Compute CLQ between two types.""" from .colocalization import colocalization_quotient - + clq = colocalization_quotient(data, type_a, type_b, radius) return {f'clq_{type_a}_{type_b}': clq} @@ -222,11 +223,11 @@ def _neighborhood_enrichment( ) -> Dict[str, float]: """Compute neighborhood enrichment with permutation test.""" from .colocalization import neighborhood_enrichment_test - + result = neighborhood_enrichment_test( data, type_a, type_b, radius, n_permutations ) - + return { f'enrichment_{type_a}_{type_b}': result['enrichment'], f'enrichment_zscore_{type_a}_{type_b}': result['zscore'], @@ -247,16 +248,16 @@ def _morans_i_metric( ) -> Dict[str, float]: """Compute Moran's I for marker.""" from .colocalization import morans_i - + if data.markers is None or marker not in data.marker_names: return { f'morans_i_{marker}': np.nan, f'morans_i_pvalue_{marker}': np.nan, } - + values = data.markers[marker].values result = morans_i(data, values, radius) - + return { f'morans_i_{marker}': result['I'], f'morans_i_pvalue_{marker}': result['pvalue'], @@ -277,14 +278,14 @@ def _spatial_interaction( ) -> Dict[str, float]: """Compute log-ratio interaction score.""" from .colocalization import spatial_interaction_matrix - + matrix = spatial_interaction_matrix(data, radius, method='log_ratio') - + if type_a in matrix.index and type_b in matrix.columns: score = matrix.loc[type_a, type_b] else: score = np.nan - + return {f'interaction_{type_a}_{type_b}': float(score)} @@ -306,10 +307,10 @@ def _hotspot_fraction( ) -> Dict[str, float]: """Compute fraction of cells in hotspots.""" from .hotspots import cell_type_hotspots, hotspot_statistics - + result = cell_type_hotspots(data, cell_type, radius, alpha=alpha) stats = hotspot_statistics(data, result) - + return { f'hotspot_fraction_{cell_type}': stats['hotspot_fraction'], f'coldspot_fraction_{cell_type}': stats['coldspot_fraction'], @@ -330,10 +331,10 @@ def _n_hotspot_cells( ) -> Dict[str, float]: """Count cells in hotspots.""" from .hotspots import cell_type_hotspots, hotspot_statistics - + result = cell_type_hotspots(data, cell_type, radius, alpha=alpha) stats = hotspot_statistics(data, result) - + return { f'n_hotspot_cells_{cell_type}': stats['n_hotspots'], f'n_coldspot_cells_{cell_type}': stats['n_coldspots'], @@ -353,10 +354,10 @@ def _mean_gi_star( ) -> Dict[str, float]: """Compute mean Gi* for cell type indicator.""" from .hotspots import getis_ord_gi_star - + indicator = (data._cell_types == cell_type).astype(float) gi_star = getis_ord_gi_star(data, indicator, radius) - + return { f'mean_gi_star_{cell_type}': float(np.mean(gi_star)), f'max_gi_star_{cell_type}': float(np.max(gi_star)), @@ -376,17 +377,17 @@ def _marker_hotspot_fraction( alpha: float = 0.05 ) -> Dict[str, float]: """Compute fraction of cells in marker hotspots.""" - from .hotspots import marker_hotspots, hotspot_statistics - + from .hotspots import hotspot_statistics, marker_hotspots + if data.markers is None or marker not in data.marker_names: return { f'marker_hotspot_fraction_{marker}': np.nan, f'marker_coldspot_fraction_{marker}': np.nan, } - + result = marker_hotspots(data, marker, radius, alpha=alpha) stats = hotspot_statistics(data, result) - + return { f'marker_hotspot_fraction_{marker}': stats['hotspot_fraction'], f'marker_coldspot_fraction_{marker}': stats['coldspot_fraction'], @@ -409,15 +410,15 @@ def _g_function_median( ) -> Dict[str, float]: """Compute median of G-function (typical nearest neighbor distance).""" from .spatial_stats import g_function - + radii = np.linspace(0.1, max_radius, 100) coords = data._coordinates[:, :2] - + if len(coords) < 2: return {'g_function_median': np.nan} - + G = g_function(coords, radii) - + # Find radius where G crosses 0.5 idx = np.searchsorted(G, 0.5) if idx >= len(radii): @@ -427,7 +428,7 @@ def _g_function_median( else: # Linear interpolation median_r = radii[idx-1] + (0.5 - G[idx-1]) * (radii[idx] - radii[idx-1]) / (G[idx] - G[idx-1] + 1e-10) - + return {'g_function_median': float(median_r)} @@ -443,15 +444,15 @@ def _j_function_summary( ) -> Dict[str, float]: """Compute J-function summary statistics.""" from .spatial_stats import j_function - + radii = np.linspace(1, max_radius, 30) coords = data._coordinates[:, :2] - + if len(coords) < 2: return {'j_function_mean': np.nan, 'j_function_min': np.nan} - + J = j_function(coords, radii) - + return { 'j_function_mean': float(np.mean(J)), 'j_function_min': float(np.min(J)), # < 1 indicates clustering diff --git a/spatialtissuepy/statistics/spatial_stats.py b/spatialtissuepy/statistics/spatial_stats.py index 84ef237..0e1a446 100644 --- a/spatialtissuepy/statistics/spatial_stats.py +++ b/spatialtissuepy/statistics/spatial_stats.py @@ -26,7 +26,9 @@ """ from __future__ import annotations -from typing import Optional, Tuple, Dict, List, Union, TYPE_CHECKING + +from typing import TYPE_CHECKING, Dict, List, Optional + import numpy as np from scipy.spatial import cKDTree from scipy.spatial.distance import cdist @@ -85,32 +87,32 @@ def ripleys_k( n = len(coordinates) if n < 2: return np.zeros(len(radii)) - + # Compute study region min_coords = coordinates.min(axis=0) max_coords = coordinates.max(axis=0) - + if area is None: area = np.prod(max_coords - min_coords) - + if area <= 0: return np.zeros(len(radii)) - + # Build KD-tree for efficient queries tree = cKDTree(coordinates) - + # Compute all pairwise distances up to max radius max_r = radii.max() pairs = tree.query_pairs(max_r, output_type='ndarray') - + if len(pairs) == 0: return np.zeros(len(radii)) - + # Compute distances for pairs distances = np.linalg.norm( coordinates[pairs[:, 0]] - coordinates[pairs[:, 1]], axis=1 ) - + # Edge correction weights if edge_correction == 'none': weights = np.ones(len(distances)) @@ -126,16 +128,16 @@ def ripleys_k( ) else: raise ValueError(f"Unknown edge_correction: {edge_correction}") - + # Compute K for each radius K = np.zeros(len(radii)) intensity = n / area - + for i, r in enumerate(radii): mask = distances <= r # Count pairs (multiply by 2 since we only have i np.ndarray: """ Compute Ripley's isotropic edge correction weights. - + For each point, the weight accounts for the fraction of the circle that falls within the study region. """ weights = np.ones(len(distances)) - + for idx, (i, j) in enumerate(pairs): d = distances[idx] if d == 0: continue - + # For both points, compute fraction of circle in bounds w_i = _circle_in_rectangle_fraction( coordinates[i], d, min_coords, max_coords @@ -166,10 +168,10 @@ def _ripley_edge_correction( w_j = _circle_in_rectangle_fraction( coordinates[j], d, min_coords, max_coords ) - + # Average weight weights[idx] = 2.0 / (w_i + w_j) if (w_i + w_j) > 0 else 1.0 - + return weights @@ -188,10 +190,10 @@ def _circle_in_rectangle_fraction( d_right = max_coords[0] - center[0] d_bottom = center[1] - min_coords[1] d_top = max_coords[1] - center[1] - + # Count how many boundaries the circle crosses fraction = 1.0 - + for d in [d_left, d_right, d_bottom, d_top]: if d < radius: # Approximate: fraction outside ≈ acos(d/r) / π @@ -199,7 +201,7 @@ def _circle_in_rectangle_fraction( fraction -= np.arccos(min(d / radius, 1.0)) / (2 * np.pi) else: fraction -= 0.25 # Point outside boundary - + return max(fraction, 0.1) # Minimum weight to avoid division issues @@ -272,7 +274,7 @@ def ripleys_h( Under CSR, H(r) = 0. - H(r) > 0 indicates clustering at scale r - H(r) < 0 indicates dispersion at scale r - + This is the most interpretable form for detecting spatial patterns. """ L = ripleys_l(coordinates, radii, area, edge_correction) @@ -320,24 +322,24 @@ def cross_k( - Kab(r) < π*r² indicates repulsion/segregation """ na, nb = len(coords_a), len(coords_b) - + if na == 0 or nb == 0: return np.zeros(len(radii)) - + # Combined coordinates for area calculation all_coords = np.vstack([coords_a, coords_b]) min_coords = all_coords.min(axis=0) max_coords = all_coords.max(axis=0) - + if area is None: area = np.prod(max_coords - min_coords) - + if area <= 0: return np.zeros(len(radii)) - + # Compute cross-distances distances = cdist(coords_a, coords_b).ravel() - + # Edge correction (simplified for cross-K) if edge_correction == 'none': weights = np.ones(len(distances)) @@ -354,14 +356,14 @@ def cross_k( ) weights[idx] = 1.0 / max(w, 0.1) idx += 1 - + # Compute K for each radius K = np.zeros(len(radii)) - + for i, r in enumerate(radii): mask = distances <= r K[i] = area * np.sum(weights[mask]) / (na * nb) - + return K @@ -452,44 +454,44 @@ def g_function( n = len(coordinates) if n < 2: return np.zeros(len(radii)) - + # Compute nearest neighbor distances tree = cKDTree(coordinates) nn_distances, _ = tree.query(coordinates, k=2) nn_distances = nn_distances[:, 1] # Exclude self - + # Compute empirical CDF G = np.zeros(len(radii)) - + if edge_correction == 'none': for i, r in enumerate(radii): G[i] = np.mean(nn_distances <= r) - + elif edge_correction in ['km', 'rs']: # Kaplan-Meier estimator with border distance censoring min_coords = coordinates.min(axis=0) max_coords = coordinates.max(axis=0) - + # Distance to nearest boundary border_dist = np.minimum( - np.minimum(coordinates[:, 0] - min_coords[0], + np.minimum(coordinates[:, 0] - min_coords[0], max_coords[0] - coordinates[:, 0]), np.minimum(coordinates[:, 1] - min_coords[1], max_coords[1] - coordinates[:, 1]) ) - + for i, r in enumerate(radii): # Points with nn_distance <= r and not censored observed = (nn_distances <= r) & (border_dist >= nn_distances) # Points at risk (border_dist >= r or nn_distance <= r) at_risk = (border_dist >= r) | (nn_distances <= r) - + if np.sum(at_risk) > 0: G[i] = np.sum(observed) / np.sum(at_risk) - + else: raise ValueError(f"Unknown edge_correction: {edge_correction}") - + return G @@ -519,11 +521,11 @@ def g_function_cross( """ if len(coords_a) == 0 or len(coords_b) == 0: return np.zeros(len(radii)) - + # Nearest b-neighbor for each a-point tree_b = cKDTree(coords_b) nn_distances, _ = tree_b.query(coords_a, k=1) - + # Empirical CDF G = np.array([np.mean(nn_distances <= r) for r in radii]) return G @@ -568,21 +570,21 @@ def f_function( """ if len(coordinates) == 0: return np.zeros(len(radii)) - + rng = np.random.default_rng(seed) - + # Generate random test points in bounding box min_coords = coordinates.min(axis=0) max_coords = coordinates.max(axis=0) - + test_points = rng.uniform( min_coords, max_coords, size=(n_test_points, coordinates.shape[1]) ) - + # Distance from test points to nearest data point tree = cKDTree(coordinates) nn_distances, _ = tree.query(test_points, k=1) - + # Empirical CDF F = np.array([np.mean(nn_distances <= r) for r in radii]) return F @@ -627,12 +629,12 @@ def j_function( """ G = g_function(coordinates, radii) F = f_function(coordinates, radii, n_test_points, seed) - + # Avoid division by zero with np.errstate(divide='ignore', invalid='ignore'): J = (1 - G) / (1 - F) J = np.where(np.isfinite(J), J, 1.0) - + return J @@ -678,44 +680,44 @@ def pair_correlation_function( n = len(coordinates) if n < 2: return np.ones(len(radii)) - + if bandwidth is None: if len(radii) > 1: bandwidth = (radii[-1] - radii[0]) / (len(radii) - 1) else: bandwidth = radii[0] / 10 - + min_coords = coordinates.min(axis=0) max_coords = coordinates.max(axis=0) - + if area is None: area = np.prod(max_coords - min_coords) - + if area <= 0: return np.ones(len(radii)) - + intensity = n / area - + # Compute all pairwise distances tree = cKDTree(coordinates) max_r = radii.max() + 2 * bandwidth pairs = tree.query_pairs(max_r, output_type='ndarray') - + if len(pairs) == 0: return np.ones(len(radii)) - + distances = np.linalg.norm( coordinates[pairs[:, 0]] - coordinates[pairs[:, 1]], axis=1 ) - + # Kernel density estimate at each radius g = np.zeros(len(radii)) - + for i, r in enumerate(radii): if r <= 0: g[i] = 1.0 continue - + # Epanechnikov kernel u = (distances - r) / bandwidth kernel_weights = np.where( @@ -723,16 +725,16 @@ def pair_correlation_function( 0.75 * (1 - u**2) / bandwidth, 0 ) - + # Expected count under CSR at distance r ring_area = 2 * np.pi * r * bandwidth expected = n * intensity * ring_area / 2 - + if expected > 0: g[i] = 2 * np.sum(kernel_weights) / (n * intensity * 2 * np.pi * r) else: g[i] = 1.0 - + return g @@ -783,13 +785,13 @@ def csr_envelope( >>> significant = (H_observed < envelope['lower']) | (H_observed > envelope['upper']) """ rng = np.random.default_rng(seed) - + # Assume square region for simplicity side = np.sqrt(area) - + # Run simulations simulations = np.zeros((n_simulations, len(radii))) - + stat_func = { 'K': lambda c: ripleys_k(c, radii, area), 'L': lambda c: ripleys_l(c, radii, area), @@ -798,19 +800,19 @@ def csr_envelope( 'F': lambda c: f_function(c, radii, seed=None), 'g': lambda c: pair_correlation_function(c, radii, area=area), }.get(statistic) - + if stat_func is None: raise ValueError(f"Unknown statistic: {statistic}") - + for i in range(n_simulations): # Generate CSR pattern coords = rng.uniform(0, side, size=(n_points, 2)) simulations[i] = stat_func(coords) - + # Compute envelopes lower = np.percentile(simulations, 2.5, axis=0) upper = np.percentile(simulations, 97.5, axis=0) - + # Theoretical values under CSR intensity = n_points / area if statistic == 'K': @@ -825,7 +827,7 @@ def csr_envelope( theoretical = np.ones(len(radii)) else: theoretical = np.mean(simulations, axis=0) - + return { 'theoretical': theoretical, 'lower': lower, @@ -840,7 +842,7 @@ def csr_envelope( # ----------------------------------------------------------------------------- def spatial_statistics( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[np.ndarray] = None, n_radii: int = 50, max_radius: Optional[float] = None, @@ -876,23 +878,23 @@ def spatial_statistics( coords = data._coordinates[idx, :2] else: coords = data._coordinates[:, :2] - + if len(coords) < 2: raise ValueError("Need at least 2 points for spatial statistics") - + # Auto-generate radii if radii is None: if max_radius is None: extent = data.extent max_radius = min(extent['x'], extent['y']) / 4 radii = np.linspace(0, max_radius, n_radii) - + # Compute area bounds = data.bounds area = (bounds['x'][1] - bounds['x'][0]) * (bounds['y'][1] - bounds['y'][0]) - + result = {'radii': radii} - + for stat in statistics: if stat == 'K': result['K'] = ripleys_k(coords, radii, area) @@ -908,12 +910,12 @@ def spatial_statistics( result['J'] = j_function(coords, radii) elif stat == 'g': result['g'] = pair_correlation_function(coords, radii, area=area) - + return result def cross_type_statistics( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radii: Optional[np.ndarray] = None, @@ -946,22 +948,22 @@ def cross_type_statistics( """ coords_a = data._coordinates[data.get_cells_by_type(type_a), :2] coords_b = data._coordinates[data.get_cells_by_type(type_b), :2] - + if len(coords_a) == 0 or len(coords_b) == 0: raise ValueError(f"Need points of both types: {type_a}, {type_b}") - + # Auto-generate radii if radii is None: if max_radius is None: extent = data.extent max_radius = min(extent['x'], extent['y']) / 4 radii = np.linspace(0, max_radius, n_radii) - + bounds = data.bounds area = (bounds['x'][1] - bounds['x'][0]) * (bounds['y'][1] - bounds['y'][0]) - + result = {'radii': radii} - + for stat in statistics: if stat == 'K': result['K'] = cross_k(coords_a, coords_b, radii, area) @@ -971,5 +973,5 @@ def cross_type_statistics( result['H'] = cross_h(coords_a, coords_b, radii, area) elif stat == 'G': result['G'] = g_function_cross(coords_a, coords_b, radii) - + return result diff --git a/spatialtissuepy/summary/__init__.py b/spatialtissuepy/summary/__init__.py index a143f7c..6fc47d6 100644 --- a/spatialtissuepy/summary/__init__.py +++ b/spatialtissuepy/summary/__init__.py @@ -79,44 +79,42 @@ """ # Import metrics to register them -from . import population -from . import spatial -from . import neighborhood +from . import neighborhood, population, spatial + +# Public API - Panel +from .panel import ( + PanelMetric, + StatisticsPanel, + list_panels, + load_panel, +) # Public API - Registry from .registry import ( - # Core registry functions - register_metric, + # Classes and exceptions + MetricInfo, + MetricRegistrationError, + MetricValidationError, + clear_custom_metrics, + describe_metric, get_metric, - list_metrics, - list_categories, get_registry, - describe_metric, + list_categories, + list_custom_metrics, + list_metrics, # Custom metric support register_custom_metric, + # Core registry functions + register_metric, unregister_custom_metric, - list_custom_metrics, - clear_custom_metrics, - # Classes and exceptions - MetricInfo, - MetricValidationError, - MetricRegistrationError, -) - -# Public API - Panel -from .panel import ( - StatisticsPanel, - PanelMetric, - load_panel, - list_panels, ) # Public API - Summary from .summary import ( - SpatialSummary, MultiSampleSummary, - compute_summary, + SpatialSummary, compute_multi_summary, + compute_summary, ) __all__ = [ diff --git a/spatialtissuepy/summary/neighborhood.py b/spatialtissuepy/summary/neighborhood.py index ed59025..0e31346 100644 --- a/spatialtissuepy/summary/neighborhood.py +++ b/spatialtissuepy/summary/neighborhood.py @@ -5,7 +5,9 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +from typing import TYPE_CHECKING, Dict + import numpy as np from scipy.spatial import cKDTree @@ -22,21 +24,21 @@ parameters={'radius': float} ) def mean_neighborhood_entropy( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float = 50.0, ) -> Dict[str, float]: """ Compute mean neighborhood entropy across all cells. - + Higher entropy = more diverse neighborhoods. - + Parameters ---------- data : SpatialTissueData Input data. radius : float Neighborhood radius. - + Returns ------- dict @@ -47,43 +49,43 @@ def mean_neighborhood_entropy( unique_types = list(data.cell_types_unique) n_types = len(unique_types) type_to_idx = {t: i for i, t in enumerate(unique_types)} - + if len(coords) < 2 or n_types < 2: return { 'mean_neighborhood_entropy': np.nan, 'std_neighborhood_entropy': np.nan } - + tree = cKDTree(coords) entropies = [] - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: entropies.append(0.0) continue - + # Count types in neighborhood counts = np.zeros(n_types) for j in neighbors: counts[type_to_idx[cell_types[j]]] += 1 - + # Entropy props = counts / counts.sum() props = props[props > 0] entropy = -np.sum(props * np.log(props)) - + # Normalize by max possible max_entropy = np.log(n_types) if max_entropy > 0: entropy = entropy / max_entropy - + entropies.append(entropy) - + entropies = np.array(entropies) - + return { 'mean_neighborhood_entropy': entropies.mean(), 'std_neighborhood_entropy': entropies.std(), @@ -98,12 +100,12 @@ def mean_neighborhood_entropy( dynamic_columns=True ) def mean_neighborhood_composition( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float = 50.0, ) -> Dict[str, float]: """ Compute mean neighborhood composition. - + Returns ------- dict @@ -114,31 +116,31 @@ def mean_neighborhood_composition( unique_types = list(data.cell_types_unique) n_types = len(unique_types) type_to_idx = {t: i for i, t in enumerate(unique_types)} - + if len(coords) < 2: return {f'mean_neighbor_prop_{t}': np.nan for t in unique_types} - + tree = cKDTree(coords) - + # Accumulate proportions all_props = np.zeros((len(coords), n_types)) - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: continue - + # Count types counts = np.zeros(n_types) for j in neighbors: counts[type_to_idx[cell_types[j]]] += 1 - + all_props[i] = counts / counts.sum() - + mean_props = all_props.mean(axis=0) - + return { f'mean_neighbor_prop_{t}': mean_props[type_to_idx[t]] for t in unique_types @@ -152,14 +154,14 @@ def mean_neighborhood_composition( parameters={'radius': float} ) def neighborhood_homogeneity( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float = 50.0, ) -> Dict[str, float]: """ Compute neighborhood homogeneity (same-type neighbor fraction). - + Higher = cells tend to be near same type. - + Returns ------- dict @@ -168,41 +170,41 @@ def neighborhood_homogeneity( coords = data.coordinates cell_types = data.cell_types unique_types = list(data.cell_types_unique) - + if len(coords) < 2: result = {'mean_homogeneity': np.nan} result.update({f'homogeneity_{t}': np.nan for t in unique_types}) return result - + tree = cKDTree(coords) - + same_type_fracs = [] type_fracs = {t: [] for t in unique_types} - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: continue - + focal_type = cell_types[i] same_type = sum(1 for j in neighbors if cell_types[j] == focal_type) frac = same_type / len(neighbors) - + same_type_fracs.append(frac) type_fracs[focal_type].append(frac) - + result = { 'mean_homogeneity': np.mean(same_type_fracs) if same_type_fracs else np.nan } - + for t in unique_types: if type_fracs[t]: result[f'homogeneity_{t}'] = np.mean(type_fracs[t]) else: result[f'homogeneity_{t}'] = np.nan - + return result @@ -213,20 +215,20 @@ def neighborhood_homogeneity( parameters={'type_a': str, 'type_b': str, 'radius': float} ) def colocalization_score( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float = 50.0, ) -> Dict[str, float]: """ Compute co-localization score between two cell types. - + Score = (observed A-B pairs) / (expected under CSR) - + > 1: co-localized (attract) = 1: random < 1: segregated (repel) - + Parameters ---------- data : SpatialTissueData @@ -235,7 +237,7 @@ def colocalization_score( Cell types to analyze. radius : float Interaction radius. - + Returns ------- dict @@ -243,40 +245,40 @@ def colocalization_score( """ coords = data.coordinates cell_types = data.cell_types - n = len(coords) - + len(coords) + mask_a = cell_types == type_a mask_b = cell_types == type_b - + n_a = mask_a.sum() n_b = mask_b.sum() - + if n_a == 0 or n_b == 0: return {f'coloc_{type_a}_{type_b}': np.nan} - + coords_a = coords[mask_a] coords_b = coords[mask_b] - + # Count observed A-B pairs within radius tree_b = cKDTree(coords_b) observed = 0 for coord_a in coords_a: n_neighbors = len(tree_b.query_ball_point(coord_a, radius)) observed += n_neighbors - + # Expected under CSR extent = data.extent area = extent['x'] * extent['y'] - + if area == 0: return {f'coloc_{type_a}_{type_b}': np.nan} - + # Expected pairs = n_a * n_b * (pi * r^2 / area) circle_area = np.pi * radius ** 2 expected = n_a * n_b * (circle_area / area) - + score = observed / expected if expected > 0 else np.nan - + return {f'coloc_{type_a}_{type_b}': score} @@ -287,20 +289,20 @@ def colocalization_score( parameters={'type_a': str, 'type_b': str, 'radius': float} ) def mixing_score( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float = 50.0, ) -> Dict[str, float]: """ Compute mixing score between two cell types. - + Mixing score = proportion of cross-type neighbors among all neighbors for cells of type A and B. - + 1 = perfectly mixed 0 = completely segregated - + Returns ------- dict @@ -308,39 +310,39 @@ def mixing_score( """ coords = data.coordinates cell_types = data.cell_types - + mask_a = cell_types == type_a mask_b = cell_types == type_b mask_ab = mask_a | mask_b - + n_ab = mask_ab.sum() - + if n_ab < 2: return {f'mixing_{type_a}_{type_b}': np.nan} - + coords_ab = coords[mask_ab] types_ab = cell_types[mask_ab] - + tree = cKDTree(coords_ab) - + cross_type_neighbors = 0 total_neighbors = 0 - + for i, coord in enumerate(coords_ab): neighbors = tree.query_ball_point(coord, radius) neighbors = [j for j in neighbors if j != i] - + if len(neighbors) == 0: continue - + focal_type = types_ab[i] for j in neighbors: total_neighbors += 1 if types_ab[j] != focal_type: cross_type_neighbors += 1 - + mixing = cross_type_neighbors / total_neighbors if total_neighbors > 0 else np.nan - + return {f'mixing_{type_a}_{type_b}': mixing} @@ -352,12 +354,12 @@ def mixing_score( dynamic_columns=True ) def interaction_strength_matrix( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float = 50.0, ) -> Dict[str, float]: """ Compute interaction strength for all cell type pairs. - + Returns ------- dict @@ -365,14 +367,14 @@ def interaction_strength_matrix( """ unique_types = list(data.cell_types_unique) result = {} - + for i, type_a in enumerate(unique_types): for type_b in unique_types[i:]: # Upper triangle including diagonal coloc = colocalization_score(data, type_a, type_b, radius=radius) key_in = f'coloc_{type_a}_{type_b}' key_out = f'interaction_{type_a}_{type_b}' result[key_out] = coloc.get(key_in, np.nan) - + return result @@ -383,14 +385,14 @@ def interaction_strength_matrix( parameters={'type_a': str, 'type_b': str, 'contact_radius': float} ) def border_contact_score( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, contact_radius: float = 20.0, ) -> Dict[str, float]: """ Compute fraction of type A cells in contact with type B. - + Returns ------- dict @@ -398,29 +400,29 @@ def border_contact_score( """ coords = data.coordinates cell_types = data.cell_types - + mask_a = cell_types == type_a mask_b = cell_types == type_b - + n_a = mask_a.sum() - + if n_a == 0 or mask_b.sum() == 0: return {f'contact_{type_a}_with_{type_b}': np.nan} - + coords_a = coords[mask_a] coords_b = coords[mask_b] - + tree_b = cKDTree(coords_b) - + # Count A cells with at least one B neighbor in_contact = 0 for coord_a in coords_a: neighbors = tree_b.query_ball_point(coord_a, contact_radius) if len(neighbors) > 0: in_contact += 1 - + fraction = in_contact / n_a - + return {f'contact_{type_a}_with_{type_b}': fraction} @@ -431,17 +433,17 @@ def border_contact_score( parameters={'infiltrating_type': str, 'target_type': str, 'radius': float} ) def infiltration_score( - data: 'SpatialTissueData', + data: SpatialTissueData, infiltrating_type: str, target_type: str, radius: float = 100.0, ) -> Dict[str, float]: """ Compute infiltration score. - + Measures how much infiltrating_type cells penetrate into regions dominated by target_type. - + Returns ------- dict @@ -449,34 +451,34 @@ def infiltration_score( """ coords = data.coordinates cell_types = data.cell_types - + mask_inf = cell_types == infiltrating_type mask_target = cell_types == target_type - + if mask_inf.sum() == 0 or mask_target.sum() == 0: return {f'infiltration_{infiltrating_type}_into_{target_type}': np.nan} - + coords_inf = coords[mask_inf] coords_target = coords[mask_target] - + # For each infiltrating cell, compute local target density tree_target = cKDTree(coords_target) - + densities = [] for coord in coords_inf: n_target_near = len(tree_target.query_ball_point(coord, radius)) local_density = n_target_near / (np.pi * radius ** 2) densities.append(local_density) - + mean_density = np.mean(densities) - + # Normalize by global target density extent = data.extent global_density = mask_target.sum() / (extent['x'] * extent['y']) - + if global_density > 0: infiltration = mean_density / global_density else: infiltration = np.nan - + return {f'infiltration_{infiltrating_type}_into_{target_type}': infiltration} diff --git a/spatialtissuepy/summary/panel.py b/spatialtissuepy/summary/panel.py index 021fa19..e42993c 100644 --- a/spatialtissuepy/summary/panel.py +++ b/spatialtissuepy/summary/panel.py @@ -7,20 +7,18 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Callable, Dict, List, Optional, - Tuple, Union -) -from dataclasses import dataclass, field -import json -import copy + import functools -import warnings +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from .registry import ( - get_metric, list_metrics, list_categories, MetricInfo, - _validate_metric_function, _validate_metric_output, - MetricValidationError + MetricInfo, + _validate_metric_function, + _validate_metric_output, + get_metric, + list_metrics, ) if TYPE_CHECKING: @@ -55,7 +53,7 @@ class PanelMetric: alias: Optional[str] = None is_inline: bool = False - def compute(self, data: 'SpatialTissueData') -> Dict[str, float]: + def compute(self, data: SpatialTissueData) -> Dict[str, float]: """Compute this metric on data.""" return self.metric_info(data, **self.params) @@ -137,7 +135,7 @@ def add( metric_name: str, alias: Optional[str] = None, **params - ) -> 'StatisticsPanel': + ) -> StatisticsPanel: """ Add a registered metric to the panel. @@ -193,7 +191,7 @@ def add_custom_function( description: str = "", validate: bool = True, **params - ) -> 'StatisticsPanel': + ) -> StatisticsPanel: """ Add a custom function directly to this panel. @@ -289,7 +287,7 @@ def add_custom_function( # Create wrapped function with output validation @functools.wraps(fn) - def validated_fn(data: 'SpatialTissueData', **kwargs) -> Dict[str, float]: + def validated_fn(data: SpatialTissueData, **kwargs) -> Dict[str, float]: merged_params = {**params, **kwargs} result = fn(data, **merged_params) return _validate_metric_output(result, name) @@ -323,7 +321,7 @@ def add_all( self, category: Optional[str] = None, include_custom: bool = True - ) -> 'StatisticsPanel': + ) -> StatisticsPanel: """ Add all registered metrics, optionally filtered by category. @@ -344,7 +342,7 @@ def add_all( self.add(name) return self - def remove(self, metric_name: str) -> 'StatisticsPanel': + def remove(self, metric_name: str) -> StatisticsPanel: """ Remove a metric from the panel. @@ -365,7 +363,7 @@ def remove(self, metric_name: str) -> 'StatisticsPanel': self._metric_names.discard(metric_name) return self - def clear(self) -> 'StatisticsPanel': + def clear(self) -> StatisticsPanel: """Remove all metrics from the panel.""" self._metrics = [] self._metric_names = set() @@ -391,7 +389,7 @@ def is_serializable(self) -> bool: """Check if entire panel can be serialized to JSON.""" return not self.has_inline_metrics - def compute(self, data: 'SpatialTissueData') -> Dict[str, float]: + def compute(self, data: SpatialTissueData) -> Dict[str, float]: """ Compute all metrics on data. @@ -464,7 +462,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, config: Dict[str, Any]) -> 'StatisticsPanel': + def from_dict(cls, config: Dict[str, Any]) -> StatisticsPanel: """ Create panel from dictionary. @@ -505,13 +503,13 @@ def to_json(self, filepath: str) -> None: json.dump(self.to_dict(), f, indent=2) @classmethod - def from_json(cls, filepath: str) -> 'StatisticsPanel': + def from_json(cls, filepath: str) -> StatisticsPanel: """Load panel from JSON file.""" - with open(filepath, 'r') as f: + with open(filepath) as f: config = json.load(f) return cls.from_dict(config) - def copy(self) -> 'StatisticsPanel': + def copy(self) -> StatisticsPanel: """ Create a copy of this panel. diff --git a/spatialtissuepy/summary/population.py b/spatialtissuepy/summary/population.py index 8fda2f9..348beff 100644 --- a/spatialtissuepy/summary/population.py +++ b/spatialtissuepy/summary/population.py @@ -6,7 +6,9 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from typing import TYPE_CHECKING, Dict, List, Optional + import numpy as np from .registry import register_metric @@ -21,20 +23,20 @@ description='Total cell count and count per cell type', dynamic_columns=True ) -def cell_counts(data: 'SpatialTissueData') -> Dict[str, float]: +def cell_counts(data: SpatialTissueData) -> Dict[str, float]: """ Compute total and per-type cell counts. - + Returns ------- dict Keys: 'n_cells', 'n_{type}' for each cell type. """ result = {'n_cells': float(data.n_cells)} - + for cell_type, count in data.cell_type_counts.items(): result[f'n_{cell_type}'] = float(count) - + return result @@ -44,10 +46,10 @@ def cell_counts(data: 'SpatialTissueData') -> Dict[str, float]: description='Proportion of each cell type', dynamic_columns=True ) -def cell_proportions(data: 'SpatialTissueData') -> Dict[str, float]: +def cell_proportions(data: SpatialTissueData) -> Dict[str, float]: """ Compute proportion of each cell type. - + Returns ------- dict @@ -55,11 +57,11 @@ def cell_proportions(data: 'SpatialTissueData') -> Dict[str, float]: """ total = data.n_cells result = {} - + for cell_type, count in data.cell_type_counts.items(): prop = count / total if total > 0 else 0.0 result[f'prop_{cell_type}'] = prop - + return result @@ -70,13 +72,13 @@ def cell_proportions(data: 'SpatialTissueData') -> Dict[str, float]: parameters={'numerator': str, 'denominator': str} ) def cell_type_ratio( - data: 'SpatialTissueData', + data: SpatialTissueData, numerator: str, denominator: str, ) -> Dict[str, float]: """ Compute ratio of two cell type counts. - + Parameters ---------- data : SpatialTissueData @@ -85,22 +87,22 @@ def cell_type_ratio( Cell type for numerator. denominator : str Cell type for denominator. - + Returns ------- dict Key: '{numerator}_{denominator}_ratio'. """ counts = data.cell_type_counts - + num = counts.get(numerator, 0) denom = counts.get(denominator, 0) - + if denom == 0: ratio = np.nan if num == 0 else np.inf else: ratio = num / denom - + return {f'{numerator}_{denominator}_ratio': ratio} @@ -110,10 +112,10 @@ def cell_type_ratio( description='Cell density (cells per unit area)', dynamic_columns=True ) -def cell_density(data: 'SpatialTissueData') -> Dict[str, float]: +def cell_density(data: SpatialTissueData) -> Dict[str, float]: """ Compute cell density (cells per unit area). - + Returns ------- dict @@ -121,15 +123,15 @@ def cell_density(data: 'SpatialTissueData') -> Dict[str, float]: """ extent = data.extent area = extent['x'] * extent['y'] - + if area == 0: return {'density_total': np.nan} - + result = {'density_total': data.n_cells / area} - + for cell_type, count in data.cell_type_counts.items(): result[f'density_{cell_type}'] = count / area - + return result @@ -140,19 +142,19 @@ def cell_density(data: 'SpatialTissueData') -> Dict[str, float]: parameters={'normalize': bool} ) def shannon_diversity( - data: 'SpatialTissueData', + data: SpatialTissueData, normalize: bool = True ) -> Dict[str, float]: """ Compute Shannon diversity index. - + Parameters ---------- data : SpatialTissueData Input data. normalize : bool, default True If True, normalize by maximum possible diversity. - + Returns ------- dict @@ -160,20 +162,20 @@ def shannon_diversity( """ counts = data.cell_type_counts total = counts.sum() - + if total == 0: return {'shannon_diversity': np.nan} - + props = counts / total props = props[props > 0] # Remove zeros - + entropy = -np.sum(props * np.log(props)) - + if normalize: max_entropy = np.log(len(counts)) if max_entropy > 0: entropy = entropy / max_entropy - + return {'shannon_diversity': entropy} @@ -183,10 +185,10 @@ def shannon_diversity( description='Simpson diversity index (Gini-Simpson)', parameters={} ) -def simpson_diversity(data: 'SpatialTissueData') -> Dict[str, float]: +def simpson_diversity(data: SpatialTissueData) -> Dict[str, float]: """ Compute Simpson diversity index (1 - sum(p_i^2)). - + Returns ------- dict @@ -194,13 +196,13 @@ def simpson_diversity(data: 'SpatialTissueData') -> Dict[str, float]: """ counts = data.cell_type_counts total = counts.sum() - + if total == 0: return {'simpson_diversity': np.nan} - + props = counts / total simpson = 1 - np.sum(props ** 2) - + return {'simpson_diversity': simpson} @@ -212,13 +214,13 @@ def simpson_diversity(data: 'SpatialTissueData') -> Dict[str, float]: dynamic_columns=True ) def marker_statistics( - data: 'SpatialTissueData', + data: SpatialTissueData, markers: Optional[List[str]] = None, stats: Optional[List[str]] = None, ) -> Dict[str, float]: """ Compute marker expression statistics. - + Parameters ---------- data : SpatialTissueData @@ -228,7 +230,7 @@ def marker_statistics( stats : list of str, optional Statistics to compute: 'mean', 'std', 'median', 'p25', 'p75'. Default: ['mean']. - + Returns ------- dict @@ -236,15 +238,15 @@ def marker_statistics( """ if data.markers is None: return {} - + if markers is None: markers = data.marker_names - + if stats is None: stats = ['mean'] - + result = {} - + stat_funcs = { 'mean': np.nanmean, 'std': np.nanstd, @@ -254,19 +256,19 @@ def marker_statistics( 'min': np.nanmin, 'max': np.nanmax, } - + for marker in markers: if marker not in data.marker_names: continue - + values = data.markers[marker].values - + # Global statistics for stat in stats: if stat in stat_funcs: key = f'{marker}_{stat}' result[key] = stat_funcs[stat](values) - + return result @@ -278,14 +280,14 @@ def marker_statistics( dynamic_columns=True ) def marker_statistics_by_type( - data: 'SpatialTissueData', + data: SpatialTissueData, markers: Optional[List[str]] = None, stats: Optional[List[str]] = None, cell_types: Optional[List[str]] = None, ) -> Dict[str, float]: """ Compute marker expression statistics stratified by cell type. - + Parameters ---------- data : SpatialTissueData @@ -296,7 +298,7 @@ def marker_statistics_by_type( Statistics: 'mean', 'std', 'median'. cell_types : list of str, optional Cell types to include. - + Returns ------- dict @@ -304,40 +306,40 @@ def marker_statistics_by_type( """ if data.markers is None: return {} - + if markers is None: markers = data.marker_names - + if stats is None: stats = ['mean'] - + if cell_types is None: cell_types = list(data.cell_types_unique) - + stat_funcs = { 'mean': np.nanmean, 'std': np.nanstd, 'median': np.nanmedian, } - + result = {} - + for marker in markers: if marker not in data.marker_names: continue - + for cell_type in cell_types: mask = data.cell_types == cell_type if mask.sum() == 0: continue - + values = data.markers.loc[mask, marker].values - + for stat in stats: if stat in stat_funcs: key = f'{marker}_{cell_type}_{stat}' result[key] = stat_funcs[stat](values) - + return result @@ -346,27 +348,27 @@ def marker_statistics_by_type( category='morphology', description='Spatial extent of the tissue sample', ) -def spatial_extent(data: 'SpatialTissueData') -> Dict[str, float]: +def spatial_extent(data: SpatialTissueData) -> Dict[str, float]: """ Compute spatial extent metrics. - + Returns ------- dict Keys: 'extent_x', 'extent_y', 'extent_area', 'extent_z' (if 3D). """ extent = data.extent - + result = { 'extent_x': extent['x'], 'extent_y': extent['y'], 'extent_area': extent['x'] * extent['y'], } - + if 'z' in extent: result['extent_z'] = extent['z'] result['extent_volume'] = extent['x'] * extent['y'] * extent['z'] - + return result @@ -375,10 +377,10 @@ def spatial_extent(data: 'SpatialTissueData') -> Dict[str, float]: category='morphology', description='Centroid of all cells', ) -def centroid(data: 'SpatialTissueData') -> Dict[str, float]: +def centroid(data: SpatialTissueData) -> Dict[str, float]: """ Compute centroid of all cells. - + Returns ------- dict @@ -386,13 +388,13 @@ def centroid(data: 'SpatialTissueData') -> Dict[str, float]: """ coords = data.coordinates center = coords.mean(axis=0) - + result = { 'centroid_x': center[0], 'centroid_y': center[1], } - + if coords.shape[1] > 2: result['centroid_z'] = center[2] - + return result diff --git a/spatialtissuepy/summary/registry.py b/spatialtissuepy/summary/registry.py index 6cc6ae0..9634de1 100644 --- a/spatialtissuepy/summary/registry.py +++ b/spatialtissuepy/summary/registry.py @@ -28,14 +28,22 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Callable, Dict, List, Optional, - Type, Union, get_type_hints -) -from dataclasses import dataclass, field + import functools import inspect import warnings +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Type, + Union, + get_type_hints, +) if TYPE_CHECKING: from spatialtissuepy.core.spatial_data import SpatialTissueData @@ -93,7 +101,7 @@ class MetricInfo: def __call__( self, - data: 'SpatialTissueData', + data: SpatialTissueData, **kwargs ) -> Dict[str, float]: """Call the metric function.""" @@ -442,7 +450,7 @@ def _register(func: MetricFunction) -> MetricFunction: # Create wrapped function with output validation @functools.wraps(func) - def validated_func(data: 'SpatialTissueData', **kwargs) -> Dict[str, float]: + def validated_func(data: SpatialTissueData, **kwargs) -> Dict[str, float]: result = func(data, **kwargs) return _validate_metric_output(result, name) diff --git a/spatialtissuepy/summary/spatial.py b/spatialtissuepy/summary/spatial.py index f5f0965..0752547 100644 --- a/spatialtissuepy/summary/spatial.py +++ b/spatialtissuepy/summary/spatial.py @@ -5,10 +5,11 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +from typing import TYPE_CHECKING, Dict, List, Optional + import numpy as np from scipy.spatial import cKDTree -from scipy.spatial.distance import pdist from .registry import register_metric @@ -23,41 +24,41 @@ dynamic_columns=True ) def mean_nearest_neighbor_distance( - data: 'SpatialTissueData' + data: SpatialTissueData ) -> Dict[str, float]: """ Compute mean nearest neighbor distance. - + Returns ------- dict Keys: 'mean_nnd', 'mean_nnd_{type}' for each type. """ coords = data.coordinates - + if len(coords) < 2: return {'mean_nnd': np.nan} - + tree = cKDTree(coords) - + # Global mean NND (k=2 because first is self with distance 0) distances, _ = tree.query(coords, k=2) global_nnd = distances[:, 1].mean() - + result = {'mean_nnd': global_nnd} - + # Per-type NND for cell_type in data.cell_types_unique: mask = data.cell_types == cell_type if mask.sum() < 2: result[f'mean_nnd_{cell_type}'] = np.nan continue - + type_coords = coords[mask] type_tree = cKDTree(type_coords) type_distances, _ = type_tree.query(type_coords, k=2) result[f'mean_nnd_{cell_type}'] = type_distances[:, 1].mean() - + return result @@ -68,13 +69,13 @@ def mean_nearest_neighbor_distance( parameters={'type_from': str, 'type_to': str} ) def cross_type_nnd( - data: 'SpatialTissueData', + data: SpatialTissueData, type_from: str, type_to: str, ) -> Dict[str, float]: """ Compute mean distance from type A to nearest type B. - + Parameters ---------- data : SpatialTissueData @@ -83,26 +84,26 @@ def cross_type_nnd( Source cell type. type_to : str Target cell type. - + Returns ------- dict Key: 'nnd_{type_from}_to_{type_to}'. """ coords = data.coordinates - + mask_from = data.cell_types == type_from mask_to = data.cell_types == type_to - + if mask_from.sum() == 0 or mask_to.sum() == 0: return {f'nnd_{type_from}_to_{type_to}': np.nan} - + coords_from = coords[mask_from] coords_to = coords[mask_to] - + tree_to = cKDTree(coords_to) distances, _ = tree_to.query(coords_from, k=1) - + return {f'nnd_{type_from}_to_{type_to}': distances.mean()} @@ -112,18 +113,18 @@ def cross_type_nnd( description='Clark-Evans aggregation index R (R<1: clustered, R>1: dispersed)', dynamic_columns=True ) -def clark_evans_index(data: 'SpatialTissueData') -> Dict[str, float]: +def clark_evans_index(data: SpatialTissueData) -> Dict[str, float]: """ Compute Clark-Evans index R. - + R = mean_observed_NND / mean_expected_NND - + Where expected NND under CSR = 0.5 * sqrt(area/n) - + R < 1: clustered R = 1: random R > 1: dispersed - + Returns ------- dict @@ -131,44 +132,44 @@ def clark_evans_index(data: 'SpatialTissueData') -> Dict[str, float]: """ coords = data.coordinates n = len(coords) - + if n < 2: return {'clark_evans_R': np.nan} - + extent = data.extent area = extent['x'] * extent['y'] - + if area == 0: return {'clark_evans_R': np.nan} - + tree = cKDTree(coords) distances, _ = tree.query(coords, k=2) observed_nnd = distances[:, 1].mean() - + # Expected NND under CSR expected_nnd = 0.5 * np.sqrt(area / n) - + R = observed_nnd / expected_nnd if expected_nnd > 0 else np.nan - + result = {'clark_evans_R': R} - + # Per-type Clark-Evans for cell_type in data.cell_types_unique: mask = data.cell_types == cell_type n_type = mask.sum() - + if n_type < 2: result[f'clark_evans_R_{cell_type}'] = np.nan continue - + type_coords = coords[mask] type_tree = cKDTree(type_coords) type_distances, _ = type_tree.query(type_coords, k=2) obs_nnd_type = type_distances[:, 1].mean() exp_nnd_type = 0.5 * np.sqrt(area / n_type) - + result[f'clark_evans_R_{cell_type}'] = obs_nnd_type / exp_nnd_type - + return result @@ -180,17 +181,17 @@ def clark_evans_index(data: 'SpatialTissueData') -> Dict[str, float]: dynamic_columns=True ) def ripleys_k( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[List[float]] = None, edge_correction: bool = True, ) -> Dict[str, float]: """ Compute Ripley's K function at specified radii. - + K(r) = (A/n^2) * sum(I(d_ij < r)) - + Where A is area, n is number of points, d_ij is distance between i and j. - + Parameters ---------- data : SpatialTissueData @@ -199,7 +200,7 @@ def ripleys_k( Radii at which to compute K. Default: [25, 50, 100, 200]. edge_correction : bool, default True Apply Ripley's edge correction. - + Returns ------- dict @@ -207,38 +208,38 @@ def ripleys_k( """ if radii is None: radii = [25, 50, 100, 200] - + coords = data.coordinates n = len(coords) - + if n < 2: return {f'K_r{r}': np.nan for r in radii} - + extent = data.extent area = extent['x'] * extent['y'] bounds = data.bounds - + if area == 0: return {f'K_r{r}': np.nan for r in radii} - + tree = cKDTree(coords) - + result = {} - + for r in radii: # Count pairs within distance r count = 0 weight_sum = 0 - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, r) n_neighbors = len(neighbors) - 1 # Exclude self - + if edge_correction: # Ripley's isotropic edge correction # Weight by proportion of circle inside study region weight = _edge_correction_weight( - coord, r, + coord, r, bounds['x'][0], bounds['x'][1], bounds['y'][0], bounds['y'][1] ) @@ -246,14 +247,14 @@ def ripleys_k( weight_sum += weight else: count += n_neighbors - + if edge_correction and weight_sum > 0: K = (area / (n * weight_sum)) * count else: K = (area / (n * (n - 1))) * count - + result[f'K_r{int(r)}'] = K - + return result @@ -265,34 +266,34 @@ def _edge_correction_weight( ) -> float: """ Compute Ripley's isotropic edge correction weight. - + Approximates the proportion of the circle of radius r centered at point that lies within the rectangular study region. """ x, y = point[0], point[1] - + # Distances to boundaries d_left = x - x_min d_right = x_max - x d_bottom = y - y_min d_top = y_max - y - + # Minimum distance to boundary d_min = min(d_left, d_right, d_bottom, d_top) - + if d_min >= r: # Circle fully inside return 1.0 - + # Simplified approximation: based on nearest edge # More accurate would consider corner effects if d_min <= 0: return 0.5 - + # Proportion of circumference inside (approximate) # Using simplified arc correction prop_inside = 0.5 + 0.5 * (d_min / r) - + return max(0.5, min(1.0, prop_inside)) @@ -304,40 +305,40 @@ def _edge_correction_weight( dynamic_columns=True ) def l_function( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[List[float]] = None, ) -> Dict[str, float]: """ Compute Ripley's L function. - + L(r) = sqrt(K(r) / pi) - r - + L > 0: clustered L = 0: random (CSR) L < 0: dispersed - + Returns ------- dict Keys: 'L_r{radius}' for each radius. """ K_values = ripleys_k(data, radii=radii) - + if radii is None: radii = [25, 50, 100, 200] - + result = {} - + for r in radii: K = K_values.get(f'K_r{int(r)}', np.nan) - + if np.isnan(K) or K < 0: L = np.nan else: L = np.sqrt(K / np.pi) - r - + result[f'L_r{int(r)}'] = L - + return result @@ -348,21 +349,21 @@ def l_function( parameters={'radii': list} ) def g_function_summary( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[List[float]] = None, ) -> Dict[str, float]: """ Compute summary of the G function (empirical CDF of NND). - + Returns G(r) values and the median NND. - + Parameters ---------- data : SpatialTissueData Input data. radii : list of float, optional Radii at which to evaluate G. - + Returns ------- dict @@ -370,26 +371,26 @@ def g_function_summary( """ if radii is None: radii = [10, 25, 50, 100] - + coords = data.coordinates n = len(coords) - + if n < 2: result = {f'G_r{int(r)}': np.nan for r in radii} result['median_nnd'] = np.nan return result - + tree = cKDTree(coords) distances, _ = tree.query(coords, k=2) nnd = distances[:, 1] - + result = {'median_nnd': np.median(nnd)} - + for r in radii: # G(r) = proportion of NNDs <= r G_r = np.mean(nnd <= r) result[f'G_r{int(r)}'] = G_r - + return result @@ -400,13 +401,13 @@ def g_function_summary( parameters={'cell_type': str, 'radius': float} ) def spatial_autocorrelation( - data: 'SpatialTissueData', + data: SpatialTissueData, cell_type: str, radius: float = 50.0, ) -> Dict[str, float]: """ Compute Moran's I for a binary cell type indicator. - + Parameters ---------- data : SpatialTissueData @@ -415,7 +416,7 @@ def spatial_autocorrelation( Cell type to analyze. radius : float Radius for spatial weights. - + Returns ------- dict @@ -423,41 +424,41 @@ def spatial_autocorrelation( """ coords = data.coordinates n = len(coords) - + if n < 3: return {f'morans_I_{cell_type}': np.nan} - + # Binary indicator y = (data.cell_types == cell_type).astype(float) y_mean = y.mean() y_centered = y - y_mean - + if np.var(y) == 0: return {f'morans_I_{cell_type}': np.nan} - + # Spatial weights (binary within radius) tree = cKDTree(coords) - + numerator = 0.0 W = 0.0 - + for i in range(n): neighbors = tree.query_ball_point(coords[i], radius) for j in neighbors: if i != j: numerator += y_centered[i] * y_centered[j] W += 1 - + if W == 0: return {f'morans_I_{cell_type}': np.nan} - + denominator = np.sum(y_centered ** 2) - + if denominator == 0: return {f'morans_I_{cell_type}': np.nan} - - I = (n / W) * (numerator / denominator) - + + I = (n / W) * (numerator / denominator) # noqa: E741 (Moran's I) + return {f'morans_I_{cell_type}': I} @@ -466,32 +467,32 @@ def spatial_autocorrelation( category='morphology', description='Convex hull area and compactness', ) -def convex_hull_metrics(data: 'SpatialTissueData') -> Dict[str, float]: +def convex_hull_metrics(data: SpatialTissueData) -> Dict[str, float]: """ Compute convex hull metrics. - + Returns ------- dict Keys: 'convex_hull_area', 'compactness'. """ from scipy.spatial import ConvexHull - + coords = data.coordinates[:, :2] # Use 2D - + if len(coords) < 3: return {'convex_hull_area': np.nan, 'compactness': np.nan} - + try: hull = ConvexHull(coords) hull_area = hull.volume # In 2D, 'volume' is area - + # Compactness: ratio of actual spread to hull extent = data.extent bbox_area = extent['x'] * extent['y'] - + compactness = hull_area / bbox_area if bbox_area > 0 else np.nan - + return { 'convex_hull_area': hull_area, 'compactness': compactness, diff --git a/spatialtissuepy/summary/summary.py b/spatialtissuepy/summary/summary.py index 347f880..b0351f3 100644 --- a/spatialtissuepy/summary/summary.py +++ b/spatialtissuepy/summary/summary.py @@ -6,13 +6,12 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Dict, Iterator, List, Optional, - Tuple, Union -) + +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple, Union + import numpy as np import pandas as pd -from pathlib import Path from .panel import StatisticsPanel, load_panel @@ -23,11 +22,11 @@ class SpatialSummary: """ Compute spatial statistics summary for a single sample. - + The summary is a 1D vector where each element is a different spatial statistic, providing a compact description of the tissue's spatial organization. - + Parameters ---------- data : SpatialTissueData @@ -35,159 +34,159 @@ class SpatialSummary: panel : StatisticsPanel or str Panel of statistics to compute. Can be a StatisticsPanel object or name of a predefined panel. - + Attributes ---------- results : dict Raw results as metric_name -> value mapping. column_names : list of str Names of all columns in order. - + Examples -------- >>> from spatialtissuepy.summary import SpatialSummary, load_panel - >>> + >>> >>> # Using predefined panel >>> summary = SpatialSummary(data, panel='basic') >>> vector = summary.to_array() - >>> + >>> >>> # Using custom panel >>> panel = StatisticsPanel() >>> panel.add('cell_counts') >>> panel.add('ripleys_k', radii=[50, 100]) >>> summary = SpatialSummary(data, panel) """ - + def __init__( self, - data: 'SpatialTissueData', + data: SpatialTissueData, panel: Union[StatisticsPanel, str], ): self.data = data - + # Resolve panel if isinstance(panel, str): self.panel = load_panel(panel) else: self.panel = panel - + # Compute summary self._results: Dict[str, float] = {} self._column_names: List[str] = [] self._compute() - + def _compute(self) -> None: """Compute all metrics in the panel.""" self._results = self.panel.compute(self.data) self._column_names = list(self._results.keys()) - + @property def results(self) -> Dict[str, float]: """Raw results as dictionary.""" return self._results.copy() - + @property def column_names(self) -> List[str]: """Ordered list of column names.""" return self._column_names.copy() - + @property def n_features(self) -> int: """Number of features in the summary vector.""" return len(self._column_names) - + def to_array(self) -> np.ndarray: """ Convert summary to 1D numpy array. - + Returns ------- np.ndarray Shape (n_features,) array of statistic values. """ return np.array([self._results[col] for col in self._column_names]) - + def to_series(self, name: Optional[str] = None) -> pd.Series: """ Convert summary to pandas Series. - + Parameters ---------- name : str, optional Series name (e.g., sample ID). - + Returns ------- pd.Series Series with metric names as index. """ return pd.Series(self._results, name=name) - + def to_dict(self) -> Dict[str, float]: """ Convert summary to dictionary. - + Returns ------- dict Metric names to values. """ return self._results.copy() - + def get(self, metric_name: str, default: float = np.nan) -> float: """ Get a specific metric value. - + Parameters ---------- metric_name : str Name of the metric. default : float Value to return if metric not found. - + Returns ------- float Metric value. """ return self._results.get(metric_name, default) - + def __getitem__(self, key: str) -> float: return self._results[key] - + def __len__(self) -> int: return len(self._results) - + def __repr__(self) -> str: return f"SpatialSummary({self.n_features} features)" - + def __str__(self) -> str: lines = [ f"SpatialSummary ({self.n_features} features)", f" Panel: {self.panel.name}", " First 10 metrics:", ] - + for col in self._column_names[:10]: val = self._results[col] if np.isnan(val): lines.append(f" {col}: NaN") else: lines.append(f" {col}: {val:.4g}") - + if len(self._column_names) > 10: lines.append(f" ... and {len(self._column_names) - 10} more") - + return '\n'.join(lines) class MultiSampleSummary: """ Compute spatial statistics summaries for multiple samples. - + Returns a DataFrame where each row is a sample and each column is a spatial statistic. - + Parameters ---------- samples : list of SpatialTissueData @@ -200,69 +199,69 @@ class MultiSampleSummary: Number of parallel jobs. -1 uses all CPUs. show_progress : bool, default True Show progress bar. - + Examples -------- >>> from spatialtissuepy.summary import MultiSampleSummary - >>> + >>> >>> # From list of samples >>> samples = [data1, data2, data3] >>> multi = MultiSampleSummary(samples, panel='basic') >>> df = multi.to_dataframe() - >>> + >>> >>> # From multi-sample data >>> multi = MultiSampleSummary.from_multisample(data, panel='basic') >>> df = multi.to_dataframe() - >>> + >>> >>> # Export for ML >>> df.to_csv('spatial_features.csv') """ - + def __init__( self, - samples: List['SpatialTissueData'], + samples: List[SpatialTissueData], panel: Union[StatisticsPanel, str], sample_ids: Optional[List[str]] = None, n_jobs: int = 1, show_progress: bool = True, ): self.samples = samples - + # Resolve panel if isinstance(panel, str): self.panel = load_panel(panel) else: self.panel = panel - + # Sample IDs if sample_ids is None: sample_ids = [str(i) for i in range(len(samples))] - + if len(sample_ids) != len(samples): raise ValueError( f"Length of sample_ids ({len(sample_ids)}) must match " f"number of samples ({len(samples)})" ) - + self.sample_ids = list(sample_ids) self.n_jobs = n_jobs self.show_progress = show_progress - + # Compute summaries self._summaries: List[SpatialSummary] = [] self._df: Optional[pd.DataFrame] = None self._compute() - + @classmethod def from_multisample( cls, - data: 'SpatialTissueData', + data: SpatialTissueData, panel: Union[StatisticsPanel, str], **kwargs - ) -> 'MultiSampleSummary': + ) -> MultiSampleSummary: """ Create from a multi-sample SpatialTissueData object. - + Parameters ---------- data : SpatialTissueData @@ -271,7 +270,7 @@ def from_multisample( Panel to compute. **kwargs Additional arguments for MultiSampleSummary. - + Returns ------- MultiSampleSummary @@ -279,44 +278,44 @@ def from_multisample( """ if not data.is_multisample: raise ValueError("Data must be multi-sample (have sample_ids)") - + samples = [] sample_ids = [] - + for sample_id, sample_data in data.iter_samples(): samples.append(sample_data) sample_ids.append(sample_id) - + return cls(samples, panel, sample_ids=sample_ids, **kwargs) - + def _compute(self) -> None: """Compute summaries for all samples.""" if self.n_jobs == 1: self._compute_sequential() else: self._compute_parallel() - + self._build_dataframe() - + def _compute_sequential(self) -> None: """Compute summaries sequentially.""" samples_iter = self.samples - + if self.show_progress: try: from tqdm import tqdm samples_iter = tqdm( - self.samples, + self.samples, desc="Computing summaries", total=len(self.samples) ) except ImportError: pass - + for sample in samples_iter: summary = SpatialSummary(sample, self.panel) self._summaries.append(summary) - + def _compute_parallel(self) -> None: """Compute summaries in parallel.""" try: @@ -325,20 +324,20 @@ def _compute_parallel(self) -> None: # Fall back to sequential self._compute_sequential() return - + n_jobs = self.n_jobs if n_jobs == -1: import os n_jobs = os.cpu_count() or 1 - + def compute_one(sample): return SpatialSummary(sample, self.panel) - + if self.show_progress: try: from tqdm import tqdm self._summaries = Parallel(n_jobs=n_jobs)( - delayed(compute_one)(s) + delayed(compute_one)(s) for s in tqdm(self.samples, desc="Computing summaries") ) except ImportError: @@ -349,67 +348,67 @@ def compute_one(sample): self._summaries = Parallel(n_jobs=n_jobs)( delayed(compute_one)(s) for s in self.samples ) - + def _build_dataframe(self) -> None: """Build DataFrame from summaries.""" if not self._summaries: self._df = pd.DataFrame() return - + # Get all column names (union across samples) all_columns = set() for summary in self._summaries: all_columns.update(summary.column_names) - + # Sort columns for consistency columns = sorted(all_columns) - + # Build rows rows = [] for summary in self._summaries: row = {col: summary.get(col, np.nan) for col in columns} rows.append(row) - + self._df = pd.DataFrame(rows, index=self.sample_ids) self._df.index.name = 'sample_id' - + @property def n_samples(self) -> int: """Number of samples.""" return len(self.samples) - + @property def n_features(self) -> int: """Number of features (columns).""" return len(self._df.columns) if self._df is not None else 0 - + @property def column_names(self) -> List[str]: """List of column names.""" return list(self._df.columns) if self._df is not None else [] - + def to_dataframe(self) -> pd.DataFrame: """ Get summary as DataFrame. - + Returns ------- pd.DataFrame Rows are samples, columns are metrics. """ return self._df.copy() - + def to_array(self) -> np.ndarray: """ Get summary as 2D numpy array. - + Returns ------- np.ndarray Shape (n_samples, n_features). """ return self._df.values.copy() - + def to_csv( self, filepath: Union[str, Path], @@ -417,7 +416,7 @@ def to_csv( ) -> None: """ Export to CSV file. - + Parameters ---------- filepath : str or Path @@ -426,7 +425,7 @@ def to_csv( Additional arguments for pd.DataFrame.to_csv. """ self._df.to_csv(filepath, **kwargs) - + def to_excel( self, filepath: Union[str, Path], @@ -435,7 +434,7 @@ def to_excel( ) -> None: """ Export to Excel file. - + Parameters ---------- filepath : str or Path @@ -446,16 +445,16 @@ def to_excel( Additional arguments for pd.DataFrame.to_excel. """ self._df.to_excel(filepath, sheet_name=sheet_name, **kwargs) - + def get_sample(self, sample_id: str) -> SpatialSummary: """ Get summary for a specific sample. - + Parameters ---------- sample_id : str Sample identifier. - + Returns ------- SpatialSummary @@ -466,16 +465,16 @@ def get_sample(self, sample_id: str) -> SpatialSummary: return self._summaries[idx] except ValueError: raise KeyError(f"Sample '{sample_id}' not found") - + def get_metric(self, metric_name: str) -> pd.Series: """ Get values of a specific metric across all samples. - + Parameters ---------- metric_name : str Metric name. - + Returns ------- pd.Series @@ -484,18 +483,18 @@ def get_metric(self, metric_name: str) -> pd.Series: if metric_name not in self._df.columns: raise KeyError(f"Metric '{metric_name}' not in summary") return self._df[metric_name] - + def describe(self) -> pd.DataFrame: """ Compute summary statistics for each metric. - + Returns ------- pd.DataFrame Standard pandas describe output. """ return self._df.describe() - + def dropna( self, axis: int = 1, @@ -504,7 +503,7 @@ def dropna( ) -> pd.DataFrame: """ Return DataFrame with NaN values handled. - + Parameters ---------- axis : int @@ -513,62 +512,61 @@ def dropna( 'any' or 'all'. thresh : int, optional Require this many non-NA values. - + Returns ------- pd.DataFrame DataFrame with NA handled. """ return self._df.dropna(axis=axis, how=how, thresh=thresh) - + def __getitem__(self, key: str) -> pd.Series: """Get a metric column.""" return self._df[key] - + def __len__(self) -> int: return self.n_samples - + def __iter__(self) -> Iterator[Tuple[str, SpatialSummary]]: """Iterate over (sample_id, summary) pairs.""" - for sample_id, summary in zip(self.sample_ids, self._summaries): - yield sample_id, summary - + yield from zip(self.sample_ids, self._summaries) + def __repr__(self) -> str: return f"MultiSampleSummary({self.n_samples} samples, {self.n_features} features)" - + def __str__(self) -> str: lines = [ - f"MultiSampleSummary", + "MultiSampleSummary", f" Samples: {self.n_samples}", f" Features: {self.n_features}", f" Panel: {self.panel.name}", "", " Sample IDs:", ] - + for sid in self.sample_ids[:5]: lines.append(f" - {sid}") - + if len(self.sample_ids) > 5: lines.append(f" ... and {len(self.sample_ids) - 5} more") - + return '\n'.join(lines) def compute_summary( - data: 'SpatialTissueData', + data: SpatialTissueData, panel: Union[StatisticsPanel, str] = 'basic', ) -> pd.Series: """ Convenience function to compute summary for a single sample. - + Parameters ---------- data : SpatialTissueData Input data. panel : StatisticsPanel or str, default 'basic' Panel to compute. - + Returns ------- pd.Series @@ -579,14 +577,14 @@ def compute_summary( def compute_multi_summary( - samples: List['SpatialTissueData'], + samples: List[SpatialTissueData], panel: Union[StatisticsPanel, str] = 'basic', sample_ids: Optional[List[str]] = None, **kwargs ) -> pd.DataFrame: """ Convenience function to compute summaries for multiple samples. - + Parameters ---------- samples : list of SpatialTissueData @@ -597,7 +595,7 @@ def compute_multi_summary( Sample identifiers. **kwargs Additional arguments for MultiSampleSummary. - + Returns ------- pd.DataFrame diff --git a/spatialtissuepy/synthetic/__init__.py b/spatialtissuepy/synthetic/__init__.py index 94ee78a..e8ee0c1 100644 --- a/spatialtissuepy/synthetic/__init__.py +++ b/spatialtissuepy/synthetic/__init__.py @@ -30,7 +30,7 @@ >>> >>> # Load a PhysiCell simulation >>> sim = PhysiCellSimulation.from_output_folder('./output') ->>> +>>> >>> # Create analysis panel >>> panel = StatisticsPanel() >>> panel.add('cell_counts') @@ -62,18 +62,18 @@ # Base classes for ABM interface from .base import ( - ABMTimeStep, - ABMSimulation, ABMExperiment, + ABMSimulation, + ABMTimeStep, ) # PhysiCell support from .physicell import ( - PhysiCellTimeStep, - PhysiCellSimulation, PhysiCellExperiment, - read_physicell_timestep, + PhysiCellSimulation, + PhysiCellTimeStep, read_physicell_simulation, + read_physicell_timestep, ) __all__ = [ diff --git a/spatialtissuepy/synthetic/base.py b/spatialtissuepy/synthetic/base.py index 4055e84..74c84f5 100644 --- a/spatialtissuepy/synthetic/base.py +++ b/spatialtissuepy/synthetic/base.py @@ -7,13 +7,12 @@ """ from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import ( - Optional, Dict, List, Any, Iterator, Tuple, Union, - TYPE_CHECKING -) from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional + import numpy as np import pandas as pd @@ -30,7 +29,7 @@ class ABMTimeStep(ABC): """ Base class representing a single time step from an ABM simulation. - + Attributes ---------- time : float @@ -46,43 +45,43 @@ class ABMTimeStep(ABC): time_index: int source_path: Path metadata: Dict[str, Any] = field(default_factory=dict) - + @abstractmethod - def to_spatial_data(self) -> 'SpatialTissueData': + def to_spatial_data(self) -> SpatialTissueData: """ Convert this time step to a SpatialTissueData object. - + Returns ------- SpatialTissueData Spatial tissue data with cell coordinates and types. """ pass - + @property @abstractmethod def n_cells(self) -> int: """Number of cells at this time step.""" pass - + @property @abstractmethod def cell_types(self) -> List[str]: """List of unique cell type names.""" pass - + def summarize( self, - panel: 'StatisticsPanel' + panel: StatisticsPanel ) -> Dict[str, float]: """ Compute summary statistics for this time step. - + Parameters ---------- panel : StatisticsPanel Panel of statistics to compute. - + Returns ------- dict @@ -90,7 +89,7 @@ def summarize( """ data = self.to_spatial_data() return panel.compute(data) - + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(" @@ -108,10 +107,10 @@ def __repr__(self) -> str: class ABMSimulation(ABC): """ Base class representing a complete ABM simulation (time series). - + A simulation consists of multiple time steps that can be iterated over or accessed by index/time. - + Attributes ---------- output_folder : Path @@ -124,42 +123,42 @@ class ABMSimulation(ABC): output_folder: Path simulation_id: str = "" metadata: Dict[str, Any] = field(default_factory=dict) - + @property @abstractmethod def n_timesteps(self) -> int: """Number of time steps in the simulation.""" pass - + @property @abstractmethod def times(self) -> np.ndarray: """Array of simulation times for all time steps.""" pass - + @property @abstractmethod def time_indices(self) -> np.ndarray: """Array of time step indices.""" pass - + @abstractmethod def get_timestep(self, index: int) -> ABMTimeStep: """ Get a specific time step by index. - + Parameters ---------- index : int Time step index (0-based). - + Returns ------- ABMTimeStep The requested time step. """ pass - + @abstractmethod def get_timestep_by_time( self, @@ -168,47 +167,47 @@ def get_timestep_by_time( ) -> ABMTimeStep: """ Get a time step closest to the specified time. - + Parameters ---------- time : float Target simulation time. tolerance : float, default 1e-6 Tolerance for time matching. - + Returns ------- ABMTimeStep The closest time step. """ pass - + def __iter__(self) -> Iterator[ABMTimeStep]: """Iterate over all time steps.""" for i in range(self.n_timesteps): yield self.get_timestep(i) - + def __len__(self) -> int: return self.n_timesteps - + def __getitem__(self, index: int) -> ABMTimeStep: return self.get_timestep(index) - + def summarize( self, - panel: 'StatisticsPanel', + panel: StatisticsPanel, progress: bool = False ) -> pd.DataFrame: """ Compute summary statistics for all time steps. - + Parameters ---------- panel : StatisticsPanel Panel of statistics to compute. progress : bool, default False Show progress bar (requires tqdm). - + Returns ------- pd.DataFrame @@ -216,7 +215,7 @@ def summarize( Includes 'time' and 'time_index' columns. """ results = [] - + iterator = range(self.n_timesteps) if progress: try: @@ -224,7 +223,7 @@ def summarize( iterator = tqdm(iterator, desc="Summarizing timesteps") except ImportError: pass - + for i in iterator: timestep = self.get_timestep(i) row = { @@ -234,21 +233,21 @@ def summarize( } row.update(timestep.summarize(panel)) results.append(row) - + df = pd.DataFrame(results) df = df.set_index('time_index') - + return df - + def summarize_timesteps( self, - panel: 'StatisticsPanel', + panel: StatisticsPanel, indices: Optional[List[int]] = None, times: Optional[List[float]] = None ) -> pd.DataFrame: """ Compute summary statistics for selected time steps. - + Parameters ---------- panel : StatisticsPanel @@ -257,7 +256,7 @@ def summarize_timesteps( Specific time step indices to summarize. times : list of float, optional Specific simulation times to summarize. - + Returns ------- pd.DataFrame @@ -265,9 +264,9 @@ def summarize_timesteps( """ if indices is None and times is None: return self.summarize(panel) - + results = [] - + if indices is not None: for i in indices: timestep = self.get_timestep(i) @@ -278,7 +277,7 @@ def summarize_timesteps( } row.update(timestep.summarize(panel)) results.append(row) - + elif times is not None: for t in times: timestep = self.get_timestep_by_time(t) @@ -289,45 +288,45 @@ def summarize_timesteps( } row.update(timestep.summarize(panel)) results.append(row) - + df = pd.DataFrame(results) if len(df) > 0: df = df.set_index('time_index') - + return df - - def to_spatial_data_series(self) -> List['SpatialTissueData']: + + def to_spatial_data_series(self) -> List[SpatialTissueData]: """ Convert all time steps to SpatialTissueData objects. - + Returns ------- list of SpatialTissueData List of spatial data for each time step. """ return [ts.to_spatial_data() for ts in self] - + def cell_counts_over_time(self) -> pd.DataFrame: """ Get cell counts by type over simulation time. - + Returns ------- pd.DataFrame DataFrame with time as index and cell type counts as columns. """ results = [] - + for timestep in self: data = timestep.to_spatial_data() row = {'time': timestep.time, 'time_index': timestep.time_index} row['total_cells'] = data.n_cells - + for cell_type in data.cell_types_unique: row[f'n_{cell_type}'] = len(data.get_cells_by_type(cell_type)) - + results.append(row) - + return pd.DataFrame(results).set_index('time_index') @@ -339,10 +338,10 @@ def cell_counts_over_time(self) -> pd.DataFrame: class ABMExperiment(ABC): """ Base class representing a collection of ABM simulations. - + An experiment typically contains multiple simulations with different parameter settings (e.g., control vs treatment, parameter sweeps). - + Attributes ---------- simulations : list of ABMSimulation @@ -355,38 +354,38 @@ class ABMExperiment(ABC): simulations: List[ABMSimulation] = field(default_factory=list) experiment_id: str = "" metadata: Dict[str, Any] = field(default_factory=dict) - + @property def n_simulations(self) -> int: """Number of simulations in the experiment.""" return len(self.simulations) - + def __iter__(self) -> Iterator[ABMSimulation]: """Iterate over simulations.""" return iter(self.simulations) - + def __len__(self) -> int: return self.n_simulations - + def __getitem__(self, index: int) -> ABMSimulation: return self.simulations[index] - + def add_simulation(self, simulation: ABMSimulation) -> None: """Add a simulation to the experiment.""" self.simulations.append(simulation) - + def summarize( self, - panel: 'StatisticsPanel', + panel: StatisticsPanel, progress: bool = False, include_simulation_id: bool = True ) -> pd.DataFrame: """ Compute summary statistics for all simulations and time steps. - + Creates a master DataFrame with all time steps from all simulations, suitable for training ML models on simulation dynamics. - + Parameters ---------- panel : StatisticsPanel @@ -395,7 +394,7 @@ def summarize( Show progress bar. include_simulation_id : bool, default True Include simulation ID column. - + Returns ------- pd.DataFrame @@ -403,46 +402,46 @@ def summarize( Includes 'simulation_id', 'time', 'time_index' columns. """ all_results = [] - + iterator = enumerate(self.simulations) if progress: try: from tqdm import tqdm iterator = tqdm( - list(iterator), + list(iterator), desc="Summarizing simulations" ) except ImportError: pass - + for i, sim in iterator: sim_df = sim.summarize(panel, progress=False) - + if include_simulation_id: sim_df['simulation_id'] = sim.simulation_id or f"sim_{i}" sim_df['simulation_index'] = i - + all_results.append(sim_df.reset_index()) - + if len(all_results) == 0: return pd.DataFrame() - + master_df = pd.concat(all_results, ignore_index=True) - + return master_df - + def summarize_by_simulation( self, - panel: 'StatisticsPanel' + panel: StatisticsPanel ) -> Dict[str, pd.DataFrame]: """ Compute summaries separately for each simulation. - + Parameters ---------- panel : StatisticsPanel Panel of statistics to compute. - + Returns ------- dict @@ -452,21 +451,21 @@ def summarize_by_simulation( sim.simulation_id or f"sim_{i}": sim.summarize(panel) for i, sim in enumerate(self.simulations) } - + def get_simulation_by_id(self, simulation_id: str) -> ABMSimulation: """ Get a simulation by its ID. - + Parameters ---------- simulation_id : str Simulation identifier. - + Returns ------- ABMSimulation The requested simulation. - + Raises ------ KeyError @@ -476,39 +475,39 @@ def get_simulation_by_id(self, simulation_id: str) -> ABMSimulation: if sim.simulation_id == simulation_id: return sim raise KeyError(f"Simulation not found: {simulation_id}") - + def final_timesteps(self) -> List[ABMTimeStep]: """ Get the final time step from each simulation. - + Returns ------- list of ABMTimeStep Final time steps from all simulations. """ return [sim.get_timestep(sim.n_timesteps - 1) for sim in self.simulations] - + def summarize_final_timesteps( self, - panel: 'StatisticsPanel' + panel: StatisticsPanel ) -> pd.DataFrame: """ Summarize only the final time step of each simulation. - + Useful for comparing end states of different simulations. - + Parameters ---------- panel : StatisticsPanel Panel of statistics to compute. - + Returns ------- pd.DataFrame One row per simulation with final time step statistics. """ results = [] - + for i, sim in enumerate(self.simulations): final_ts = sim.get_timestep(sim.n_timesteps - 1) row = { @@ -519,5 +518,5 @@ def summarize_final_timesteps( } row.update(final_ts.summarize(panel)) results.append(row) - + return pd.DataFrame(results) diff --git a/spatialtissuepy/synthetic/physicell/__init__.py b/spatialtissuepy/synthetic/physicell/__init__.py index 777e19d..e2acc7e 100644 --- a/spatialtissuepy/synthetic/physicell/__init__.py +++ b/spatialtissuepy/synthetic/physicell/__init__.py @@ -31,25 +31,24 @@ cell simulator for 3-D multicellular systems. PLoS Comput Biol. """ -from .reader import ( - PhysiCellTimeStep, - PhysiCellSimulation, - PhysiCellExperiment, - read_physicell_timestep, - read_physicell_simulation, - read_physicell_experiment, - discover_physicell_timesteps, -) - from .parser import ( - parse_physicell_xml, - parse_cells_mat, - parse_microenvironment_mat, + CELL_CYCLE_PHASES, get_cell_type_mapping, + get_phase_name, is_alive, is_dead, - get_phase_name, - CELL_CYCLE_PHASES, + parse_cells_mat, + parse_microenvironment_mat, + parse_physicell_xml, +) +from .reader import ( + PhysiCellExperiment, + PhysiCellSimulation, + PhysiCellTimeStep, + discover_physicell_timesteps, + read_physicell_experiment, + read_physicell_simulation, + read_physicell_timestep, ) __all__ = [ diff --git a/spatialtissuepy/synthetic/physicell/parser.py b/spatialtissuepy/synthetic/physicell/parser.py index b6ac544..874ba67 100644 --- a/spatialtissuepy/synthetic/physicell/parser.py +++ b/spatialtissuepy/synthetic/physicell/parser.py @@ -6,12 +6,13 @@ """ from __future__ import annotations -from typing import Dict, List, Any, Optional, Tuple -from pathlib import Path -import numpy as np + import xml.etree.ElementTree as ET from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +import numpy as np # ----------------------------------------------------------------------------- # XML Parsing @@ -37,12 +38,12 @@ class PhysiCellMetadata: def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: """ Parse a PhysiCell output XML file for metadata. - + Parameters ---------- xml_path : Path Path to the output*.xml file. - + Returns ------- PhysiCellMetadata @@ -50,26 +51,26 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: """ tree = ET.parse(xml_path) root = tree.getroot() - + # Extract time time_elem = root.find('.//current_time') time = float(time_elem.text) if time_elem is not None else 0.0 time_units = time_elem.get('units', 'min') if time_elem is not None else 'min' - + # Extract runtime runtime_elem = root.find('.//current_runtime') runtime = float(runtime_elem.text) if runtime_elem is not None else 0.0 - + # Extract space units space_units = 'micron' # Default mesh_elem = root.find('.//mesh') if mesh_elem is not None: space_units = mesh_elem.get('units', 'micron') - + # Extract program info - check both old and new XML structures program_name = 'PhysiCell' program_version = 'unknown' - + # New format: software/n and software/version software_elem = root.find('.//software') if software_elem is not None: @@ -79,7 +80,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: program_name = name_elem.text if ver_elem is not None and ver_elem.text: program_version = ver_elem.text - + # Old format: program/name and program/version if program_version == 'unknown': program_elem = root.find('.//program') @@ -90,11 +91,11 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: program_name = name_elem.text or 'PhysiCell' if ver_elem is not None: program_version = ver_elem.text or 'unknown' - + # Extract domain bounds from mesh bounding_box domain_min = (-500.0, -500.0, -10.0) domain_max = (500.0, 500.0, 10.0) - + bbox_elem = root.find('.//bounding_box') if bbox_elem is not None and bbox_elem.text: try: @@ -104,7 +105,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: domain_max = (coords[3], coords[4], coords[5]) except (ValueError, IndexError): pass - + # Fall back to domain element if bounding_box not found if domain_min == (-500.0, -500.0, -10.0): domain_elem = root.find('.//domain') @@ -115,7 +116,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: x_max = domain_elem.find('x_max') y_max = domain_elem.find('y_max') z_max = domain_elem.find('z_max') - + if all(e is not None for e in [x_min, y_min, z_min, x_max, y_max, z_max]): domain_min = ( float(x_min.text), float(y_min.text), float(z_min.text) @@ -123,7 +124,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: domain_max = ( float(x_max.text), float(y_max.text), float(z_max.text) ) - + # Extract substrate names substrate_names = [] variables_elem = root.find('.//variables') @@ -131,11 +132,11 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: for var in variables_elem.findall('variable'): name = var.get('name', 'unknown') substrate_names.append(name) - + # Extract cell type names and IDs cell_type_names = [] cell_type_ids = [] - + # First try: cell_types in simplified_data (PhysiCell 1.10+ output XML format) cell_types_elem = root.find('.//simplified_data/cell_types') if cell_types_elem is not None: @@ -144,7 +145,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: name = type_elem.text or f'type_{cell_id}' cell_type_ids.append(cell_id) cell_type_names.append(name) - + # Second try: cell_definitions in settings XML format if not cell_type_names: cell_defs = root.find('.//cell_definitions') @@ -154,7 +155,7 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: cell_id = int(cell_def.get('ID', len(cell_type_names))) cell_type_names.append(name) cell_type_ids.append(cell_id) - + # Extract labels from simplified_data (column indices for MAT file) labels_elem = root.find('.//simplified_data/labels') custom_labels = {} @@ -164,12 +165,12 @@ def parse_physicell_xml(xml_path: Path) -> PhysiCellMetadata: size = int(label.get('size', 1)) name = label.text or f'custom_{index}' custom_labels[index] = (name, size) - + extra = { 'custom_labels': custom_labels, 'xml_path': str(xml_path), } - + return PhysiCellMetadata( time=time, time_units=time_units, @@ -192,23 +193,23 @@ def get_cell_type_mapping( ) -> Dict[int, str]: """ Get mapping from cell type IDs to names. - + Reads from the PhysiCell output XML (preferred) or settings XML. - + Parameters ---------- xml_path : Path, optional Path to output*.xml file (preferred source for cell types). settings_xml_path : Path, optional Path to PhysiCell_settings.xml or config.xml file. - + Returns ------- dict Mapping from cell type ID (int) to name (str). """ mapping = {} - + # Try output XML first (has cell_types in simplified_data for PhysiCell 1.10+) if xml_path is not None: try: @@ -217,13 +218,13 @@ def get_cell_type_mapping( mapping[cell_id] = name except Exception: pass - + # Fall back to settings XML if output XML didn't have cell types if not mapping and settings_xml_path is not None and settings_xml_path.exists(): try: tree = ET.parse(settings_xml_path) root = tree.getroot() - + cell_defs = root.find('.//cell_definitions') if cell_defs is not None: for cell_def in cell_defs.findall('cell_definition'): @@ -232,11 +233,11 @@ def get_cell_type_mapping( mapping[cell_id] = name except Exception: pass - + # Default if nothing found if not mapping: mapping[0] = 'default' - + return mapping @@ -293,7 +294,7 @@ def parse_cells_mat( ) -> Dict[str, np.ndarray]: """ Parse a PhysiCell cells_physicell.mat file. - + Parameters ---------- mat_path : Path @@ -302,7 +303,7 @@ def parse_cells_mat( Mapping from cell type IDs to names. index_mapping : dict, optional Custom row index mapping for different PhysiCell versions. - + Returns ------- dict @@ -317,17 +318,17 @@ def parse_cells_mat( - 'raw_data': Full matrix from file """ from scipy.io import loadmat - + # Load the MAT file mat_data = loadmat(str(mat_path)) - + # Find the cells data - usually named 'cells' or similar cell_matrix = None for key in ['cells', 'basic_agents', 'cell_data']: if key in mat_data: cell_matrix = mat_data[key] break - + if cell_matrix is None: # Try to find any 2D array that looks like cell data for key, value in mat_data.items(): @@ -335,16 +336,16 @@ def parse_cells_mat( if value.ndim == 2 and value.shape[0] >= 4: cell_matrix = value break - + if cell_matrix is None: raise ValueError(f"Could not find cell data in {mat_path}") - + # Ensure cells are columns if cell_matrix.shape[0] > cell_matrix.shape[1]: cell_matrix = cell_matrix.T - + n_cells = cell_matrix.shape[1] - + if n_cells == 0: return { 'positions': np.empty((0, 3)), @@ -356,7 +357,7 @@ def parse_cells_mat( 'ids': np.array([], dtype=int), 'raw_data': cell_matrix, } - + # Use provided or default index mapping if index_mapping is None: # Auto-detect based on matrix shape @@ -365,24 +366,24 @@ def parse_cells_mat( index_mapping = CELL_DATA_INDICES_V2 else: index_mapping = CELL_DATA_INDICES_LEGACY - + # Extract positions positions = np.column_stack([ cell_matrix[index_mapping.get('position_x', 1), :], cell_matrix[index_mapping.get('position_y', 2), :], cell_matrix[index_mapping.get('position_z', 3), :], ]) - + # Extract cell IDs ids = cell_matrix[index_mapping.get('ID', 0), :].astype(int) - + # Extract cell types cell_type_idx = index_mapping.get('cell_type', 5) if cell_type_idx < cell_matrix.shape[0]: cell_type_ids = cell_matrix[cell_type_idx, :].astype(int) else: cell_type_ids = np.zeros(n_cells, dtype=int) - + # Map to names if mapping provided if cell_type_mapping: cell_types = np.array([ @@ -391,14 +392,14 @@ def parse_cells_mat( ]) else: cell_types = np.array([f'type_{int(ct)}' for ct in cell_type_ids]) - + # Extract volumes vol_idx = index_mapping.get('total_volume', 4) if vol_idx < cell_matrix.shape[0]: volumes = cell_matrix[vol_idx, :] else: volumes = np.ones(n_cells) * 2494.0 # Default PhysiCell volume - + # Extract radii radius_idx = index_mapping.get('radius', 37) # Default for V2 format if radius_idx < cell_matrix.shape[0]: @@ -406,14 +407,14 @@ def parse_cells_mat( else: # Compute from volume (assuming spherical cells) radii = np.cbrt(3 * volumes / (4 * np.pi)) - + # Extract cell cycle phase phase_idx = index_mapping.get('current_phase', 7) # Default for V2 format if phase_idx < cell_matrix.shape[0]: phases = cell_matrix[phase_idx, :].astype(int) else: phases = np.zeros(n_cells, dtype=int) - + # Extract dead flag (PhysiCell 1.10+ has explicit dead flag at index 26) dead_idx = index_mapping.get('dead', 26) if dead_idx < cell_matrix.shape[0]: @@ -421,7 +422,7 @@ def parse_cells_mat( else: # Fall back to inferring from phase code dead_flags = np.array([1 if p >= 100 else 0 for p in phases]) - + return { 'positions': positions, 'cell_types': cell_types, @@ -441,14 +442,14 @@ def parse_microenvironment_mat( ) -> Dict[str, np.ndarray]: """ Parse a PhysiCell microenvironment MAT file. - + Parameters ---------- mat_path : Path Path to the *_microenvironment0.mat file. substrate_names : list of str, optional Names of substrates (from XML metadata). - + Returns ------- dict @@ -458,39 +459,39 @@ def parse_microenvironment_mat( - 'raw_data': Full matrix from file """ from scipy.io import loadmat - + mat_data = loadmat(str(mat_path)) - + # Find the microenvironment data me_matrix = None for key in ['multiscale_microenvironment', 'microenvironment', 'M']: if key in mat_data: me_matrix = mat_data[key] break - + if me_matrix is None: for key, value in mat_data.items(): if not key.startswith('_') and isinstance(value, np.ndarray): if value.ndim == 2 and value.shape[0] >= 4: me_matrix = value break - + if me_matrix is None: raise ValueError(f"Could not find microenvironment data in {mat_path}") - + # Structure: rows 0-2 are x,y,z; row 3 is volume; rows 4+ are substrates voxel_positions = me_matrix[:3, :].T # (n_voxels, 3) - + # Extract substrate concentrations concentrations = {} n_substrates = me_matrix.shape[0] - 4 - + if substrate_names is None: substrate_names = [f'substrate_{i}' for i in range(n_substrates)] - + for i, name in enumerate(substrate_names[:n_substrates]): concentrations[name] = me_matrix[4 + i, :] - + return { 'voxel_positions': voxel_positions, 'concentrations': concentrations, diff --git a/spatialtissuepy/synthetic/physicell/reader.py b/spatialtissuepy/synthetic/physicell/reader.py index ce2df8b..86c52c3 100644 --- a/spatialtissuepy/synthetic/physicell/reader.py +++ b/spatialtissuepy/synthetic/physicell/reader.py @@ -6,23 +6,26 @@ """ from __future__ import annotations + +import re from dataclasses import dataclass, field -from typing import Dict, List, Any, Optional, Iterator, Tuple, Union from pathlib import Path -import re +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np import pandas as pd -from ..base import ABMTimeStep, ABMSimulation, ABMExperiment +if TYPE_CHECKING: + from spatialtissuepy.core import SpatialTissueData + +from ..base import ABMExperiment, ABMSimulation, ABMTimeStep from .parser import ( - parse_physicell_xml, - parse_cells_mat, - get_cell_type_mapping, - is_alive, PhysiCellMetadata, + get_cell_type_mapping, + parse_cells_mat, + parse_physicell_xml, ) - # ----------------------------------------------------------------------------- # PhysiCell TimeStep # ----------------------------------------------------------------------------- @@ -31,10 +34,10 @@ class PhysiCellTimeStep(ABMTimeStep): """ A single time step from a PhysiCell simulation. - + This class lazily loads data from PhysiCell output files and converts them to SpatialTissueData for spatial analysis. - + Attributes ---------- time : float @@ -51,12 +54,12 @@ class PhysiCellTimeStep(ABMTimeStep): Mapping from cell type IDs to names. include_dead_cells : bool Whether to include dead cells in analysis. - + Examples -------- >>> timestep = read_physicell_timestep('./output/output00000010.xml') >>> print(f"Time: {timestep.time} min, Cells: {timestep.n_cells}") - >>> + >>> >>> # Convert to SpatialTissueData for analysis >>> data = timestep.to_spatial_data() >>> from spatialtissuepy.statistics import ripleys_h @@ -67,7 +70,7 @@ class PhysiCellTimeStep(ABMTimeStep): include_dead_cells: bool = False _cell_data: Optional[Dict[str, np.ndarray]] = field(default=None, repr=False) _physicell_metadata: Optional[PhysiCellMetadata] = field(default=None, repr=False) - + def _load_cell_data(self) -> Dict[str, np.ndarray]: """Load cell data from MAT file (cached).""" if self._cell_data is None: @@ -76,13 +79,13 @@ def _load_cell_data(self) -> Dict[str, np.ndarray]: self.cell_type_mapping ) return self._cell_data - + def _load_metadata(self) -> PhysiCellMetadata: """Load metadata from XML file (cached).""" if self._physicell_metadata is None: self._physicell_metadata = parse_physicell_xml(self.source_path) return self._physicell_metadata - + @property def n_cells(self) -> int: """Number of cells at this time step.""" @@ -93,24 +96,24 @@ def n_cells(self) -> int: # Filter out dead cells using dead_flags alive_mask = data['dead_flags'] == 0 return np.sum(alive_mask) - + @property def n_cells_total(self) -> int: """Total number of cells including dead.""" return len(self._load_cell_data()['cell_types']) - + @property def n_dead_cells(self) -> int: """Number of dead cells.""" data = self._load_cell_data() return np.sum(data['dead_flags'] == 1) - + @property def cell_types(self) -> List[str]: """List of unique cell type names.""" data = self._load_cell_data() return list(np.unique(data['cell_types'])) - + @property def positions(self) -> np.ndarray: """Cell positions as (n_cells, 3) array.""" @@ -120,7 +123,7 @@ def positions(self) -> np.ndarray: else: alive_mask = data['dead_flags'] == 0 return data['positions'][alive_mask] - + @property def domain_bounds(self) -> Dict[str, Tuple[float, float]]: """Simulation domain bounds.""" @@ -130,20 +133,20 @@ def domain_bounds(self) -> Dict[str, Tuple[float, float]]: 'y': (meta.domain_min[1], meta.domain_max[1]), 'z': (meta.domain_min[2], meta.domain_max[2]), } - - def to_spatial_data(self) -> 'SpatialTissueData': + + def to_spatial_data(self) -> SpatialTissueData: """ Convert to SpatialTissueData for spatial analysis. - + Returns ------- SpatialTissueData Spatial tissue data object. """ from spatialtissuepy.core import SpatialTissueData - + data = self._load_cell_data() - + if self.include_dead_cells: positions = data['positions'] cell_types = data['cell_types'] @@ -155,13 +158,13 @@ def to_spatial_data(self) -> 'SpatialTissueData': cell_types = data['cell_types'][alive_mask] volumes = data['volumes'][alive_mask] radii = data['radii'][alive_mask] - + # Create markers DataFrame with cell properties markers = pd.DataFrame({ 'volume': volumes, 'radius': radii, }) - + # Add metadata extra_metadata = { 'source': 'PhysiCell', @@ -169,25 +172,25 @@ def to_spatial_data(self) -> 'SpatialTissueData': 'time_index': self.time_index, 'source_path': str(self.source_path), } - + return SpatialTissueData( coordinates=positions, cell_types=cell_types, markers=markers, metadata=extra_metadata, ) - + def to_dataframe(self) -> pd.DataFrame: """ Convert cell data to a pandas DataFrame. - + Returns ------- pd.DataFrame DataFrame with cell properties. """ data = self._load_cell_data() - + df = pd.DataFrame({ 'cell_id': data['ids'], 'x': data['positions'][:, 0], @@ -201,12 +204,12 @@ def to_dataframe(self) -> pd.DataFrame: 'is_dead': data['dead_flags'].astype(bool), 'is_alive': ~data['dead_flags'].astype(bool), }) - + df['time'] = self.time df['time_index'] = self.time_index - + return df - + def cell_counts_by_type(self) -> Dict[str, int]: """Get cell counts by type.""" data = self.to_spatial_data() @@ -224,10 +227,10 @@ def cell_counts_by_type(self) -> Dict[str, int]: class PhysiCellSimulation(ABMSimulation): """ A complete PhysiCell simulation (time series). - + This class manages all time steps from a PhysiCell simulation output folder and provides methods for analyzing the full time series. - + Attributes ---------- output_folder : Path @@ -238,16 +241,16 @@ class PhysiCellSimulation(ABMSimulation): Mapping from cell type IDs to names. include_dead_cells : bool Whether to include dead cells. - + Examples -------- >>> sim = PhysiCellSimulation.from_output_folder('./output') >>> print(f"Found {sim.n_timesteps} time steps") - >>> + >>> >>> # Iterate over time steps >>> for timestep in sim: ... print(f"t={timestep.time}: {timestep.n_cells} cells") - >>> + >>> >>> # Summarize with statistics panel >>> panel = StatisticsPanel() >>> panel.add('cell_counts') @@ -260,7 +263,7 @@ class PhysiCellSimulation(ABMSimulation): default_factory=list, repr=False ) _times: Optional[np.ndarray] = field(default=None, repr=False) - + @classmethod def from_output_folder( cls, @@ -268,10 +271,10 @@ def from_output_folder( simulation_id: Optional[str] = None, settings_xml: Optional[Union[str, Path]] = None, include_dead_cells: bool = False - ) -> 'PhysiCellSimulation': + ) -> PhysiCellSimulation: """ Create a PhysiCellSimulation from an output folder. - + Parameters ---------- output_folder : str or Path @@ -282,27 +285,27 @@ def from_output_folder( Path to PhysiCell_settings.xml for cell type names. include_dead_cells : bool, default False Whether to include dead cells in analysis. - + Returns ------- PhysiCellSimulation Loaded simulation. """ output_folder = Path(output_folder) - + if not output_folder.exists(): raise FileNotFoundError(f"Output folder not found: {output_folder}") - + # Auto-generate simulation ID from folder name if simulation_id is None: simulation_id = output_folder.name - + # Discover time step files timestep_files = discover_physicell_timesteps(output_folder) - + if len(timestep_files) == 0: raise ValueError(f"No PhysiCell output files found in {output_folder}") - + # Get cell type mapping first_xml = timestep_files[0][1] if settings_xml is not None: @@ -319,9 +322,9 @@ def from_output_folder( if candidate.exists(): settings_path = candidate break - + cell_type_mapping = get_cell_type_mapping(first_xml, settings_path) - + sim = cls( output_folder=output_folder, simulation_id=simulation_id, @@ -329,14 +332,14 @@ def from_output_folder( include_dead_cells=include_dead_cells, _timestep_files=timestep_files, ) - + return sim - + @property def n_timesteps(self) -> int: """Number of time steps.""" return len(self._timestep_files) - + @property def times(self) -> np.ndarray: """Array of simulation times.""" @@ -346,22 +349,22 @@ def times(self) -> np.ndarray: for _, xml_path, _ in self._timestep_files ]) return self._times - + @property def time_indices(self) -> np.ndarray: """Array of time step indices.""" return np.array([idx for idx, _, _ in self._timestep_files]) - + def get_timestep(self, index: int) -> PhysiCellTimeStep: """ Get a specific time step by index. - + Parameters ---------- index : int Time step index (0-based position in sorted list). Supports negative indexing (e.g., -1 for last timestep). - + Returns ------- PhysiCellTimeStep @@ -370,15 +373,15 @@ def get_timestep(self, index: int) -> PhysiCellTimeStep: # Support negative indexing if index < 0: index = self.n_timesteps + index - + if index < 0 or index >= self.n_timesteps: raise IndexError(f"Time step index out of range: {index}") - + time_idx, xml_path, mat_path = self._timestep_files[index] - + # Get time from XML metadata = parse_physicell_xml(xml_path) - + return PhysiCellTimeStep( time=metadata.time, time_index=time_idx, @@ -388,7 +391,7 @@ def get_timestep(self, index: int) -> PhysiCellTimeStep: cell_type_mapping=self.cell_type_mapping, include_dead_cells=self.include_dead_cells, ) - + def get_timestep_by_time( self, time: float, @@ -396,14 +399,14 @@ def get_timestep_by_time( ) -> PhysiCellTimeStep: """ Get time step closest to specified time. - + Parameters ---------- time : float Target simulation time. tolerance : float, default 1e-6 Tolerance for exact matching. - + Returns ------- PhysiCellTimeStep @@ -412,19 +415,19 @@ def get_timestep_by_time( times = self.times idx = np.argmin(np.abs(times - time)) return self.get_timestep(idx) - + def get_timestep_by_original_index( self, original_index: int ) -> PhysiCellTimeStep: """ Get time step by its original PhysiCell index. - + Parameters ---------- original_index : int Original index from filename (e.g., 87 for output00000087.xml). - + Returns ------- PhysiCellTimeStep @@ -433,20 +436,20 @@ def get_timestep_by_original_index( for i, (idx, _, _) in enumerate(self._timestep_files): if idx == original_index: return self.get_timestep(i) - + raise KeyError(f"Time step with index {original_index} not found") - + def cell_counts_over_time(self) -> pd.DataFrame: """ Get cell counts over simulation time. - + Returns ------- pd.DataFrame DataFrame with time, total cells, and per-type counts. """ results = [] - + for timestep in self: row = { 'time': timestep.time, @@ -455,19 +458,19 @@ def cell_counts_over_time(self) -> pd.DataFrame: 'dead_cells': timestep.n_dead_cells, } row.update({ - f'n_{ct}': count + f'n_{ct}': count for ct, count in timestep.cell_counts_by_type().items() }) results.append(row) - + return pd.DataFrame(results) - + def to_trajectory_dataframe(self) -> pd.DataFrame: """ Create a full trajectory DataFrame with all cells at all times. - + Warning: This can be memory-intensive for large simulations. - + Returns ------- pd.DataFrame @@ -476,7 +479,7 @@ def to_trajectory_dataframe(self) -> pd.DataFrame: dfs = [] for timestep in self: dfs.append(timestep.to_dataframe()) - + return pd.concat(dfs, ignore_index=True) @@ -488,10 +491,10 @@ def to_trajectory_dataframe(self) -> pd.DataFrame: class PhysiCellExperiment(ABMExperiment): """ Collection of PhysiCell simulations for comparative analysis. - + An experiment contains multiple simulations, typically with different parameter settings (control, treatments, parameter sweeps). - + Examples -------- >>> experiment = PhysiCellExperiment.from_folders([ @@ -499,18 +502,18 @@ class PhysiCellExperiment(ABMExperiment): ... './sim_treatment_low/output', ... './sim_treatment_high/output', ... ]) - >>> + >>> >>> # Summarize all simulations >>> panel = StatisticsPanel() >>> panel.add('cell_counts') >>> panel.add('ripleys_h_max') >>> master_df = experiment.summarize(panel) - >>> + >>> >>> # Use for ML training >>> X = master_df[panel.get_statistic_names()] >>> y = master_df['simulation_id'] """ - + @classmethod def from_folders( cls, @@ -519,10 +522,10 @@ def from_folders( experiment_id: str = "", include_dead_cells: bool = False, **kwargs - ) -> 'PhysiCellExperiment': + ) -> PhysiCellExperiment: """ Create experiment from multiple output folders. - + Parameters ---------- folders : list of str or Path @@ -533,7 +536,7 @@ def from_folders( Identifier for this experiment. include_dead_cells : bool, default False Whether to include dead cells. - + Returns ------- PhysiCellExperiment @@ -541,7 +544,7 @@ def from_folders( """ if simulation_ids is None: simulation_ids = [Path(f).name for f in folders] - + simulations = [] for folder, sim_id in zip(folders, simulation_ids): try: @@ -553,13 +556,13 @@ def from_folders( simulations.append(sim) except Exception as e: print(f"Warning: Could not load {folder}: {e}") - + return cls( simulations=simulations, experiment_id=experiment_id, metadata=kwargs, ) - + @classmethod def from_parent_folder( cls, @@ -567,16 +570,16 @@ def from_parent_folder( experiment_id: str = "", include_dead_cells: bool = False, output_subfolder: str = "output" - ) -> 'PhysiCellExperiment': + ) -> PhysiCellExperiment: """ Create experiment from a parent folder containing simulation folders. - + Assumes structure: parent_folder/ sim1/output/ sim2/output/ sim3/output/ - + Parameters ---------- parent_folder : str or Path @@ -587,24 +590,24 @@ def from_parent_folder( Whether to include dead cells. output_subfolder : str, default "output" Name of output subfolder in each simulation. - + Returns ------- PhysiCellExperiment Loaded experiment. """ parent = Path(parent_folder) - + folders = [] sim_ids = [] - + for child in sorted(parent.iterdir()): if child.is_dir(): output_path = child / output_subfolder if output_path.exists(): folders.append(output_path) sim_ids.append(child.name) - + return cls.from_folders( folders, simulation_ids=sim_ids, @@ -620,18 +623,18 @@ def from_parent_folder( def _find_cells_mat_file(output_folder: Path, index: int) -> Optional[Path]: """ Find the cells MAT file for a given output index. - + PhysiCell versions use different naming conventions: - Newer versions (1.10+): output{index}_cells_physicell.mat - Older versions: output{index}_cells.mat - + Parameters ---------- output_folder : Path Path to output folder. index : int Output file index. - + Returns ------- Path or None @@ -641,12 +644,12 @@ def _find_cells_mat_file(output_folder: Path, index: int) -> Optional[Path]: mat_file_new = output_folder / f'output{index:08d}_cells_physicell.mat' if mat_file_new.exists(): return mat_file_new - + # Fall back to older naming convention mat_file_old = output_folder / f'output{index:08d}_cells.mat' if mat_file_old.exists(): return mat_file_old - + return None @@ -655,38 +658,38 @@ def discover_physicell_timesteps( ) -> List[Tuple[int, Path, Path]]: """ Discover PhysiCell output files in a folder. - + Parameters ---------- output_folder : Path Path to output folder. - + Returns ------- list of (int, Path, Path) List of (time_index, xml_path, mat_path) tuples, sorted by index. """ output_folder = Path(output_folder) - + # Pattern for PhysiCell output files xml_pattern = re.compile(r'output(\d{8})\.xml$') - + timesteps = [] - + for xml_file in output_folder.glob('output*.xml'): match = xml_pattern.match(xml_file.name) if match: index = int(match.group(1)) - + # Find corresponding MAT file (handles both naming conventions) mat_file = _find_cells_mat_file(output_folder, index) - + if mat_file is not None: timesteps.append((index, xml_file, mat_file)) - + # Sort by index timesteps.sort(key=lambda x: x[0]) - + return timesteps @@ -697,7 +700,7 @@ def read_physicell_timestep( ) -> PhysiCellTimeStep: """ Read a single PhysiCell time step. - + Parameters ---------- xml_path : str or Path @@ -706,25 +709,25 @@ def read_physicell_timestep( Mapping from cell type IDs to names. include_dead_cells : bool, default False Whether to include dead cells. - + Returns ------- PhysiCellTimeStep Loaded time step. """ xml_path = Path(xml_path) - + # Parse XML for metadata metadata = parse_physicell_xml(xml_path) - + # Extract index from filename base_name = xml_path.stem # e.g., "output00000087" match = re.search(r'output(\d+)', base_name) time_index = int(match.group(1)) if match else 0 - + # Find corresponding MAT file (handles both naming conventions) mat_path = _find_cells_mat_file(xml_path.parent, time_index) - + if mat_path is None: # Provide helpful error message listing what was checked mat_path_new = xml_path.parent / f'{base_name}_cells_physicell.mat' @@ -734,11 +737,11 @@ def read_physicell_timestep( f" - {mat_path_new}\n" f" - {mat_path_old}" ) - + # Get cell type mapping if not provided if cell_type_mapping is None: cell_type_mapping = get_cell_type_mapping(xml_path) - + return PhysiCellTimeStep( time=metadata.time, time_index=time_index, @@ -761,14 +764,14 @@ def read_physicell_simulation( ) -> PhysiCellSimulation: """ Read a complete PhysiCell simulation. - + Parameters ---------- output_folder : str or Path Path to output folder. **kwargs Additional arguments passed to PhysiCellSimulation.from_output_folder. - + Returns ------- PhysiCellSimulation @@ -783,14 +786,14 @@ def read_physicell_experiment( ) -> PhysiCellExperiment: """ Read multiple PhysiCell simulations as an experiment. - + Parameters ---------- folders : list of str or Path Paths to simulation output folders. **kwargs Additional arguments passed to PhysiCellExperiment.from_folders. - + Returns ------- PhysiCellExperiment diff --git a/spatialtissuepy/topology/__init__.py b/spatialtissuepy/topology/__init__.py index ec1e7a4..b69d390 100644 --- a/spatialtissuepy/topology/__init__.py +++ b/spatialtissuepy/topology/__init__.py @@ -69,51 +69,50 @@ >>> plot_mapper_graph(result) """ -from .mapper import SpatialMapper, MapperResult, spatial_mapper -from .cover import Cover, UniformCover, AdaptiveCover, BallCover, create_cover -from .nerve import MapperNode, MapperEdge +# Analysis functions +from .analysis import ( + cell_mapper_features, + cells_in_multiple_nodes, + compare_mapper_results, + component_statistics, + edge_summary_dataframe, + extract_mapper_features, + find_bridge_nodes, + find_hub_nodes, + get_component_cells, + mapper_stability_score, + node_summary_dataframe, + optimal_n_intervals, + uncovered_cells, +) +from .cover import AdaptiveCover, BallCover, Cover, UniformCover, create_cover # Filter functions from .filters import ( + constant_filter, density_filter, - pca_filter, eccentricity_filter, + entropy_filter, linfinity_centrality_filter, + pca_filter, sum_filter, - entropy_filter, - constant_filter, ) +from .mapper import MapperResult, SpatialMapper, spatial_mapper +from .nerve import MapperEdge, MapperNode # Spatial filters from .spatial_filters import ( - spatial_coordinate_filter, - radial_filter, - distance_to_type_filter, + composite_filter, distance_to_boundary_filter, - spatial_density_filter, + distance_to_type_filter, gaussian_smoothed_filter, - composite_filter, multiscale_spatial_filter, + radial_filter, + spatial_coordinate_filter, + spatial_density_filter, type_proportion_filter, ) -# Analysis functions -from .analysis import ( - node_summary_dataframe, - edge_summary_dataframe, - find_hub_nodes, - find_bridge_nodes, - component_statistics, - get_component_cells, - compare_mapper_results, - extract_mapper_features, - cell_mapper_features, - cells_in_multiple_nodes, - uncovered_cells, - mapper_stability_score, - optimal_n_intervals, -) - __all__ = [ # Main classes 'SpatialMapper', @@ -122,7 +121,7 @@ # Cover classes 'Cover', 'UniformCover', - 'AdaptiveCover', + 'AdaptiveCover', 'BallCover', 'create_cover', # Nerve classes diff --git a/spatialtissuepy/topology/analysis.py b/spatialtissuepy/topology/analysis.py index 8c0327e..5c703ae 100644 --- a/spatialtissuepy/topology/analysis.py +++ b/spatialtissuepy/topology/analysis.py @@ -6,40 +6,43 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Any + +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + import numpy as np import pandas as pd if TYPE_CHECKING: - from .mapper import MapperResult from spatialtissuepy.core.spatial_data import SpatialTissueData + from .mapper import MapperResult + # ----------------------------------------------------------------------------- # Node Analysis # ----------------------------------------------------------------------------- def node_summary_dataframe( - result: 'MapperResult', - data: Optional['SpatialTissueData'] = None + result: MapperResult, + data: Optional[SpatialTissueData] = None ) -> pd.DataFrame: """ Create a summary DataFrame of all Mapper nodes. - + Parameters ---------- result : MapperResult Mapper result. data : SpatialTissueData, optional Original data for additional statistics. - + Returns ------- pd.DataFrame DataFrame with one row per node. """ rows = [] - + for node in result.nodes: row = { 'node_id': node.node_id, @@ -53,38 +56,38 @@ def node_summary_dataframe( 'spatial_x': node.spatial_centroid[0], 'spatial_y': node.spatial_centroid[1], } - + if len(node.spatial_centroid) > 2: row['spatial_z'] = node.spatial_centroid[2] - + # Add composition if available if result.graph is not None and node.node_id in result.graph.nodes: comp = result.graph.nodes[node.node_id].get('composition', {}) for cell_type, count in comp.items(): row[f'count_{cell_type}'] = count row[f'prop_{cell_type}'] = count / node.size if node.size > 0 else 0 - + rows.append(row) - + return pd.DataFrame(rows) -def edge_summary_dataframe(result: 'MapperResult') -> pd.DataFrame: +def edge_summary_dataframe(result: MapperResult) -> pd.DataFrame: """ Create a summary DataFrame of all Mapper edges. - + Parameters ---------- result : MapperResult Mapper result. - + Returns ------- pd.DataFrame DataFrame with one row per edge. """ rows = [] - + for edge in result.edges: row = { 'source': edge.source, @@ -93,18 +96,18 @@ def edge_summary_dataframe(result: 'MapperResult') -> pd.DataFrame: 'n_shared': len(edge.shared_members), } rows.append(row) - + return pd.DataFrame(rows) def find_hub_nodes( - result: 'MapperResult', + result: MapperResult, n_hubs: int = 5, metric: str = 'degree' ) -> List[Tuple[int, float]]: """ Find hub nodes in the Mapper graph. - + Parameters ---------- result : MapperResult @@ -113,7 +116,7 @@ def find_hub_nodes( Number of hub nodes to return. metric : str, default 'degree' Hub metric: 'degree', 'betweenness', 'closeness', 'size'. - + Returns ------- list of (node_id, score) @@ -121,11 +124,11 @@ def find_hub_nodes( """ if result.graph is None: raise ValueError("MapperResult has no graph") - + import networkx as nx - + G = result.graph - + if metric == 'degree': scores = dict(G.degree()) elif metric == 'betweenness': @@ -136,28 +139,28 @@ def find_hub_nodes( scores = {n: G.nodes[n].get('size', 0) for n in G.nodes()} else: raise ValueError(f"Unknown metric: {metric}") - + sorted_nodes = sorted(scores.items(), key=lambda x: x[1], reverse=True) - + return sorted_nodes[:n_hubs] def find_bridge_nodes( - result: 'MapperResult', + result: MapperResult, n_bridges: int = 5 ) -> List[Tuple[int, float]]: """ Find bridge nodes connecting different components. - + These are nodes whose removal would most increase graph fragmentation. - + Parameters ---------- result : MapperResult Mapper result. n_bridges : int, default 5 Number of bridge nodes to return. - + Returns ------- list of (node_id, bridge_score) @@ -165,32 +168,32 @@ def find_bridge_nodes( """ if result.graph is None: raise ValueError("MapperResult has no graph") - + import networkx as nx - + G = result.graph - + # Use betweenness centrality as bridge metric betweenness = nx.betweenness_centrality(G) - + sorted_nodes = sorted(betweenness.items(), key=lambda x: x[1], reverse=True) - + return sorted_nodes[:n_bridges] # ----------------------------------------------------------------------------- -# Component Analysis +# Component Analysis # ----------------------------------------------------------------------------- -def component_statistics(result: 'MapperResult') -> pd.DataFrame: +def component_statistics(result: MapperResult) -> pd.DataFrame: """ Compute statistics for each connected component. - + Parameters ---------- result : MapperResult Mapper result. - + Returns ------- pd.DataFrame @@ -198,20 +201,20 @@ def component_statistics(result: 'MapperResult') -> pd.DataFrame: """ if result.graph is None: raise ValueError("MapperResult has no graph") - + import networkx as nx - + G = result.graph components = list(nx.connected_components(G)) - + # Sort by size components = sorted(components, key=len, reverse=True) - + rows = [] - + for i, comp_nodes in enumerate(components): subgraph = G.subgraph(comp_nodes) - + # Get all cells in this component cells = [] for node_id in comp_nodes: @@ -220,7 +223,7 @@ def component_statistics(result: 'MapperResult') -> pd.DataFrame: cells.extend(node.members) break cells = np.unique(cells) - + row = { 'component_id': i, 'n_nodes': len(comp_nodes), @@ -229,7 +232,7 @@ def component_statistics(result: 'MapperResult') -> pd.DataFrame: 'filter_mean': np.mean(result.filter_values[cells]), 'filter_std': np.std(result.filter_values[cells]), } - + # Aggregate composition composition = {} for node_id in comp_nodes: @@ -237,29 +240,29 @@ def component_statistics(result: 'MapperResult') -> pd.DataFrame: comp = G.nodes[node_id].get('composition', {}) for ct, count in comp.items(): composition[ct] = composition.get(ct, 0) + count - + for ct, count in composition.items(): row[f'count_{ct}'] = count - + rows.append(row) - + return pd.DataFrame(rows) def get_component_cells( - result: 'MapperResult', + result: MapperResult, component_idx: int = 0 ) -> np.ndarray: """ Get all cell indices in a specific component. - + Parameters ---------- result : MapperResult Mapper result. component_idx : int, default 0 Component index (0 = largest). - + Returns ------- np.ndarray @@ -273,19 +276,19 @@ def get_component_cells( # ----------------------------------------------------------------------------- def compare_mapper_results( - results: List['MapperResult'], + results: List[MapperResult], sample_ids: Optional[List[str]] = None ) -> pd.DataFrame: """ Compare Mapper results across multiple samples. - + Parameters ---------- results : list of MapperResult Mapper results from different samples. sample_ids : list of str, optional Names for each sample. - + Returns ------- pd.DataFrame @@ -293,12 +296,12 @@ def compare_mapper_results( """ if sample_ids is None: sample_ids = [f'sample_{i}' for i in range(len(results))] - + rows = [] - + for sample_id, result in zip(sample_ids, results): stats = result.statistics - + row = { 'sample_id': sample_id, 'n_nodes': result.n_nodes, @@ -310,37 +313,37 @@ def compare_mapper_results( 'density': stats.get('density', np.nan), 'avg_clustering': stats.get('avg_clustering', np.nan), } - + # Filter statistics row['filter_mean'] = np.mean(result.filter_values) row['filter_std'] = np.std(result.filter_values) - + rows.append(row) - + return pd.DataFrame(rows) def extract_mapper_features( - result: 'MapperResult', + result: MapperResult, prefix: str = 'mapper' ) -> Dict[str, float]: """ Extract summary features from Mapper result for ML. - + Parameters ---------- result : MapperResult Mapper result. prefix : str, default 'mapper' Prefix for feature names. - + Returns ------- dict Dictionary of feature name -> value. """ stats = result.statistics - + features = { f'{prefix}_n_nodes': result.n_nodes, f'{prefix}_n_edges': result.n_edges, @@ -353,20 +356,20 @@ def extract_mapper_features( f'{prefix}_filter_mean': np.mean(result.filter_values), f'{prefix}_filter_std': np.std(result.filter_values), } - + # Node size distribution node_sizes = [node.size for node in result.nodes] if node_sizes: features[f'{prefix}_node_size_std'] = np.std(node_sizes) features[f'{prefix}_node_size_max'] = np.max(node_sizes) features[f'{prefix}_node_size_min'] = np.min(node_sizes) - + # Edge weight distribution if result.edges: edge_weights = [e.weight for e in result.edges] features[f'{prefix}_edge_weight_mean'] = np.mean(edge_weights) features[f'{prefix}_edge_weight_max'] = np.max(edge_weights) - + # Component sizes if result.n_components > 0 and result.graph is not None: import networkx as nx @@ -375,7 +378,7 @@ def extract_mapper_features( features[f'{prefix}_component_size_ratio'] = ( max(comp_sizes) / result.n_nodes if result.n_nodes > 0 else 0 ) - + return features @@ -384,98 +387,98 @@ def extract_mapper_features( # ----------------------------------------------------------------------------- def cell_mapper_features( - result: 'MapperResult', - data: 'SpatialTissueData' + result: MapperResult, + data: SpatialTissueData ) -> pd.DataFrame: """ Compute per-cell features from Mapper result. - + Parameters ---------- result : MapperResult Mapper result. data : SpatialTissueData Original data. - + Returns ------- pd.DataFrame DataFrame with one row per cell. """ n_cells = data.n_cells - + # Initialize features features = { 'filter_value': result.filter_values, 'n_nodes': np.zeros(n_cells, dtype=int), 'in_graph': np.zeros(n_cells, dtype=bool), } - + # Count nodes per cell for cell_idx, node_ids in result.cell_node_map.items(): features['n_nodes'][cell_idx] = len(node_ids) features['in_graph'][cell_idx] = len(node_ids) > 0 - + # Component assignment if result.graph is not None: import networkx as nx components = list(nx.connected_components(result.graph)) components = sorted(components, key=len, reverse=True) - + node_to_comp = {} for i, comp in enumerate(components): for node in comp: node_to_comp[node] = i - + features['component_id'] = np.full(n_cells, -1, dtype=int) - + for cell_idx, node_ids in result.cell_node_map.items(): if node_ids: features['component_id'][cell_idx] = node_to_comp.get(node_ids[0], -1) - + return pd.DataFrame(features) -def cells_in_multiple_nodes(result: 'MapperResult') -> np.ndarray: +def cells_in_multiple_nodes(result: MapperResult) -> np.ndarray: """ Find cells that belong to multiple Mapper nodes. - + These cells are in the overlap regions and may represent transitional or boundary cells. - + Parameters ---------- result : MapperResult Mapper result. - + Returns ------- np.ndarray Indices of cells in multiple nodes. """ multi_node_cells = [] - + for cell_idx, node_ids in result.cell_node_map.items(): if len(node_ids) > 1: multi_node_cells.append(cell_idx) - + return np.array(multi_node_cells) def uncovered_cells( - result: 'MapperResult', + result: MapperResult, n_cells: int ) -> np.ndarray: """ Find cells not covered by any Mapper node. - + Parameters ---------- result : MapperResult Mapper result. n_cells : int Total number of cells in data. - + Returns ------- np.ndarray @@ -484,7 +487,7 @@ def uncovered_cells( covered = set(result.cell_node_map.keys()) all_cells = set(range(n_cells)) uncovered = all_cells - covered - + return np.array(sorted(list(uncovered))) @@ -493,14 +496,14 @@ def uncovered_cells( # ----------------------------------------------------------------------------- def mapper_stability_score( - data: 'SpatialTissueData', + data: SpatialTissueData, n_runs: int = 10, subsample_fraction: float = 0.8, **mapper_kwargs ) -> Dict[str, float]: """ Assess Mapper stability through subsampling. - + Parameters ---------- data : SpatialTissueData @@ -511,42 +514,42 @@ def mapper_stability_score( Fraction of cells to subsample. **mapper_kwargs Arguments passed to SpatialMapper. - + Returns ------- dict Stability metrics. """ from .mapper import SpatialMapper - + results = [] n_cells = data.n_cells n_subsample = int(n_cells * subsample_fraction) - + for run in range(n_runs): # Random subsample indices = np.random.choice(n_cells, n_subsample, replace=False) - + # Create subsampled data from spatialtissuepy.core import SpatialTissueData subdata = SpatialTissueData( coordinates=data._coordinates[indices], cell_types=data._cell_types[indices] ) - + # Run Mapper mapper = SpatialMapper(**mapper_kwargs) result = mapper.fit(subdata) - + results.append({ 'n_nodes': result.n_nodes, 'n_edges': result.n_edges, 'n_components': result.n_components, }) - + # Compute stability metrics df = pd.DataFrame(results) - + stability = { 'n_nodes_mean': df['n_nodes'].mean(), 'n_nodes_std': df['n_nodes'].std(), @@ -556,19 +559,19 @@ def mapper_stability_score( 'n_components_mean': df['n_components'].mean(), 'n_components_std': df['n_components'].std(), } - + return stability def optimal_n_intervals( - data: 'SpatialTissueData', + data: SpatialTissueData, interval_range: List[int] = None, metric: str = 'n_components', **mapper_kwargs ) -> Tuple[int, pd.DataFrame]: """ Find optimal number of intervals for Mapper. - + Parameters ---------- data : SpatialTissueData @@ -579,7 +582,7 @@ def optimal_n_intervals( Metric to optimize: 'n_components', 'n_nodes', 'coverage'. **mapper_kwargs Additional arguments for SpatialMapper. - + Returns ------- optimal_n : int @@ -588,18 +591,18 @@ def optimal_n_intervals( Results for all interval values. """ from .mapper import SpatialMapper - + if interval_range is None: interval_range = [5, 8, 10, 12, 15, 20] - + results = [] - + for n_int in interval_range: mapper = SpatialMapper(n_intervals=n_int, **mapper_kwargs) result = mapper.fit(data) - + coverage = len(result.cell_node_map) / data.n_cells - + results.append({ 'n_intervals': n_int, 'n_nodes': result.n_nodes, @@ -607,9 +610,9 @@ def optimal_n_intervals( 'n_components': result.n_components, 'coverage': coverage, }) - + df = pd.DataFrame(results) - + # Find optimal based on metric if metric == 'n_components': # Prefer fewer components (more connected) @@ -619,7 +622,7 @@ def optimal_n_intervals( optimal_idx = df['coverage'].idxmax() else: optimal_idx = df[metric].idxmax() - + optimal_n = df.loc[optimal_idx, 'n_intervals'] - + return int(optimal_n), df diff --git a/spatialtissuepy/topology/cover.py b/spatialtissuepy/topology/cover.py index 3d7fe9a..bf9db63 100644 --- a/spatialtissuepy/topology/cover.py +++ b/spatialtissuepy/topology/cover.py @@ -13,8 +13,10 @@ """ from __future__ import annotations -from typing import List, Tuple, Optional, Union + from dataclasses import dataclass +from typing import List, Optional + import numpy as np @@ -24,49 +26,49 @@ class CoverElement: index: int lower: float upper: float - + def contains(self, value: float) -> bool: """Check if value is within this cover element.""" return self.lower <= value <= self.upper - + def __repr__(self) -> str: return f"CoverElement({self.index}, [{self.lower:.3f}, {self.upper:.3f}])" class Cover: """Base class for cover constructions.""" - + def __init__(self): self.elements: List[CoverElement] = [] - - def fit(self, filter_values: np.ndarray) -> 'Cover': + + def fit(self, filter_values: np.ndarray) -> Cover: """ Fit the cover to filter values. - + Parameters ---------- filter_values : np.ndarray 1D array of filter values. - + Returns ------- Cover Self, with elements populated. """ raise NotImplementedError - + def get_element_members( self, filter_values: np.ndarray ) -> List[np.ndarray]: """ Get point indices belonging to each cover element. - + Parameters ---------- filter_values : np.ndarray 1D array of filter values. - + Returns ------- list of np.ndarray @@ -77,13 +79,13 @@ def get_element_members( mask = (filter_values >= element.lower) & (filter_values <= element.upper) members.append(np.where(mask)[0]) return members - + def __len__(self) -> int: return len(self.elements) - + def __iter__(self): return iter(self.elements) - + def __getitem__(self, idx: int) -> CoverElement: return self.elements[idx] @@ -91,11 +93,11 @@ def __getitem__(self, idx: int) -> CoverElement: class UniformCover(Cover): """ Cover with equal-width intervals and fixed overlap. - + The standard cover type for Mapper. Divides the filter range into n_intervals equal-width intervals, with adjacent intervals overlapping by a fraction specified by overlap_fraction. - + Parameters ---------- n_intervals : int, default 10 @@ -103,151 +105,150 @@ class UniformCover(Cover): overlap_fraction : float, default 0.5 Fraction of interval width that overlaps with neighbors. 0.5 means 50% overlap (each point typically in ~2 intervals). - + Examples -------- >>> cover = UniformCover(n_intervals=10, overlap_fraction=0.5) >>> cover.fit(filter_values) >>> members = cover.get_element_members(filter_values) """ - + def __init__( self, n_intervals: int = 10, overlap_fraction: float = 0.5 ): super().__init__() - + if n_intervals < 1: raise ValueError("n_intervals must be >= 1") if not 0 <= overlap_fraction < 1: raise ValueError("overlap_fraction must be in [0, 1)") - + self.n_intervals = n_intervals self.overlap_fraction = overlap_fraction - - def fit(self, filter_values: np.ndarray) -> 'UniformCover': + + def fit(self, filter_values: np.ndarray) -> UniformCover: """Fit uniform cover to filter values.""" filter_values = np.asarray(filter_values) - + f_min = filter_values.min() f_max = filter_values.max() f_range = f_max - f_min - + if f_range == 0: # All values identical: single interval self.elements = [CoverElement(0, f_min - 0.5, f_max + 0.5)] return self - + # Interval width without overlap base_width = f_range / self.n_intervals - + # Actual interval width with overlap # Each interval extends beyond its base by overlap_fraction/2 on each side interval_width = base_width * (1 + self.overlap_fraction) - + # Step between interval centers - step = base_width - + self.elements = [] for i in range(self.n_intervals): center = f_min + base_width * (i + 0.5) lower = center - interval_width / 2 upper = center + interval_width / 2 - + # Ensure we cover the full range if i == 0: lower = f_min - 1e-10 # Slightly below to include boundary if i == self.n_intervals - 1: upper = f_max + 1e-10 # Slightly above to include boundary - + self.elements.append(CoverElement(i, lower, upper)) - + return self class AdaptiveCover(Cover): """ Cover with equal-count intervals (quantile-based). - + Instead of equal-width intervals, this cover ensures each interval contains approximately the same number of points. This is useful when filter values are not uniformly distributed. - + Parameters ---------- n_intervals : int, default 10 Number of intervals. overlap_fraction : float, default 0.5 Fraction of points that overlap with neighboring intervals. - + Examples -------- >>> cover = AdaptiveCover(n_intervals=10, overlap_fraction=0.5) >>> cover.fit(filter_values) """ - + def __init__( self, n_intervals: int = 10, overlap_fraction: float = 0.5 ): super().__init__() - + if n_intervals < 1: raise ValueError("n_intervals must be >= 1") if not 0 <= overlap_fraction < 1: raise ValueError("overlap_fraction must be in [0, 1)") - + self.n_intervals = n_intervals self.overlap_fraction = overlap_fraction - - def fit(self, filter_values: np.ndarray) -> 'AdaptiveCover': + + def fit(self, filter_values: np.ndarray) -> AdaptiveCover: """Fit adaptive cover using quantiles.""" filter_values = np.asarray(filter_values) n_points = len(filter_values) - + if n_points == 0: self.elements = [] return self - + # Compute quantiles for interval boundaries # Without overlap, boundaries would be at 0, 1/n, 2/n, ..., 1 quantiles # With overlap, we extend each interval - + base_quantiles = np.linspace(0, 1, self.n_intervals + 1) - + # Extend each interval by overlap_fraction / 2 on each side (in quantile space) extension = self.overlap_fraction / 2 / self.n_intervals - + self.elements = [] for i in range(self.n_intervals): q_lower = max(0, base_quantiles[i] - extension) q_upper = min(1, base_quantiles[i + 1] + extension) - + lower = np.quantile(filter_values, q_lower) upper = np.quantile(filter_values, q_upper) - + # Handle edge cases if i == 0: lower = filter_values.min() - 1e-10 if i == self.n_intervals - 1: upper = filter_values.max() + 1e-10 - + self.elements.append(CoverElement(i, lower, upper)) - + return self class BallCover(Cover): """ Cover using balls in 2D filter space. - + For use with 2D filter functions (e.g., combined x and y coordinates). Places overlapping balls to cover the 2D filter space. - + Note: This is a more advanced cover type for specialized applications. - + Parameters ---------- n_balls : int, default 20 @@ -255,7 +256,7 @@ class BallCover(Cover): overlap_fraction : float, default 0.3 Fraction of ball radius that overlaps with neighbors. """ - + def __init__( self, n_balls: int = 20, @@ -266,37 +267,37 @@ def __init__( self.overlap_fraction = overlap_fraction self._centers: Optional[np.ndarray] = None self._radius: float = 0.0 - - def fit(self, filter_values: np.ndarray) -> 'BallCover': + + def fit(self, filter_values: np.ndarray) -> BallCover: """ Fit ball cover to 2D filter values. - + Parameters ---------- filter_values : np.ndarray 2D array of shape (n_points, 2). """ filter_values = np.asarray(filter_values) - + if filter_values.ndim != 2 or filter_values.shape[1] != 2: raise ValueError("BallCover requires 2D filter values (n_points, 2)") - + # Compute bounding box mins = filter_values.min(axis=0) maxs = filter_values.max(axis=0) ranges = maxs - mins - + # Estimate grid dimensions aspect = ranges[0] / ranges[1] if ranges[1] > 0 else 1 n_y = int(np.sqrt(self.n_balls / aspect)) n_x = int(self.n_balls / n_y) - + # Ball radius based on grid spacing spacing_x = ranges[0] / n_x if n_x > 0 else ranges[0] spacing_y = ranges[1] / n_y if n_y > 0 else ranges[1] base_radius = max(spacing_x, spacing_y) / 2 self._radius = base_radius * (1 + self.overlap_fraction) - + # Generate ball centers on grid centers = [] for i in range(n_x): @@ -304,30 +305,30 @@ def fit(self, filter_values: np.ndarray) -> 'BallCover': cx = mins[0] + spacing_x * (i + 0.5) cy = mins[1] + spacing_y * (j + 0.5) centers.append([cx, cy]) - + self._centers = np.array(centers) - + # Create cover elements (using 1D indexing) self.elements = [ CoverElement(i, 0, 0) # lower/upper not meaningful for balls for i in range(len(centers)) ] - + return self - + def get_element_members( self, filter_values: np.ndarray ) -> List[np.ndarray]: """Get points within each ball.""" filter_values = np.asarray(filter_values) - + members = [] for center in self._centers: distances = np.linalg.norm(filter_values - center, axis=1) mask = distances <= self._radius members.append(np.where(mask)[0]) - + return members @@ -339,7 +340,7 @@ def create_cover( ) -> Cover: """ Factory function to create a cover. - + Parameters ---------- cover_type : str, default 'uniform' @@ -350,7 +351,7 @@ def create_cover( Overlap fraction. **kwargs Additional arguments passed to cover constructor. - + Returns ------- Cover @@ -361,13 +362,13 @@ def create_cover( 'adaptive': AdaptiveCover, 'ball': BallCover, } - + if cover_type not in cover_types: raise ValueError(f"Unknown cover type: {cover_type}. " f"Options: {list(cover_types.keys())}") - + cls = cover_types[cover_type] - + if cover_type == 'ball': return cls(n_balls=n_intervals, overlap_fraction=overlap_fraction, **kwargs) else: diff --git a/spatialtissuepy/topology/filters.py b/spatialtissuepy/topology/filters.py index 248f1a8..d4203b7 100644 --- a/spatialtissuepy/topology/filters.py +++ b/spatialtissuepy/topology/filters.py @@ -25,7 +25,9 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Optional, Union + +from typing import TYPE_CHECKING, Callable, Optional + import numpy as np from scipy.spatial import cKDTree @@ -42,21 +44,21 @@ def density_filter( ) -> FilterFunction: """ Create a density-based filter function. - + Computes local cell density as the number of cells within a given radius. - + Parameters ---------- radius : float, default 50.0 Radius for density calculation (in coordinate units). normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction Filter function that computes local density. - + Examples -------- >>> filter_fn = density_filter(radius=100) @@ -65,7 +67,7 @@ def density_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: tree = cKDTree(coordinates) # Count neighbors within radius for each point @@ -73,12 +75,12 @@ def _filter( len(tree.query_ball_point(coord, radius)) - 1 # Exclude self for coord in coordinates ], dtype=float) - + if normalize and counts.max() > counts.min(): counts = (counts - counts.min()) / (counts.max() - counts.min()) - + return counts - + return _filter @@ -88,16 +90,16 @@ def pca_filter( ) -> FilterFunction: """ Create a PCA-based filter function. - + Projects neighborhood vectors onto principal components. - + Parameters ---------- n_components : int, default 1 Number of PCA components to compute. component_index : int, default 0 Which component to use as filter (0 = first PC). - + Returns ------- FilterFunction @@ -106,42 +108,42 @@ def pca_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: from sklearn.decomposition import PCA - + # Handle edge case of single-column neighborhoods if neighborhoods.shape[1] <= n_components: # Can't do PCA, just return first column or zeros if neighborhoods.shape[1] > component_index: return neighborhoods[:, component_index] return np.zeros(len(neighborhoods)) - + pca = PCA(n_components=min(n_components, neighborhoods.shape[1])) transformed = pca.fit_transform(neighborhoods) - + if component_index >= transformed.shape[1]: raise ValueError( f"component_index {component_index} >= n_components {transformed.shape[1]}" ) - + return transformed[:, component_index] - + return _filter def eccentricity_filter(normalize: bool = True) -> FilterFunction: """ Create an eccentricity-based filter function. - + Computes distance from each point to the centroid of all points in the neighborhood feature space. - + Parameters ---------- normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction @@ -150,16 +152,16 @@ def eccentricity_filter(normalize: bool = True) -> FilterFunction: def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: centroid = neighborhoods.mean(axis=0) distances = np.linalg.norm(neighborhoods - centroid, axis=1) - + if normalize and distances.max() > distances.min(): distances = (distances - distances.min()) / (distances.max() - distances.min()) - + return distances - + return _filter @@ -169,10 +171,10 @@ def linfinity_centrality_filter( ) -> FilterFunction: """ Create an L-infinity centrality filter function. - + Computes the maximum distance to any other point in neighborhood space. For large datasets, samples a subset for efficiency. - + Parameters ---------- sample_size : int, optional @@ -180,7 +182,7 @@ def linfinity_centrality_filter( If None, uses all points (can be slow for large datasets). normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction @@ -189,46 +191,46 @@ def linfinity_centrality_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: n_points = len(neighborhoods) - + if sample_size is not None and sample_size < n_points: # Sample indices for efficiency sample_idx = np.random.choice(n_points, sample_size, replace=False) sample_points = neighborhoods[sample_idx] else: sample_points = neighborhoods - + # Compute max distance to sampled points for each point max_distances = np.zeros(n_points) for i, point in enumerate(neighborhoods): distances = np.linalg.norm(sample_points - point, axis=1) max_distances[i] = distances.max() - + if normalize and max_distances.max() > max_distances.min(): max_distances = ( - (max_distances - max_distances.min()) / + (max_distances - max_distances.min()) / (max_distances.max() - max_distances.min()) ) - + return max_distances - + return _filter def sum_filter(normalize: bool = True) -> FilterFunction: """ Create a sum-based filter function. - + Computes the sum of neighborhood vector components (total neighbor count if neighborhoods are counts, or 1.0 if normalized). - + Parameters ---------- normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction @@ -237,30 +239,30 @@ def sum_filter(normalize: bool = True) -> FilterFunction: def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: sums = neighborhoods.sum(axis=1) - + if normalize and sums.max() > sums.min(): sums = (sums - sums.min()) / (sums.max() - sums.min()) - + return sums - + return _filter def entropy_filter(normalize: bool = True) -> FilterFunction: """ Create an entropy-based filter function. - + Computes Shannon entropy of neighborhood composition, measuring diversity of cell types in each cell's neighborhood. - + Parameters ---------- normalize : bool, default True If True, normalize by maximum possible entropy. - + Returns ------- FilterFunction @@ -269,39 +271,39 @@ def entropy_filter(normalize: bool = True) -> FilterFunction: def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: # Normalize rows to proportions row_sums = neighborhoods.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # Avoid division by zero props = neighborhoods / row_sums - + # Compute entropy: -sum(p * log(p)) # Handle zeros by using np.where with np.errstate(divide='ignore', invalid='ignore'): log_props = np.where(props > 0, np.log(props), 0) entropy = -np.sum(props * log_props, axis=1) - + if normalize: # Maximum entropy is log(n_categories) max_entropy = np.log(neighborhoods.shape[1]) if max_entropy > 0: entropy = entropy / max_entropy - + return entropy - + return _filter def constant_filter(value: float = 0.0) -> FilterFunction: """ Create a constant filter function (useful for testing). - + Parameters ---------- value : float, default 0.0 Constant value to return for all points. - + Returns ------- FilterFunction @@ -310,8 +312,8 @@ def constant_filter(value: float = 0.0) -> FilterFunction: def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: return np.full(len(coordinates), value) - + return _filter diff --git a/spatialtissuepy/topology/mapper.py b/spatialtissuepy/topology/mapper.py index 42df5da..dd630d0 100644 --- a/spatialtissuepy/topology/mapper.py +++ b/spatialtissuepy/topology/mapper.py @@ -24,31 +24,34 @@ """ from __future__ import annotations -from typing import ( - TYPE_CHECKING, Any, Callable, Dict, List, Optional, - Tuple, Union -) + from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + import numpy as np from scipy.spatial import cKDTree -from .cover import Cover, UniformCover, AdaptiveCover, create_cover +from .cover import Cover, create_cover +from .filters import FilterFunction, density_filter, pca_filter from .nerve import ( - MapperNode, MapperEdge, build_nerve, - nodes_edges_to_networkx, compute_graph_statistics + MapperEdge, + MapperNode, + build_nerve, + compute_graph_statistics, + nodes_edges_to_networkx, ) -from .filters import density_filter, pca_filter, FilterFunction if TYPE_CHECKING: - from spatialtissuepy.core.spatial_data import SpatialTissueData import networkx as nx + from spatialtissuepy.core.spatial_data import SpatialTissueData + @dataclass class MapperResult: """ Container for Mapper algorithm results. - + Attributes ---------- graph : nx.Graph @@ -66,28 +69,28 @@ class MapperResult: parameters : dict Parameters used for this Mapper run. """ - graph: 'nx.Graph' + graph: nx.Graph nodes: List[MapperNode] edges: List[MapperEdge] filter_values: np.ndarray cell_node_map: Dict[int, List[int]] cover: Cover parameters: Dict[str, Any] - + # Cached statistics _statistics: Optional[Dict[str, Any]] = field(default=None, repr=False) _node_compositions: Optional[Dict[int, Dict[str, int]]] = field(default=None, repr=False) - + @property def n_nodes(self) -> int: """Number of nodes in the Mapper graph.""" return len(self.nodes) - + @property def n_edges(self) -> int: """Number of edges in the Mapper graph.""" return len(self.edges) - + @property def n_components(self) -> int: """Number of connected components.""" @@ -97,30 +100,30 @@ def n_components(self) -> int: except ImportError: # Count manually from nodes/edges return self._count_components_manual() - + def _count_components_manual(self) -> int: """Count connected components without networkx.""" if len(self.nodes) == 0: return 0 - + # Union-find parent = {n.node_id: n.node_id for n in self.nodes} - + def find(x): if parent[x] != x: parent[x] = find(parent[x]) return parent[x] - + def union(x, y): px, py = find(x), find(y) if px != py: parent[px] = py - + for edge in self.edges: union(edge.source, edge.target) - + return len(set(find(n.node_id) for n in self.nodes)) - + @property def statistics(self) -> Dict[str, Any]: """Compute and cache graph statistics.""" @@ -134,7 +137,7 @@ def statistics(self) -> Dict[str, Any]: 'n_connected_components': self.n_components, } return self._statistics - + @property def node_compositions(self) -> Dict[int, Dict[str, int]]: """Cell type composition of each node.""" @@ -145,21 +148,21 @@ def node_compositions(self) -> Dict[int, Dict[str, int]]: comp = self.graph.nodes[node.node_id].get('composition', {}) self._node_compositions[node.node_id] = comp return self._node_compositions - + @property def node_spatial_centroids(self) -> Dict[int, np.ndarray]: """Spatial centroids of each node.""" return {node.node_id: node.spatial_centroid for node in self.nodes} - + def get_node_members(self, node_id: int) -> np.ndarray: """ Get cell indices belonging to a specific node. - + Parameters ---------- node_id : int Node identifier. - + Returns ------- np.ndarray @@ -169,16 +172,16 @@ def get_node_members(self, node_id: int) -> np.ndarray: if node.node_id == node_id: return node.members.copy() raise ValueError(f"Node {node_id} not found") - + def get_cells_by_component(self, component_id: int = 0) -> np.ndarray: """ Get all cells in a connected component. - + Parameters ---------- component_id : int, default 0 Component index (0 is largest component). - + Returns ------- np.ndarray @@ -189,25 +192,25 @@ def get_cells_by_component(self, component_id: int = 0) -> np.ndarray: components = list(nx.connected_components(self.graph)) # Sort by size (descending) components = sorted(components, key=len, reverse=True) - + if component_id >= len(components): raise ValueError(f"Component {component_id} not found (only {len(components)} components)") - + component_nodes = components[component_id] cells = [] for node_id in component_nodes: cells.extend(self.get_node_members(node_id)) - + return np.unique(cells) except ImportError: raise ImportError("networkx required for get_cells_by_component") - + def __repr__(self) -> str: return ( f"MapperResult(n_nodes={self.n_nodes}, n_edges={self.n_edges}, " f"n_components={self.n_components})" ) - + def __str__(self) -> str: lines = [ "MapperResult", @@ -215,7 +218,7 @@ def __str__(self) -> str: f" Edges: {self.n_edges}", f" Connected components: {self.n_components}", ] - + stats = self.statistics if 'mean_degree' in stats: lines.append(f" Mean node degree: {stats['mean_degree']:.2f}") @@ -223,17 +226,17 @@ def __str__(self) -> str: lines.append(f" Mean node size: {stats['mean_node_size']:.1f} cells") if 'total_cells_in_nodes' in stats: lines.append(f" Total cells in graph: {stats['total_cells_in_nodes']}") - + return "\n".join(lines) class SpatialMapper: """ Spatial Mapper algorithm for cell community discovery. - + Implements the Mapper algorithm from topological data analysis with spatial-aware filter functions designed for tissue biology. - + Parameters ---------- filter_fn : str, callable, or FilterFunction @@ -255,7 +258,7 @@ class SpatialMapper: Minimum cells to form a cluster. min_edge_weight : int, default 1 Minimum overlap to create an edge. - + Examples -------- >>> mapper = SpatialMapper( @@ -264,7 +267,7 @@ class SpatialMapper: ... overlap=0.5, ... ) >>> result = mapper.fit(data, neighborhood_radius=50) - + >>> # With spatial filter >>> from spatialtissuepy.topology.spatial_filters import radial_filter >>> mapper = SpatialMapper( @@ -274,7 +277,7 @@ class SpatialMapper: ... ) >>> result = mapper.fit(data, neighborhood_radius=50) """ - + def __init__( self, filter_fn: Union[str, FilterFunction] = 'density', @@ -294,10 +297,10 @@ def __init__( self.clustering_params = clustering_params or {} self.min_cluster_size = min_cluster_size self.min_edge_weight = min_edge_weight - + # Resolve string filter to function self._filter_fn = self._resolve_filter(filter_fn) - + def _resolve_filter( self, filter_fn: Union[str, FilterFunction] @@ -305,29 +308,29 @@ def _resolve_filter( """Resolve filter string to function.""" if callable(filter_fn): return filter_fn - + filter_map = { 'density': density_filter(), 'pca': pca_filter(n_components=1), } - + if filter_fn in filter_map: return filter_map[filter_fn] - + raise ValueError( f"Unknown filter: {filter_fn}. " f"Options: {list(filter_map.keys())} or provide a callable." ) - + def fit( self, - data: 'SpatialTissueData', + data: SpatialTissueData, neighborhood_radius: float = 50.0, features: Optional[np.ndarray] = None, ) -> MapperResult: """ Fit Mapper to spatial tissue data. - + Parameters ---------- data : SpatialTissueData @@ -337,7 +340,7 @@ def fit( features : np.ndarray, optional Precomputed feature matrix. If None, computes neighborhood composition matrix. - + Returns ------- MapperResult @@ -345,16 +348,16 @@ def fit( """ coordinates = data._coordinates cell_types = data._cell_types - + # Compute neighborhood composition matrix if not provided if features is None: features = self._compute_neighborhood_matrix( data, radius=neighborhood_radius ) - + # Compute filter values filter_values = self._filter_fn(coordinates, features, data) - + # Create and fit cover cover = create_cover( cover_type=self.cover_type, @@ -362,10 +365,10 @@ def fit( overlap_fraction=self.overlap ) cover.fit(filter_values) - + # Get cover element members cover_members = cover.get_element_members(filter_values) - + # Build nerve (cluster and connect) nodes, edges = build_nerve( cover_element_members=cover_members, @@ -376,14 +379,14 @@ def fit( min_edge_weight=self.min_edge_weight, **self.clustering_params ) - + # Convert to NetworkX graph try: graph = nodes_edges_to_networkx(nodes, edges, cell_types) except ImportError: # Create minimal graph placeholder graph = None - + # Build cell-to-node mapping cell_node_map: Dict[int, List[int]] = {} for node in nodes: @@ -391,7 +394,7 @@ def fit( if cell_idx not in cell_node_map: cell_node_map[cell_idx] = [] cell_node_map[cell_idx].append(node.node_id) - + # Store parameters parameters = { 'filter_fn': str(self.filter_fn), @@ -404,7 +407,7 @@ def fit( 'min_edge_weight': self.min_edge_weight, 'neighborhood_radius': neighborhood_radius, } - + return MapperResult( graph=graph, nodes=nodes, @@ -414,24 +417,24 @@ def fit( cover=cover, parameters=parameters, ) - + def _compute_neighborhood_matrix( self, - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float ) -> np.ndarray: """ Compute neighborhood composition matrix. - + For each cell, counts the number of each cell type within radius. - + Parameters ---------- data : SpatialTissueData Input data. radius : float Neighborhood radius. - + Returns ------- np.ndarray @@ -441,33 +444,33 @@ def _compute_neighborhood_matrix( cell_types = data._cell_types unique_types = data.cell_types_unique type_to_idx = {t: i for i, t in enumerate(unique_types)} - + n_cells = len(coordinates) n_types = len(unique_types) - + # Build KD-tree tree = cKDTree(coordinates) - + # Compute neighborhoods neighborhoods = np.zeros((n_cells, n_types), dtype=float) - + for i, coord in enumerate(coordinates): # Find neighbors within radius neighbor_idx = tree.query_ball_point(coord, radius) - + # Count cell types (excluding self) for j in neighbor_idx: if j != i: type_idx = type_to_idx[cell_types[j]] neighborhoods[i, type_idx] += 1 - + # Normalize rows (optional: convert to proportions) row_sums = neighborhoods.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # Avoid division by zero neighborhoods = neighborhoods / row_sums - + return neighborhoods - + def __repr__(self) -> str: return ( f"SpatialMapper(filter_fn={self.filter_fn!r}, " @@ -476,7 +479,7 @@ def __repr__(self) -> str: def spatial_mapper( - data: 'SpatialTissueData', + data: SpatialTissueData, filter_fn: Union[str, FilterFunction] = 'density', neighborhood_radius: float = 50.0, n_intervals: int = 10, @@ -487,7 +490,7 @@ def spatial_mapper( ) -> MapperResult: """ Convenience function to run Spatial Mapper. - + Parameters ---------- data : SpatialTissueData @@ -506,12 +509,12 @@ def spatial_mapper( Clustering parameters. min_cluster_size : int, default 3 Minimum cluster size. - + Returns ------- MapperResult Mapper results. - + Examples -------- >>> result = spatial_mapper(data, filter_fn='density', n_intervals=10) diff --git a/spatialtissuepy/topology/nerve.py b/spatialtissuepy/topology/nerve.py index 6e6cf4b..fb7c301 100644 --- a/spatialtissuepy/topology/nerve.py +++ b/spatialtissuepy/topology/nerve.py @@ -10,8 +10,10 @@ """ from __future__ import annotations -from typing import Dict, List, Optional, Set, Tuple, Any + from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + import numpy as np try: @@ -25,7 +27,7 @@ class MapperNode: """ A node in the Mapper graph. - + Attributes ---------- node_id : int @@ -47,12 +49,12 @@ class MapperNode: cluster_label: int centroid: np.ndarray = field(default_factory=lambda: np.array([])) spatial_centroid: np.ndarray = field(default_factory=lambda: np.array([])) - + @property def size(self) -> int: """Number of cells in this node.""" return len(self.members) - + def __repr__(self) -> str: return f"MapperNode(id={self.node_id}, size={self.size}, cover={self.cover_element})" @@ -61,7 +63,7 @@ def __repr__(self) -> str: class MapperEdge: """ An edge in the Mapper graph. - + Attributes ---------- source : int @@ -77,7 +79,7 @@ class MapperEdge: target: int weight: int shared_members: np.ndarray = field(default_factory=lambda: np.array([])) - + def __repr__(self) -> str: return f"MapperEdge({self.source} -- {self.target}, weight={self.weight})" @@ -91,7 +93,7 @@ def cluster_cover_element( ) -> np.ndarray: """ Cluster points within a cover element. - + Parameters ---------- element_members : np.ndarray @@ -104,7 +106,7 @@ def cluster_cover_element( Minimum points to form a cluster. **clustering_params Additional parameters for clustering algorithm. - + Returns ------- np.ndarray @@ -113,25 +115,25 @@ def cluster_cover_element( """ if len(element_members) < min_cluster_size: return np.full(len(element_members), -1) - + element_features = features[element_members] - + if method == 'dbscan': from sklearn.cluster import DBSCAN - + eps = clustering_params.get('eps', 0.5) min_samples = clustering_params.get('min_samples', min_cluster_size) - + clusterer = DBSCAN(eps=eps, min_samples=min_samples) labels = clusterer.fit_predict(element_features) - + elif method == 'agglomerative': from sklearn.cluster import AgglomerativeClustering - + n_clusters = clustering_params.get('n_clusters', None) distance_threshold = clustering_params.get('distance_threshold', 0.5) linkage = clustering_params.get('linkage', 'ward') - + if n_clusters is None: clusterer = AgglomerativeClustering( n_clusters=None, @@ -143,21 +145,21 @@ def cluster_cover_element( n_clusters=n_clusters, linkage=linkage ) - + labels = clusterer.fit_predict(element_features) - + elif method == 'kmeans': from sklearn.cluster import KMeans - + n_clusters = clustering_params.get('n_clusters', 2) n_clusters = min(n_clusters, len(element_members)) - + clusterer = KMeans(n_clusters=n_clusters, n_init=10, random_state=42) labels = clusterer.fit_predict(element_features) - + else: raise ValueError(f"Unknown clustering method: {method}") - + # Filter small clusters unique_labels = np.unique(labels) for label in unique_labels: @@ -165,7 +167,7 @@ def cluster_cover_element( continue if np.sum(labels == label) < min_cluster_size: labels[labels == label] = -1 - + return labels @@ -180,7 +182,7 @@ def build_nerve( ) -> Tuple[List[MapperNode], List[MapperEdge]]: """ Build the nerve (graph) from clustered cover elements. - + Parameters ---------- cover_element_members : list of np.ndarray @@ -197,7 +199,7 @@ def build_nerve( Minimum overlap to create an edge. **clustering_params Parameters for clustering algorithm. - + Returns ------- nodes : list of MapperNode @@ -207,15 +209,15 @@ def build_nerve( """ nodes: List[MapperNode] = [] node_id_counter = 0 - + # Track which cells belong to which nodes (for edge computation) cell_to_nodes: Dict[int, List[int]] = {} - + # Cluster each cover element for element_idx, members in enumerate(cover_element_members): if len(members) == 0: continue - + # Cluster within this element labels = cluster_cover_element( members, @@ -224,20 +226,20 @@ def build_nerve( min_cluster_size=min_cluster_size, **clustering_params ) - + # Create nodes for each cluster unique_labels = np.unique(labels) for label in unique_labels: if label == -1: # Skip noise continue - + cluster_mask = labels == label cluster_members = members[cluster_mask] - + # Compute centroids centroid = features[cluster_members].mean(axis=0) spatial_centroid = coordinates[cluster_members].mean(axis=0) - + node = MapperNode( node_id=node_id_counter, members=cluster_members, @@ -247,26 +249,25 @@ def build_nerve( spatial_centroid=spatial_centroid ) nodes.append(node) - + # Track cell-to-node mapping for cell_idx in cluster_members: if cell_idx not in cell_to_nodes: cell_to_nodes[cell_idx] = [] cell_to_nodes[cell_idx].append(node_id_counter) - + node_id_counter += 1 - + # Build edges from overlapping nodes edges: List[MapperEdge] = [] - edge_set: Set[Tuple[int, int]] = set() # Track added edges - + # Count overlaps between all pairs of nodes overlap_counts: Dict[Tuple[int, int], List[int]] = {} - + for cell_idx, node_ids in cell_to_nodes.items(): if len(node_ids) < 2: continue - + # All pairs of nodes sharing this cell for i, node_i in enumerate(node_ids): for node_j in node_ids[i + 1:]: @@ -274,7 +275,7 @@ def build_nerve( if edge_key not in overlap_counts: overlap_counts[edge_key] = [] overlap_counts[edge_key].append(cell_idx) - + # Create edges for pairs with sufficient overlap for (node_i, node_j), shared_cells in overlap_counts.items(): if len(shared_cells) >= min_edge_weight: @@ -285,7 +286,7 @@ def build_nerve( shared_members=np.array(shared_cells) ) edges.append(edge) - + return nodes, edges @@ -293,10 +294,10 @@ def nodes_edges_to_networkx( nodes: List[MapperNode], edges: List[MapperEdge], cell_types: Optional[np.ndarray] = None -) -> 'nx.Graph': +) -> nx.Graph: """ Convert Mapper nodes and edges to a NetworkX graph. - + Parameters ---------- nodes : list of MapperNode @@ -305,12 +306,12 @@ def nodes_edges_to_networkx( Mapper edges. cell_types : np.ndarray, optional Cell type labels for computing node compositions. - + Returns ------- nx.Graph NetworkX graph with node and edge attributes. - + Raises ------ ImportError @@ -321,9 +322,9 @@ def nodes_edges_to_networkx( "networkx is required for graph operations. " "Install with: pip install networkx" ) - + G = nx.Graph() - + # Add nodes with attributes for node in nodes: node_attrs = { @@ -334,7 +335,7 @@ def nodes_edges_to_networkx( 'centroid': node.centroid, 'spatial_centroid': node.spatial_centroid, } - + # Add cell type composition if available if cell_types is not None: unique_types, counts = np.unique( @@ -342,14 +343,14 @@ def nodes_edges_to_networkx( ) composition = dict(zip(unique_types, counts)) node_attrs['composition'] = composition - + # Dominant cell type dominant_idx = np.argmax(counts) node_attrs['dominant_type'] = unique_types[dominant_idx] node_attrs['dominant_fraction'] = counts[dominant_idx] / node.size - + G.add_node(node.node_id, **node_attrs) - + # Add edges with attributes for edge in edges: edge_attrs = { @@ -357,19 +358,19 @@ def nodes_edges_to_networkx( 'shared_members': edge.shared_members, } G.add_edge(edge.source, edge.target, **edge_attrs) - + return G -def compute_graph_statistics(G: 'nx.Graph') -> Dict[str, Any]: +def compute_graph_statistics(G: nx.Graph) -> Dict[str, Any]: """ Compute summary statistics for a Mapper graph. - + Parameters ---------- G : nx.Graph Mapper graph. - + Returns ------- dict @@ -377,35 +378,35 @@ def compute_graph_statistics(G: 'nx.Graph') -> Dict[str, Any]: """ if not HAS_NETWORKX: raise ImportError("networkx is required for graph statistics") - + stats = { 'n_nodes': G.number_of_nodes(), 'n_edges': G.number_of_edges(), 'n_connected_components': nx.number_connected_components(G), } - + if G.number_of_nodes() > 0: # Node degree statistics degrees = [d for n, d in G.degree()] stats['mean_degree'] = np.mean(degrees) stats['max_degree'] = np.max(degrees) stats['min_degree'] = np.min(degrees) - + # Node size statistics sizes = [G.nodes[n].get('size', 0) for n in G.nodes()] stats['mean_node_size'] = np.mean(sizes) stats['total_cells_in_nodes'] = np.sum(sizes) - + # Density if G.number_of_nodes() > 1: stats['density'] = nx.density(G) else: stats['density'] = 0.0 - + # Clustering coefficient if G.number_of_nodes() > 2: stats['avg_clustering'] = nx.average_clustering(G) else: stats['avg_clustering'] = 0.0 - + return stats diff --git a/spatialtissuepy/topology/spatial_filters.py b/spatialtissuepy/topology/spatial_filters.py index c20051f..9dc648b 100644 --- a/spatialtissuepy/topology/spatial_filters.py +++ b/spatialtissuepy/topology/spatial_filters.py @@ -20,10 +20,11 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, List, Optional, Union, Tuple + +from typing import TYPE_CHECKING, Callable, List, Optional, Union + import numpy as np from scipy.spatial import cKDTree -from scipy.ndimage import gaussian_filter1d if TYPE_CHECKING: from spatialtissuepy.core.spatial_data import SpatialTissueData @@ -38,50 +39,50 @@ def spatial_coordinate_filter( ) -> FilterFunction: """ Create a filter based on spatial coordinates. - + Uses the raw x, y, or z coordinate as the filter value. This captures spatial gradients along tissue axes (e.g., epithelium-to-stroma transitions). - + Parameters ---------- axis : str or int, default 'x' Which axis to use: 'x' (0), 'y' (1), or 'z' (2). normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction Filter function that returns coordinate values. - + Examples -------- >>> # Capture left-right gradient >>> filter_fn = spatial_coordinate_filter(axis='x') - >>> - >>> # Capture top-bottom gradient + >>> + >>> # Capture top-bottom gradient >>> filter_fn = spatial_coordinate_filter(axis='y') """ axis_map = {'x': 0, 'y': 1, 'z': 2} axis_idx = axis_map.get(axis, axis) if isinstance(axis, str) else axis - + def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: if axis_idx >= coordinates.shape[1]: raise ValueError( f"Axis {axis_idx} not available in {coordinates.shape[1]}D data" ) - + values = coordinates[:, axis_idx].copy() - + if normalize and values.max() > values.min(): values = (values - values.min()) / (values.max() - values.min()) - + return values - + return _filter @@ -91,11 +92,11 @@ def radial_filter( ) -> FilterFunction: """ Create a radial distance filter from a reference point. - + Computes Euclidean distance from each cell to a reference point. Useful for analyzing radial organization around a tumor center, blood vessel, or other landmark. - + Parameters ---------- center : np.ndarray, optional @@ -103,12 +104,12 @@ def radial_filter( If None, uses the centroid of all cells. normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction Filter function that computes radial distances. - + Examples -------- >>> # Distance from tumor center @@ -121,25 +122,25 @@ def radial_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: ref_point = center if ref_point is None: ref_point = coordinates.mean(axis=0) - + ref_point = np.asarray(ref_point) if len(ref_point) != coordinates.shape[1]: raise ValueError( f"Center has {len(ref_point)} dims but data has {coordinates.shape[1]}" ) - + distances = np.linalg.norm(coordinates - ref_point, axis=1) - + if normalize and distances.max() > distances.min(): distances = (distances - distances.min()) / (distances.max() - distances.min()) - + return distances - + return _filter @@ -150,11 +151,11 @@ def distance_to_type_filter( ) -> FilterFunction: """ Create a filter based on distance to nearest cell of a specified type. - + For each cell, computes the distance to the nearest cell of the target type. This is powerful for analyzing spatial relationships like immune proximity to tumor. - + Parameters ---------- cell_type : str @@ -164,12 +165,12 @@ def distance_to_type_filter( max_distance : float, optional Maximum distance to consider. Distances beyond this are capped. Useful for preventing outliers from dominating normalization. - + Returns ------- FilterFunction Filter function that computes distance to cell type. - + Examples -------- >>> # Distance to nearest tumor cell @@ -181,31 +182,31 @@ def distance_to_type_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: # Find cells of target type target_mask = data._cell_types == cell_type - + if not np.any(target_mask): raise ValueError(f"No cells of type '{cell_type}' found in data") - + target_coords = data._coordinates[target_mask] - + # Build KD-tree for target cells target_tree = cKDTree(target_coords) - + # Query distance to nearest target for all cells distances, _ = target_tree.query(coordinates, k=1) - + # Cap distances if specified if max_distance is not None: distances = np.minimum(distances, max_distance) - + if normalize and distances.max() > distances.min(): distances = (distances - distances.min()) / (distances.max() - distances.min()) - + return distances - + return _filter @@ -215,10 +216,10 @@ def distance_to_boundary_filter( ) -> FilterFunction: """ Create a filter based on distance to tissue boundary. - + Computes distance from each cell to the edge of the tissue region. Useful for identifying core vs. peripheral cells. - + Parameters ---------- boundary_method : str, default 'convex_hull' @@ -227,7 +228,7 @@ def distance_to_boundary_filter( - 'bbox': Use bounding box normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction @@ -236,44 +237,44 @@ def distance_to_boundary_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: if boundary_method == 'bbox': # Distance to nearest bounding box edge mins = coordinates.min(axis=0) maxs = coordinates.max(axis=0) - + # For each point, find distance to nearest edge dist_to_min = coordinates - mins dist_to_max = maxs - coordinates distances = np.minimum(dist_to_min, dist_to_max).min(axis=1) - + elif boundary_method == 'convex_hull': - from scipy.spatial import ConvexHull, Delaunay - + from scipy.spatial import ConvexHull + if coordinates.shape[1] != 2: raise ValueError("Convex hull boundary only supported for 2D data") - + hull = ConvexHull(coordinates) hull_points = coordinates[hull.vertices] - + # Approximate: distance to nearest hull point # (True distance to hull boundary is more complex) hull_tree = cKDTree(hull_points) distances, _ = hull_tree.query(coordinates, k=1) - + # Invert: points on boundary have 0, interior points positive # We want interior points to have high values max_dist = distances.max() distances = max_dist - distances else: raise ValueError(f"Unknown boundary_method: {boundary_method}") - + if normalize and distances.max() > distances.min(): distances = (distances - distances.min()) / (distances.max() - distances.min()) - + return distances - + return _filter @@ -283,17 +284,17 @@ def spatial_density_filter( ) -> FilterFunction: """ Create a filter based on local spatial cell density. - + Unlike the generic density_filter (which operates on neighborhood feature space), this operates directly on spatial coordinates. - + Parameters ---------- radius : float, default 50.0 Radius for counting neighbors (in coordinate units). normalize : bool, default True If True, normalize to [0, 1] range. - + Returns ------- FilterFunction @@ -302,21 +303,21 @@ def spatial_density_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: tree = cKDTree(coordinates) - + # Count neighbors within radius (excluding self) counts = np.array([ len(tree.query_ball_point(coord, radius)) - 1 for coord in coordinates ], dtype=float) - + if normalize and counts.max() > counts.min(): counts = (counts - counts.min()) / (counts.max() - counts.min()) - + return counts - + return _filter @@ -327,10 +328,10 @@ def gaussian_smoothed_filter( ) -> FilterFunction: """ Create a spatially smoothed version of any filter. - + Applies Gaussian-weighted spatial smoothing to the output of another filter function, reducing noise and emphasizing regional trends. - + Parameters ---------- base_filter : FilterFunction @@ -339,12 +340,12 @@ def gaussian_smoothed_filter( Spatial scale of Gaussian smoothing (in coordinate units). n_neighbors : int, default 20 Number of nearest neighbors to use for smoothing. - + Returns ------- FilterFunction Spatially smoothed filter function. - + Examples -------- >>> # Smooth PCA filter spatially @@ -354,30 +355,30 @@ def gaussian_smoothed_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: # Get base filter values base_values = base_filter(coordinates, neighborhoods, data) - + # Build spatial KD-tree tree = cKDTree(coordinates) - + # For each point, compute weighted average of neighbors smoothed = np.zeros_like(base_values) - + for i, coord in enumerate(coordinates): # Find nearest neighbors distances, indices = tree.query(coord, k=min(n_neighbors, len(coordinates))) - + # Gaussian weights weights = np.exp(-0.5 * (distances / sigma) ** 2) weights /= weights.sum() - + # Weighted average smoothed[i] = np.sum(weights * base_values[indices]) - + return smoothed - + return _filter @@ -388,10 +389,10 @@ def composite_filter( ) -> FilterFunction: """ Create a weighted combination of multiple filters. - + Combines multiple filter functions into a single filter by weighted averaging. Useful for creating multi-scale or multi-aspect filters. - + Parameters ---------- filters : list of FilterFunction @@ -400,12 +401,12 @@ def composite_filter( Weights for each filter. If None, uses equal weights. normalize_components : bool, default True If True, normalize each component to [0, 1] before combining. - + Returns ------- FilterFunction Combined filter function. - + Examples -------- >>> # Combine PCA with spatial x-coordinate @@ -416,36 +417,36 @@ def composite_filter( """ if weights is None: weights = [1.0 / len(filters)] * len(filters) - + if len(weights) != len(filters): raise ValueError("Number of weights must match number of filters") - + weights = np.array(weights) weights = weights / weights.sum() # Normalize weights - + def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: components = [] - + for f in filters: values = f(coordinates, neighborhoods, data) - + if normalize_components: if values.max() > values.min(): values = (values - values.min()) / (values.max() - values.min()) - + components.append(values) - + # Weighted sum combined = np.zeros(len(coordinates)) for w, c in zip(weights, components): combined += w * c - + return combined - + return _filter @@ -456,10 +457,10 @@ def multiscale_spatial_filter( ) -> FilterFunction: """ Create a multi-scale spatial filter. - + Computes a spatial measure (density) at multiple scales and combines them. This captures both local and regional spatial patterns. - + Parameters ---------- radii : list of float @@ -468,12 +469,12 @@ def multiscale_spatial_filter( Spatial measure to use at each scale ('density'). weights : list of float, optional Weights for each scale. If None, uses equal weights. - + Returns ------- FilterFunction Multi-scale spatial filter. - + Examples -------- >>> # Density at multiple scales @@ -481,10 +482,10 @@ def multiscale_spatial_filter( """ if method != 'density': raise ValueError(f"Unknown method: {method}. Currently only 'density' supported.") - + # Create density filters at each scale filters = [spatial_density_filter(radius=r) for r in radii] - + return composite_filter(filters, weights=weights) @@ -495,11 +496,11 @@ def type_proportion_filter( ) -> FilterFunction: """ Create a filter based on local proportion of a cell type. - + For each cell, computes the proportion of nearby cells that are of the specified type. Useful for identifying regions enriched for specific cell populations. - + Parameters ---------- cell_type : str @@ -508,7 +509,7 @@ def type_proportion_filter( Radius for neighborhood (in coordinate units). normalize : bool, default True If True, output is already in [0, 1] (proportion). - + Returns ------- FilterFunction @@ -517,20 +518,20 @@ def type_proportion_filter( def _filter( coordinates: np.ndarray, neighborhoods: np.ndarray, - data: 'SpatialTissueData' + data: SpatialTissueData ) -> np.ndarray: tree = cKDTree(coordinates) type_mask = data._cell_types == cell_type - + proportions = np.zeros(len(coordinates)) - + for i, coord in enumerate(coordinates): neighbor_idx = tree.query_ball_point(coord, radius) if len(neighbor_idx) > 1: # Exclude self only case n_type = type_mask[neighbor_idx].sum() proportions[i] = n_type / len(neighbor_idx) - + # Already in [0, 1], normalization is optional return proportions - + return _filter diff --git a/spatialtissuepy/topology/summary_metrics.py b/spatialtissuepy/topology/summary_metrics.py index 4b08555..517e456 100644 --- a/spatialtissuepy/topology/summary_metrics.py +++ b/spatialtissuepy/topology/summary_metrics.py @@ -5,7 +5,8 @@ for standardized computation across samples. """ -from typing import Dict, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict + import numpy as np from spatialtissuepy.summary.registry import register_metric @@ -13,6 +14,8 @@ if TYPE_CHECKING: from spatialtissuepy.core import SpatialTissueData + from .mapper import MapperResult + # Store fitted results for reuse within a session _result_cache: Dict[str, 'MapperResult'] = {} @@ -36,14 +39,14 @@ def _mapper_n_nodes( ) -> Dict[str, float]: """Compute number of Mapper nodes.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + return {'mapper_n_nodes': float(result.n_nodes)} @@ -61,14 +64,14 @@ def _mapper_n_components( ) -> Dict[str, float]: """Compute number of connected components.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + return {'mapper_n_components': float(result.n_components)} @@ -86,16 +89,16 @@ def _mapper_density( ) -> Dict[str, float]: """Compute Mapper graph density.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + stats = result.statistics - + return {'mapper_density': float(stats.get('density', 0))} @@ -113,16 +116,16 @@ def _mapper_summary( ) -> Dict[str, float]: """Compute comprehensive Mapper statistics.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + stats = result.statistics - + output = { 'mapper_n_nodes': float(result.n_nodes), 'mapper_n_edges': float(result.n_edges), @@ -131,11 +134,11 @@ def _mapper_summary( 'mapper_mean_node_size': float(stats.get('mean_node_size', 0)), 'mapper_density': float(stats.get('density', 0)), } - + # Node coverage coverage = len(result.cell_node_map) / data.n_cells if data.n_cells > 0 else 0 output['mapper_coverage'] = coverage - + return output @@ -153,16 +156,16 @@ def _mapper_clustering( ) -> Dict[str, float]: """Compute Mapper average clustering coefficient.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + stats = result.statistics - + return {'mapper_avg_clustering': float(stats.get('avg_clustering', 0))} @@ -185,14 +188,14 @@ def _mapper_spatial_filter( """Compute Mapper with spatial x-coordinate filter.""" from .mapper import SpatialMapper from .spatial_filters import spatial_coordinate_filter - + mapper = SpatialMapper( filter_fn=spatial_coordinate_filter('x'), n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + return { 'mapper_spatial_n_nodes': float(result.n_nodes), 'mapper_spatial_n_components': float(result.n_components), @@ -215,21 +218,21 @@ def _mapper_distance_to_type( """Compute Mapper with distance-to-type filter.""" from .mapper import SpatialMapper from .spatial_filters import distance_to_type_filter - + # Check if cell type exists if cell_type not in data.cell_types_unique: return { f'mapper_dist_{cell_type}_n_nodes': np.nan, f'mapper_dist_{cell_type}_n_components': np.nan, } - + mapper = SpatialMapper( filter_fn=distance_to_type_filter(cell_type), n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + return { f'mapper_dist_{cell_type}_n_nodes': float(result.n_nodes), f'mapper_dist_{cell_type}_n_components': float(result.n_components), @@ -254,14 +257,14 @@ def _mapper_largest_component( ) -> Dict[str, float]: """Compute largest component statistics.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + if result.graph is not None and result.n_nodes > 0: import networkx as nx components = list(nx.connected_components(result.graph)) @@ -274,7 +277,7 @@ def _mapper_largest_component( else: largest_size = 0 ratio = 0 - + return { 'mapper_largest_component_nodes': float(largest_size), 'mapper_component_ratio': float(ratio), @@ -299,14 +302,14 @@ def _mapper_node_size_stats( ) -> Dict[str, float]: """Compute node size distribution statistics.""" from .mapper import SpatialMapper - + mapper = SpatialMapper( filter_fn='density', n_intervals=n_intervals, overlap=overlap, ) result = mapper.fit(data, neighborhood_radius=radius) - + if result.nodes: sizes = [node.size for node in result.nodes] return { diff --git a/spatialtissuepy/topology/visualization.py b/spatialtissuepy/topology/visualization.py index fb4b00e..a1f26b7 100644 --- a/spatialtissuepy/topology/visualization.py +++ b/spatialtissuepy/topology/visualization.py @@ -7,7 +7,7 @@ Example migration: # Old from spatialtissuepy.topology.visualization import plot_mapper_graph - + # New (recommended) from spatialtissuepy.viz import plot_mapper_graph """ @@ -16,17 +16,17 @@ # Re-export from viz module for backward compatibility from spatialtissuepy.viz.mapper import ( + create_mapper_report, + plot_filter_distribution, + plot_mapper_diagnostics, plot_mapper_graph, plot_mapper_spatial, - plot_filter_distribution, plot_node_composition, - plot_mapper_diagnostics, - create_mapper_report, ) __all__ = [ 'plot_mapper_graph', - 'plot_mapper_spatial', + 'plot_mapper_spatial', 'plot_filter_distribution', 'plot_node_composition', 'plot_mapper_diagnostics', diff --git a/spatialtissuepy/utils/__init__.py b/spatialtissuepy/utils/__init__.py index 4fec11c..e2171a0 100644 --- a/spatialtissuepy/utils/__init__.py +++ b/spatialtissuepy/utils/__init__.py @@ -3,13 +3,13 @@ """ from spatialtissuepy.utils.metrics import ( + jaccard_index, shannon_entropy, simpson_diversity, - jaccard_index, ) __all__ = [ "shannon_entropy", - "simpson_diversity", + "simpson_diversity", "jaccard_index", ] diff --git a/spatialtissuepy/utils/metrics.py b/spatialtissuepy/utils/metrics.py index 04361fa..c413c11 100644 --- a/spatialtissuepy/utils/metrics.py +++ b/spatialtissuepy/utils/metrics.py @@ -2,9 +2,10 @@ Common metrics and calculations for spatial analysis. """ -import numpy as np from typing import Union +import numpy as np + def shannon_entropy( counts: Union[np.ndarray, list], @@ -34,19 +35,19 @@ def shannon_entropy( """ counts = np.asarray(counts, dtype=float) counts = counts[counts > 0] # Remove zeros - + if len(counts) == 0: return 0.0 - + # Convert to proportions props = counts / counts.sum() - + # Calculate entropy: -sum(p * log(p)) entropy = -np.sum(props * np.log(props)) - + if normalize and len(counts) > 1: entropy = entropy / np.log(len(counts)) - + return float(entropy) @@ -78,14 +79,14 @@ def simpson_diversity( """ counts = np.asarray(counts, dtype=float) total = counts.sum() - + if total <= 1: return 0.0 - + # D = 1 - sum(n_i * (n_i - 1)) / (N * (N - 1)) numerator = np.sum(counts * (counts - 1)) denominator = total * (total - 1) - + return float(1 - numerator / denominator) @@ -113,13 +114,13 @@ def jaccard_index( """ set_a = set(set_a) set_b = set(set_b) - + intersection = len(set_a & set_b) union = len(set_a | set_b) - + if union == 0: return 0.0 - + return float(intersection / union) @@ -166,27 +167,27 @@ def normalize_counts( Normalized data. """ counts = np.asarray(counts, dtype=float) - + if counts.ndim == 1: counts = counts.reshape(1, -1) - + if method == 'proportion': row_sums = counts.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # Avoid division by zero return counts / row_sums - + elif method == 'zscore': mean = counts.mean(axis=0, keepdims=True) std = counts.std(axis=0, keepdims=True) std[std == 0] = 1 return (counts - mean) / std - + elif method == 'minmax': min_val = counts.min(axis=0, keepdims=True) max_val = counts.max(axis=0, keepdims=True) range_val = max_val - min_val range_val[range_val == 0] = 1 return (counts - min_val) / range_val - + else: raise ValueError(f"Unknown normalization method: {method}") diff --git a/spatialtissuepy/viz/__init__.py b/spatialtissuepy/viz/__init__.py index 7ceb0f5..6aae241 100644 --- a/spatialtissuepy/viz/__init__.py +++ b/spatialtissuepy/viz/__init__.py @@ -46,85 +46,84 @@ """ # Configuration and themes -from .config import ( - set_publication_style, - set_default_style, - get_cell_type_colors, - get_categorical_palette, - get_sequential_cmap, - get_diverging_cmap, - save_figure, - PlotConfig, -) - -# Spatial plots -from .spatial import ( - plot_spatial_scatter, - plot_cell_types, - plot_marker_expression, - plot_density_map, - plot_voronoi, - plot_spatial_domains, - plot_cell_neighborhoods, -) - -# Network plots -from .network import ( - plot_cell_graph, - plot_graph_on_tissue, - plot_degree_distribution, - plot_centrality_by_type, - plot_type_mixing_matrix, -) - -# Statistics plots -from .statistics import ( - plot_ripleys_curve, - plot_pcf_curve, - plot_colocalization_heatmap, - plot_neighborhood_enrichment, - plot_hotspot_map, - plot_morans_scatter, -) - # Comparison plots from .comparison import ( plot_metric_comparison, plot_metric_heatmap, - plot_violin_comparison, plot_pca_samples, - plot_umap_samples, plot_sample_correlation, plot_trajectory, + plot_umap_samples, + plot_violin_comparison, +) +from .config import ( + PlotConfig, + get_categorical_palette, + get_cell_type_colors, + get_diverging_cmap, + get_sequential_cmap, + save_figure, + set_default_style, + set_publication_style, ) # LDA plots from .lda import ( + plot_lda_diagnostics, plot_topic_composition, - plot_topic_spatial, plot_topic_enrichment_heatmap, - plot_topic_transition_matrix, - plot_lda_diagnostics, plot_topic_proportions_bar, + plot_topic_spatial, + plot_topic_transition_matrix, ) # Mapper/TDA plots from .mapper import ( + create_mapper_report, + plot_filter_distribution, + plot_mapper_diagnostics, plot_mapper_graph, plot_mapper_spatial, - plot_filter_distribution, plot_node_composition, - plot_mapper_diagnostics, - create_mapper_report, +) + +# Network plots +from .network import ( + plot_cell_graph, + plot_centrality_by_type, + plot_degree_distribution, + plot_graph_on_tissue, + plot_type_mixing_matrix, ) # Quality control plots from .qc import ( plot_cell_count_summary, - plot_spatial_coverage, + plot_convergence, plot_model_selection, + plot_spatial_coverage, plot_stability_analysis, - plot_convergence, +) + +# Spatial plots +from .spatial import ( + plot_cell_neighborhoods, + plot_cell_types, + plot_density_map, + plot_marker_expression, + plot_spatial_domains, + plot_spatial_scatter, + plot_voronoi, +) + +# Statistics plots +from .statistics import ( + plot_colocalization_heatmap, + plot_hotspot_map, + plot_morans_scatter, + plot_neighborhood_enrichment, + plot_pcf_curve, + plot_ripleys_curve, ) __all__ = [ diff --git a/spatialtissuepy/viz/comparison.py b/spatialtissuepy/viz/comparison.py index 5878fae..36fc0e9 100644 --- a/spatialtissuepy/viz/comparison.py +++ b/spatialtissuepy/viz/comparison.py @@ -6,13 +6,17 @@ """ from __future__ import annotations + from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np import pandas as pd from .config import ( - get_axes, get_categorical_palette, get_sequential_cmap, - get_diverging_cmap, despine, _check_matplotlib + _check_matplotlib, + despine, + get_axes, + get_categorical_palette, ) if TYPE_CHECKING: @@ -27,12 +31,12 @@ def plot_metric_comparison( colors: Optional[Dict[str, str]] = None, show_points: bool = True, order: Optional[List[str]] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot metric comparison across groups (box/violin/bar plot). - + Parameters ---------- df : pd.DataFrame @@ -53,71 +57,70 @@ def plot_metric_comparison( Matplotlib axes. **kwargs Additional arguments to seaborn plot function. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if metric not in df.columns: raise ValueError(f"Metric '{metric}' not found in DataFrame") if group_by not in df.columns: raise ValueError(f"Group column '{group_by}' not found in DataFrame") - + groups = df[group_by].unique() if order is None else order n_groups = len(groups) - + if colors is None: palette = get_categorical_palette(n_groups) colors = {g: palette[i] for i, g in enumerate(groups)} - + # Prepare data data_by_group = [df[df[group_by] == g][metric].dropna().values for g in groups] positions = range(n_groups) - + if kind == 'box': bp = ax.boxplot(data_by_group, positions=positions, patch_artist=True, **kwargs) for patch, group in zip(bp['boxes'], groups): patch.set_facecolor(colors.get(group, '#888888')) patch.set_alpha(0.7) - + elif kind == 'violin': parts = ax.violinplot(data_by_group, positions=positions, showmeans=True, **kwargs) for i, (pc, group) in enumerate(zip(parts['bodies'], groups)): pc.set_facecolor(colors.get(group, '#888888')) pc.set_alpha(0.7) - + elif kind == 'bar': means = [np.mean(d) for d in data_by_group] stds = [np.std(d) for d in data_by_group] - bars = ax.bar(positions, means, yerr=stds, capsize=3, + ax.bar(positions, means, yerr=stds, capsize=3, color=[colors.get(g, '#888888') for g in groups], alpha=0.7, **kwargs) - + elif kind == 'strip': for i, (data, group) in enumerate(zip(data_by_group, groups)): jitter = np.random.uniform(-0.2, 0.2, len(data)) - ax.scatter(np.full(len(data), i) + jitter, data, + ax.scatter(np.full(len(data), i) + jitter, data, c=colors.get(group, '#888888'), alpha=0.6, s=20, **kwargs) - + # Overlay points for box/violin if show_points and kind in ['box', 'violin']: for i, (data, group) in enumerate(zip(data_by_group, groups)): jitter = np.random.uniform(-0.15, 0.15, len(data)) - ax.scatter(np.full(len(data), i) + jitter, data, + ax.scatter(np.full(len(data), i) + jitter, data, c='black', alpha=0.4, s=10, zorder=3) - + ax.set_xticks(positions) ax.set_xticklabels(groups, rotation=45, ha='right') ax.set_xlabel(group_by) ax.set_ylabel(metric) ax.set_title(f'{metric} by {group_by}') despine(ax) - + return ax @@ -132,10 +135,10 @@ def plot_metric_heatmap( annot: bool = False, figsize: Tuple[float, float] = (12, 8), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Plot heatmap of metrics across samples. - + Parameters ---------- df : pd.DataFrame @@ -158,7 +161,7 @@ def plot_metric_heatmap( Figure size. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Figure @@ -168,13 +171,13 @@ def plot_metric_heatmap( import matplotlib.pyplot as plt from scipy.cluster import hierarchy from scipy.spatial.distance import pdist - + # Select metrics if metrics is None: metrics = df.select_dtypes(include=[np.number]).columns.tolist() if sample_col in metrics: metrics.remove(sample_col) - + # Prepare data matrix if sample_col in df.columns: sample_labels = df[sample_col].values @@ -182,41 +185,41 @@ def plot_metric_heatmap( else: sample_labels = np.arange(len(df)) data_matrix = df[metrics].values - + # Standardize if standardize: data_matrix = (data_matrix - np.nanmean(data_matrix, axis=0)) / np.nanstd(data_matrix, axis=0) - + # Handle NaN data_matrix = np.nan_to_num(data_matrix, nan=0) - + # Clustering if cluster_rows and data_matrix.shape[0] > 2: row_linkage = hierarchy.linkage(pdist(data_matrix), method='ward') row_order = hierarchy.leaves_list(row_linkage) else: row_order = np.arange(data_matrix.shape[0]) - + if cluster_cols and data_matrix.shape[1] > 2: col_linkage = hierarchy.linkage(pdist(data_matrix.T), method='ward') col_order = hierarchy.leaves_list(col_linkage) else: col_order = np.arange(data_matrix.shape[1]) - + # Reorder data_ordered = data_matrix[row_order][:, col_order] metrics_ordered = [metrics[i] for i in col_order] samples_ordered = sample_labels[row_order] - + # Create figure fig, ax = plt.subplots(figsize=figsize) - + # Determine color limits vmax = np.percentile(np.abs(data_ordered), 95) - + im = ax.imshow(data_ordered, cmap=cmap, aspect='auto', vmin=-vmax, vmax=vmax, **kwargs) plt.colorbar(im, ax=ax, label='Z-score' if standardize else 'Value') - + # Add annotations if annot: for i in range(data_ordered.shape[0]): @@ -224,7 +227,7 @@ def plot_metric_heatmap( value = data_ordered[i, j] color = 'white' if abs(value) > vmax * 0.5 else 'black' ax.text(j, i, f'{value:.2f}', ha='center', va='center', color=color, fontsize=6) - + ax.set_xticks(range(len(metrics_ordered))) ax.set_xticklabels(metrics_ordered, rotation=90, ha='center') ax.set_yticks(range(len(samples_ordered))) @@ -232,9 +235,9 @@ def plot_metric_heatmap( ax.set_xlabel('Metric') ax.set_ylabel('Sample') ax.set_title('Sample-Metric Heatmap') - + fig.tight_layout() - + return fig @@ -245,10 +248,10 @@ def plot_violin_comparison( ncols: int = 3, figsize_per_panel: Tuple[float, float] = (4, 4), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create faceted violin plots for multiple metrics. - + Parameters ---------- df : pd.DataFrame @@ -263,7 +266,7 @@ def plot_violin_comparison( Size per panel. **kwargs Additional arguments to violinplot. - + Returns ------- plt.Figure @@ -271,48 +274,48 @@ def plot_violin_comparison( """ _check_matplotlib() import matplotlib.pyplot as plt - + n_metrics = len(metrics) nrows = int(np.ceil(n_metrics / ncols)) - + fig, axes = plt.subplots( nrows, ncols, figsize=(figsize_per_panel[0] * ncols, figsize_per_panel[1] * nrows), squeeze=False ) - + groups = sorted(df[group_by].unique()) n_groups = len(groups) palette = get_categorical_palette(n_groups) colors = {g: palette[i] for i, g in enumerate(groups)} - + for idx, metric in enumerate(metrics): row = idx // ncols col = idx % ncols ax = axes[row, col] - + if metric not in df.columns: ax.text(0.5, 0.5, f'{metric}\nnot found', ha='center', va='center') continue - + data_by_group = [df[df[group_by] == g][metric].dropna().values for g in groups] - + parts = ax.violinplot(data_by_group, showmeans=True, **kwargs) for i, (pc, group) in enumerate(zip(parts['bodies'], groups)): pc.set_facecolor(colors[group]) pc.set_alpha(0.7) - + ax.set_xticks(range(1, n_groups + 1)) ax.set_xticklabels(groups, rotation=45, ha='right') ax.set_title(metric) despine(ax) - + # Hide empty panels for idx in range(n_metrics, nrows * ncols): axes[idx // ncols, idx % ncols].set_visible(False) - + fig.tight_layout() - + return fig @@ -323,12 +326,12 @@ def plot_pca_samples( label_col: Optional[str] = None, n_components: int = 2, colors: Optional[Dict[str, str]] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot PCA of samples based on metrics. - + Parameters ---------- df : pd.DataFrame @@ -347,59 +350,58 @@ def plot_pca_samples( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler - + ax = get_axes(ax) - + # Select metrics if metrics is None: metrics = df.select_dtypes(include=[np.number]).columns.tolist() - + # Prepare data X = df[metrics].values X = np.nan_to_num(X, nan=0) - + # Standardize and PCA scaler = StandardScaler() X_scaled = scaler.fit_transform(X) - + pca = PCA(n_components=n_components) X_pca = pca.fit_transform(X_scaled) - + # Plot if color_by is not None and color_by in df.columns: groups = df[color_by].unique() if colors is None: palette = get_categorical_palette(len(groups)) colors = {g: palette[i] for i, g in enumerate(groups)} - + for group in groups: mask = df[color_by] == group - ax.scatter(X_pca[mask, 0], X_pca[mask, 1], + ax.scatter(X_pca[mask, 0], X_pca[mask, 1], c=colors.get(group, '#888888'), label=group, **kwargs) ax.legend(frameon=False) else: ax.scatter(X_pca[:, 0], X_pca[:, 1], **kwargs) - + # Add labels if label_col is not None and label_col in df.columns: for i, label in enumerate(df[label_col]): ax.annotate(str(label), (X_pca[i, 0], X_pca[i, 1]), fontsize=8, alpha=0.7) - + ax.set_xlabel(f'PC1 ({pca.explained_variance_ratio_[0]*100:.1f}%)') ax.set_ylabel(f'PC2 ({pca.explained_variance_ratio_[1]*100:.1f}%)') ax.set_title('PCA of Samples') despine(ax) - + return ax @@ -411,12 +413,12 @@ def plot_umap_samples( n_neighbors: int = 15, min_dist: float = 0.1, colors: Optional[Dict[str, str]] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot UMAP of samples based on metrics. - + Parameters ---------- df : pd.DataFrame @@ -437,46 +439,45 @@ def plot_umap_samples( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + try: import umap except ImportError: raise ImportError("umap-learn required for UMAP plots. Install with: pip install umap-learn") - + from sklearn.preprocessing import StandardScaler - + ax = get_axes(ax) - + # Select metrics if metrics is None: metrics = df.select_dtypes(include=[np.number]).columns.tolist() - + # Prepare data X = df[metrics].values X = np.nan_to_num(X, nan=0) - + # Standardize and UMAP scaler = StandardScaler() X_scaled = scaler.fit_transform(X) - + reducer = umap.UMAP(n_neighbors=n_neighbors, min_dist=min_dist, random_state=42) X_umap = reducer.fit_transform(X_scaled) - + # Plot if color_by is not None and color_by in df.columns: groups = df[color_by].unique() if colors is None: palette = get_categorical_palette(len(groups)) colors = {g: palette[i] for i, g in enumerate(groups)} - + for group in groups: mask = df[color_by] == group ax.scatter(X_umap[mask, 0], X_umap[mask, 1], @@ -484,17 +485,17 @@ def plot_umap_samples( ax.legend(frameon=False) else: ax.scatter(X_umap[:, 0], X_umap[:, 1], **kwargs) - + # Add labels if label_col is not None and label_col in df.columns: for i, label in enumerate(df[label_col]): ax.annotate(str(label), (X_umap[i, 0], X_umap[i, 1]), fontsize=8, alpha=0.7) - + ax.set_xlabel('UMAP1') ax.set_ylabel('UMAP2') ax.set_title('UMAP of Samples') despine(ax) - + return ax @@ -504,12 +505,12 @@ def plot_sample_correlation( method: str = 'pearson', cmap: str = 'RdBu_r', annot: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot correlation matrix between samples. - + Parameters ---------- df : pd.DataFrame @@ -526,7 +527,7 @@ def plot_sample_correlation( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -534,21 +535,21 @@ def plot_sample_correlation( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + # Select metrics if metrics is None: metrics = df.select_dtypes(include=[np.number]).columns.tolist() - + # Compute correlation between samples (transpose to get samples as columns) data = df[metrics].T corr = data.corr(method=method) - + # Plot heatmap im = ax.imshow(corr, cmap=cmap, vmin=-1, vmax=1, **kwargs) plt.colorbar(im, ax=ax, label='Correlation') - + # Add annotations if annot: for i in range(len(corr)): @@ -556,13 +557,13 @@ def plot_sample_correlation( value = corr.iloc[i, j] color = 'white' if abs(value) > 0.5 else 'black' ax.text(j, i, f'{value:.2f}', ha='center', va='center', color=color, fontsize=8) - + ax.set_xticks(range(len(corr))) ax.set_yticks(range(len(corr))) ax.set_xticklabels(corr.columns, rotation=45, ha='right') ax.set_yticklabels(corr.index) ax.set_title(f'Sample Correlation ({method})') - + return ax @@ -575,12 +576,12 @@ def plot_trajectory( show_points: bool = True, show_error: bool = True, error_type: str = 'std', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot metric trajectories over time or another continuous variable. - + Parameters ---------- df : pd.DataFrame @@ -603,30 +604,29 @@ def plot_trajectory( Matplotlib axes. **kwargs Additional arguments to plot(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if isinstance(y_cols, str): y_cols = [y_cols] - + # Determine groups if group_by is not None: groups = sorted(df[group_by].unique()) else: groups = ['all'] - + if colors is None: palette = get_categorical_palette(len(groups) * len(y_cols)) color_idx = 0 - + for y_col in y_cols: for group in groups: if group_by is not None: @@ -636,29 +636,29 @@ def plot_trajectory( else: subset = df label = y_col - + x = subset[x_col].values y = subset[y_col].values - + # Get color if colors is not None: color = colors.get(group, colors.get(y_col, None)) else: color = palette[color_idx] color_idx += 1 - + # Plot line sort_idx = np.argsort(x) ax.plot(x[sort_idx], y[sort_idx], color=color, label=label, **kwargs) - + # Show points if show_points: ax.scatter(x, y, color=color, s=20, alpha=0.6) - + ax.set_xlabel(x_col) ax.set_ylabel(y_cols[0] if len(y_cols) == 1 else 'Value') ax.set_title('Trajectory') ax.legend(frameon=False) despine(ax) - + return ax diff --git a/spatialtissuepy/viz/config.py b/spatialtissuepy/viz/config.py index 20a2ad5..41e999f 100644 --- a/spatialtissuepy/viz/config.py +++ b/spatialtissuepy/viz/config.py @@ -7,10 +7,17 @@ """ from __future__ import annotations -from typing import Dict, List, Optional, Tuple, Union, Any + from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np +if TYPE_CHECKING: + # Annotations only; matplotlib stays an optional runtime dependency and is + # imported lazily inside the functions that need it. + import matplotlib.pyplot as plt + # Lazy imports for matplotlib _MPL_AVAILABLE = None @@ -20,7 +27,7 @@ def _check_matplotlib(): global _MPL_AVAILABLE if _MPL_AVAILABLE is None: try: - import matplotlib + import matplotlib # noqa: F401 (optional dependency probe) _MPL_AVAILABLE = True except ImportError: _MPL_AVAILABLE = False @@ -51,19 +58,19 @@ def _check_matplotlib(): 'NK_cell': '#8c564b', # Brown 'Neutrophil': '#e377c2', # Pink 'Monocyte': '#7f7f7f', # Gray - + # Tumor cells 'Tumor': '#d62728', # Red 'Cancer': '#d62728', 'Tumor_proliferating': '#ff9896', 'Tumor_hypoxic': '#8b0000', - + # Stromal cells 'Fibroblast': '#bcbd22', # Yellow-green 'CAF': '#bcbd22', 'Endothelial': '#17becf', # Cyan 'Epithelial': '#98df8a', # Light green - + # Other 'Unknown': '#c7c7c7', 'Other': '#c7c7c7', @@ -115,7 +122,7 @@ def _check_matplotlib(): class PlotConfig: """ Configuration class for plot styling. - + Attributes ---------- figsize : tuple @@ -152,23 +159,23 @@ class PlotConfig: line_width: float = 1.5 marker_size: float = 20 alpha: float = 0.7 - + # Color settings cell_type_colors: Dict[str, str] = field(default_factory=lambda: CELL_TYPE_COLORS.copy()) categorical_palette: str = 'default' sequential_cmap: str = 'viridis' diverging_cmap: str = 'RdBu_r' - + # Grid and spines show_grid: bool = False grid_alpha: float = 0.3 despine: bool = True - + def apply(self): """Apply this configuration to matplotlib.""" _check_matplotlib() import matplotlib.pyplot as plt - + plt.rcParams.update({ 'figure.figsize': self.figsize, 'figure.dpi': self.dpi, @@ -212,14 +219,14 @@ def set_publication_style( ) -> PlotConfig: """ Set matplotlib style for publication-quality figures. - + Parameters ---------- journal : str, default 'default' Target journal style: 'default', 'nature', 'science', 'cell'. column_width : str, default 'single' Column width: 'single', 'double', 'full'. - + Returns ------- PlotConfig @@ -227,7 +234,7 @@ def set_publication_style( """ _check_matplotlib() import matplotlib.pyplot as plt - + # Journal-specific widths (inches) widths = { 'default': {'single': 3.5, 'double': 7.0, 'full': 7.5}, @@ -235,10 +242,10 @@ def set_publication_style( 'science': {'single': 3.4, 'double': 7.0, 'full': 7.0}, 'cell': {'single': 3.3, 'double': 6.9, 'full': 7.0}, } - + width = widths.get(journal, widths['default']).get(column_width, 3.5) height = width * 0.75 # Default aspect ratio - + config = PlotConfig( figsize=(width, height), dpi=300, @@ -254,9 +261,9 @@ def set_publication_style( show_grid=False, despine=True, ) - + set_config(config) - + # Additional matplotlib settings for publication plt.rcParams.update({ 'pdf.fonttype': 42, # TrueType fonts for PDF @@ -268,14 +275,14 @@ def set_publication_style( 'xtick.major.size': 3, 'ytick.major.size': 3, }) - + return config def set_default_style() -> PlotConfig: """ Reset to default plotting style. - + Returns ------- PlotConfig @@ -283,26 +290,26 @@ def set_default_style() -> PlotConfig: """ _check_matplotlib() import matplotlib.pyplot as plt - + plt.rcdefaults() - + config = PlotConfig() set_config(config) - + return config def set_presentation_style() -> PlotConfig: """ Set style for presentations (larger fonts, bolder lines). - + Returns ------- PlotConfig Applied configuration. """ _check_matplotlib() - + config = PlotConfig( figsize=(10, 7), dpi=150, @@ -317,9 +324,9 @@ def set_presentation_style() -> PlotConfig: show_grid=False, despine=True, ) - + set_config(config) - + return config @@ -333,28 +340,28 @@ def get_cell_type_colors( ) -> Dict[str, str]: """ Get color mapping for cell types. - + Parameters ---------- cell_types : list of str, optional Cell types to get colors for. If None, returns all known colors. palette : str, default 'default' Palette to use for unknown cell types. - + Returns ------- dict Mapping from cell type to hex color. """ colors = _current_config.cell_type_colors.copy() - + if cell_types is None: return colors - + # Add colors for unknown types palette_colors = CATEGORICAL_PALETTES.get(palette, CATEGORICAL_PALETTES['default']) unknown_idx = 0 - + result = {} for ct in cell_types: if ct in colors: @@ -362,7 +369,7 @@ def get_cell_type_colors( else: result[ct] = palette_colors[unknown_idx % len(palette_colors)] unknown_idx += 1 - + return result @@ -372,29 +379,29 @@ def get_categorical_palette( ) -> List[str]: """ Get a categorical color palette. - + Parameters ---------- n_colors : int Number of colors needed. palette : str, default 'default' Palette name: 'default', 'colorblind', 'pastel', 'dark'. - + Returns ------- list of str List of hex colors. """ colors = CATEGORICAL_PALETTES.get(palette, CATEGORICAL_PALETTES['default']) - + if n_colors <= len(colors): return colors[:n_colors] - + # Extend by cycling extended = [] for i in range(n_colors): extended.append(colors[i % len(colors)]) - + return extended @@ -403,12 +410,12 @@ def get_sequential_cmap( ) -> str: """ Get a sequential colormap name. - + Parameters ---------- name : str, default 'density' Type of data: 'density', 'expression', 'distance', 'enrichment', 'count'. - + Returns ------- str @@ -422,12 +429,12 @@ def get_diverging_cmap( ) -> str: """ Get a diverging colormap name. - + Parameters ---------- name : str, default 'correlation' Type of data: 'correlation', 'log_fold_change', 'residual', 'difference'. - + Returns ------- str @@ -445,10 +452,10 @@ def create_figure( ncols: int = 1, figsize: Optional[Tuple[float, float]] = None, **kwargs -) -> Tuple['plt.Figure', Union['plt.Axes', np.ndarray]]: +) -> Tuple[plt.Figure, Union[plt.Axes, np.ndarray]]: """ Create a figure with consistent styling. - + Parameters ---------- nrows : int, default 1 @@ -459,7 +466,7 @@ def create_figure( Figure size. If None, uses config default scaled by grid. **kwargs Additional arguments to plt.subplots(). - + Returns ------- fig : plt.Figure @@ -469,18 +476,18 @@ def create_figure( """ _check_matplotlib() import matplotlib.pyplot as plt - + if figsize is None: base = _current_config.figsize figsize = (base[0] * ncols, base[1] * nrows) - + fig, axes = plt.subplots(nrows, ncols, figsize=figsize, **kwargs) - + return fig, axes def save_figure( - fig: 'plt.Figure', + fig: plt.Figure, filename: str, formats: List[str] = None, dpi: Optional[int] = None, @@ -490,7 +497,7 @@ def save_figure( ): """ Save figure in multiple formats. - + Parameters ---------- fig : plt.Figure @@ -509,13 +516,13 @@ def save_figure( Additional arguments to fig.savefig(). """ _check_matplotlib() - + if formats is None: formats = ['pdf', 'png'] - + if dpi is None: dpi = _current_config.dpi - + for fmt in formats: output_path = f"{filename}.{fmt}" fig.savefig( @@ -529,7 +536,7 @@ def save_figure( def despine( - ax: 'plt.Axes', + ax: plt.Axes, left: bool = False, bottom: bool = False, right: bool = True, @@ -537,7 +544,7 @@ def despine( ): """ Remove spines from axes. - + Parameters ---------- ax : plt.Axes @@ -545,13 +552,13 @@ def despine( left, bottom, right, top : bool Which spines to remove. """ - for spine, remove in [('left', left), ('bottom', bottom), + for spine, remove in [('left', left), ('bottom', bottom), ('right', right), ('top', top)]: ax.spines[spine].set_visible(not remove) def add_scalebar( - ax: 'plt.Axes', + ax: plt.Axes, length: float, unit: str = 'µm', location: str = 'lower right', @@ -560,7 +567,7 @@ def add_scalebar( ): """ Add a scale bar to spatial plots. - + Parameters ---------- ax : plt.Axes @@ -578,33 +585,32 @@ def add_scalebar( """ _check_matplotlib() from matplotlib.patches import Rectangle - from matplotlib.offsetbox import AnchoredOffsetbox, AuxTransformBox - + if fontsize is None: fontsize = _current_config.tick_size - + # Get axes limits xlim = ax.get_xlim() ylim = ax.get_ylim() - + # Position based on location if 'lower' in location: y = ylim[0] + 0.05 * (ylim[1] - ylim[0]) else: y = ylim[1] - 0.1 * (ylim[1] - ylim[0]) - + if 'right' in location: x = xlim[1] - 0.05 * (xlim[0] - xlim[0]) - length else: x = xlim[0] + 0.05 * (xlim[1] - xlim[0]) - + # Draw scale bar bar_height = 0.01 * (ylim[1] - ylim[0]) ax.add_patch(Rectangle( (x, y), length, bar_height, facecolor=color, edgecolor=color )) - + # Add label ax.text( x + length / 2, y - bar_height * 2, @@ -615,19 +621,19 @@ def add_scalebar( def get_axes( - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, figsize: Optional[Tuple[float, float]] = None -) -> 'plt.Axes': +) -> plt.Axes: """ Get or create axes for plotting. - + Parameters ---------- ax : plt.Axes, optional Existing axes. If None, creates new figure. figsize : tuple, optional Figure size if creating new figure. - + Returns ------- plt.Axes @@ -635,10 +641,10 @@ def get_axes( """ _check_matplotlib() import matplotlib.pyplot as plt - + if ax is None: if figsize is None: figsize = _current_config.figsize fig, ax = plt.subplots(figsize=figsize) - + return ax diff --git a/spatialtissuepy/viz/lda.py b/spatialtissuepy/viz/lda.py index e40bc23..2362288 100644 --- a/spatialtissuepy/viz/lda.py +++ b/spatialtissuepy/viz/lda.py @@ -7,32 +7,36 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +from typing import TYPE_CHECKING, Optional, Tuple + import numpy as np -import pandas as pd from .config import ( - get_axes, get_categorical_palette, get_sequential_cmap, - get_diverging_cmap, despine, _check_matplotlib + _check_matplotlib, + despine, + get_axes, + get_categorical_palette, ) if TYPE_CHECKING: + import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData from spatialtissuepy.lda import SpatialLDA - import matplotlib.pyplot as plt def plot_topic_composition( - model: 'SpatialLDA', + model: SpatialLDA, n_top: int = 5, normalize: bool = True, cmap: str = 'viridis', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot topic-cell type composition matrix. - + Parameters ---------- model : SpatialLDA @@ -47,7 +51,7 @@ def plot_topic_composition( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -55,22 +59,22 @@ def plot_topic_composition( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + # Get topic-word matrix (topics x cell types) topic_word = model._lda_model.components_ - + if normalize: topic_word = topic_word / topic_word.sum(axis=1, keepdims=True) - + # Get cell type names cell_types = model.cell_types_ - + # Plot heatmap im = ax.imshow(topic_word, cmap=cmap, aspect='auto', **kwargs) plt.colorbar(im, ax=ax, label='Weight' if not normalize else 'Proportion') - + ax.set_yticks(range(model.n_topics)) ax.set_yticklabels([f'Topic {i}' for i in range(model.n_topics)]) ax.set_xticks(range(len(cell_types))) @@ -78,23 +82,23 @@ def plot_topic_composition( ax.set_xlabel('Cell Type') ax.set_ylabel('Topic') ax.set_title('Topic-Cell Type Composition') - + return ax def plot_topic_spatial( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, topic: int = 0, size: float = 5, cmap: str = 'viridis', show_colorbar: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot spatial distribution of a single topic. - + Parameters ---------- model : SpatialLDA @@ -113,7 +117,7 @@ def plot_topic_spatial( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes @@ -121,47 +125,47 @@ def plot_topic_spatial( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + # Get topic weights topic_weights = model.transform(data) - + if topic >= topic_weights.shape[1]: raise ValueError(f"Topic {topic} not found. Model has {model.n_topics} topics.") - + coords = data._coordinates - + scatter = ax.scatter( coords[:, 0], coords[:, 1], c=topic_weights[:, topic], s=size, cmap=cmap, rasterized=True, **kwargs ) - + if show_colorbar: plt.colorbar(scatter, ax=ax, label=f'Topic {topic} weight') - + ax.set_xlabel('X (um)') ax.set_ylabel('Y (um)') ax.set_title(f'Topic {topic} Spatial Distribution') ax.set_aspect('equal') despine(ax) - + return ax def plot_topic_enrichment_heatmap( - model: 'SpatialLDA', + model: SpatialLDA, cmap: str = 'RdBu_r', center: float = 0.0, annot: bool = True, fmt: str = '.2f', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot topic-cell type enrichment heatmap (log2 fold change). - + Parameters ---------- model : SpatialLDA @@ -178,7 +182,7 @@ def plot_topic_enrichment_heatmap( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -186,22 +190,23 @@ def plot_topic_enrichment_heatmap( """ _check_matplotlib() import matplotlib.pyplot as plt + from spatialtissuepy.lda import topic_enrichment - + ax = get_axes(ax) - + # Compute enrichment enrichment = topic_enrichment(model) - + cell_types = model.cell_types_ - + # Determine color limits vmax = np.nanpercentile(np.abs(enrichment), 95) - + # Plot heatmap im = ax.imshow(enrichment, cmap=cmap, aspect='auto', vmin=-vmax, vmax=vmax, **kwargs) plt.colorbar(im, ax=ax, label='Log2 Fold Enrichment') - + # Add annotations if annot: for i in range(enrichment.shape[0]): @@ -211,7 +216,7 @@ def plot_topic_enrichment_heatmap( continue color = 'white' if abs(value) > vmax * 0.5 else 'black' ax.text(j, i, format(value, fmt), ha='center', va='center', color=color, fontsize=8) - + ax.set_yticks(range(model.n_topics)) ax.set_yticklabels([f'Topic {i}' for i in range(model.n_topics)]) ax.set_xticks(range(len(cell_types))) @@ -219,25 +224,25 @@ def plot_topic_enrichment_heatmap( ax.set_xlabel('Cell Type') ax.set_ylabel('Topic') ax.set_title('Topic-Cell Type Enrichment') - + return ax def plot_topic_transition_matrix( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, radius: float = 50.0, normalize: bool = True, cmap: str = 'Blues', annot: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot topic transition/co-occurrence matrix. - + Shows how often topics co-occur in neighboring cells. - + Parameters ---------- model : SpatialLDA @@ -256,7 +261,7 @@ def plot_topic_transition_matrix( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -265,35 +270,35 @@ def plot_topic_transition_matrix( _check_matplotlib() import matplotlib.pyplot as plt from scipy.spatial import cKDTree - + ax = get_axes(ax) - + # Get dominant topics dominant = model.predict(data) coords = data._coordinates - + # Build spatial neighbor graph tree = cKDTree(coords) - + # Count topic transitions n_topics = model.n_topics transition_matrix = np.zeros((n_topics, n_topics)) - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, radius) for j in neighbors: if i != j: transition_matrix[dominant[i], dominant[j]] += 1 - + if normalize: row_sums = transition_matrix.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 transition_matrix = transition_matrix / row_sums - + # Plot heatmap im = ax.imshow(transition_matrix, cmap=cmap, aspect='auto', **kwargs) plt.colorbar(im, ax=ax, label='Proportion' if normalize else 'Count') - + # Add annotations if annot: for i in range(n_topics): @@ -302,7 +307,7 @@ def plot_topic_transition_matrix( color = 'white' if value > transition_matrix.max() * 0.5 else 'black' fmt = '.2f' if normalize else '.0f' ax.text(j, i, format(value, fmt), ha='center', va='center', color=color, fontsize=8) - + ax.set_xticks(range(n_topics)) ax.set_yticks(range(n_topics)) ax.set_xticklabels([f'Topic {i}' for i in range(n_topics)]) @@ -310,19 +315,19 @@ def plot_topic_transition_matrix( ax.set_xlabel('Neighbor Topic') ax.set_ylabel('Cell Topic') ax.set_title('Topic Transition Matrix') - + return ax def plot_lda_diagnostics( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, figsize: Tuple[float, float] = (14, 10), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create comprehensive LDA diagnostic plots. - + Parameters ---------- model : SpatialLDA @@ -333,7 +338,7 @@ def plot_lda_diagnostics( Figure size. **kwargs Additional arguments. - + Returns ------- plt.Figure @@ -341,23 +346,23 @@ def plot_lda_diagnostics( """ _check_matplotlib() import matplotlib.pyplot as plt + from spatialtissuepy.lda import ( topic_assignment_uncertainty, - topic_spatial_distribution, ) - + fig, axes = plt.subplots(2, 3, figsize=figsize) - + # 1. Topic composition heatmap plot_topic_composition(model, ax=axes[0, 0]) - + # 2. Topic enrichment heatmap plot_topic_enrichment_heatmap(model, ax=axes[0, 1]) - + # 3. Topic prevalence bar chart topic_weights = model.transform(data) topic_means = topic_weights.mean(axis=0) - + ax = axes[0, 2] palette = get_categorical_palette(model.n_topics) ax.bar(range(model.n_topics), topic_means, color=palette) @@ -366,7 +371,7 @@ def plot_lda_diagnostics( ax.set_title('Topic Prevalence') ax.set_xticks(range(model.n_topics)) despine(ax) - + # 4. Assignment uncertainty distribution ax = axes[1, 0] uncertainty = topic_assignment_uncertainty(model, data) @@ -377,7 +382,7 @@ def plot_lda_diagnostics( ax.axvline(np.mean(uncertainty), color='red', linestyle='--', label=f'Mean={np.mean(uncertainty):.2f}') ax.legend(frameon=False) despine(ax) - + # 5. Dominant topic spatial plot ax = axes[1, 1] coords = data._coordinates @@ -389,17 +394,17 @@ def plot_lda_diagnostics( ax.set_aspect('equal') plt.colorbar(scatter, ax=ax, label='Topic') despine(ax) - + # 6. Perplexity info ax = axes[1, 2] ax.axis('off') - + try: perplexity = model.perplexity(data) perplexity_text = f'{perplexity:.2f}' except Exception: perplexity_text = 'N/A' - + info_text = [ 'Model Summary', '=' * 30, @@ -414,26 +419,26 @@ def plot_lda_diagnostics( ', '.join(model.cell_types_[:10]), '...' if len(model.cell_types_) > 10 else '', ] - + ax.text(0.1, 0.9, '\n'.join(info_text), transform=ax.transAxes, fontfamily='monospace', fontsize=9, verticalalignment='top') - + fig.tight_layout() - + return fig def plot_topic_proportions_bar( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, by_cell_type: bool = False, stacked: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot topic proportion bar chart. - + Parameters ---------- model : SpatialLDA @@ -448,34 +453,33 @@ def plot_topic_proportions_bar( Matplotlib axes. **kwargs Additional arguments to bar(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + topic_weights = model.transform(data) palette = get_categorical_palette(model.n_topics) - + if by_cell_type: # Group by cell type cell_types = data._cell_types unique_types = data.cell_types_unique n_types = len(unique_types) - + # Compute mean topic weights per cell type type_topic_means = np.zeros((n_types, model.n_topics)) for i, ct in enumerate(unique_types): mask = cell_types == ct type_topic_means[i] = topic_weights[mask].mean(axis=0) - + x = np.arange(n_types) - + if stacked: bottom = np.zeros(n_types) for t in range(model.n_topics): @@ -487,44 +491,44 @@ def plot_topic_proportions_bar( for t in range(model.n_topics): ax.bar(x + t * width, type_topic_means[:, t], width=width, label=f'Topic {t}', color=palette[t], **kwargs) - + ax.set_xticks(x + (0 if stacked else width * (model.n_topics - 1) / 2)) ax.set_xticklabels(unique_types, rotation=45, ha='right') ax.set_xlabel('Cell Type') - + else: # Overall topic proportions topic_means = topic_weights.mean(axis=0) topic_stds = topic_weights.std(axis=0) - + x = np.arange(model.n_topics) ax.bar(x, topic_means, yerr=topic_stds, capsize=3, color=palette[:model.n_topics], **kwargs) - + ax.set_xticks(x) ax.set_xticklabels([f'Topic {i}' for i in range(model.n_topics)]) ax.set_xlabel('Topic') - + ax.set_ylabel('Mean Weight') ax.set_title('Topic Proportions') ax.legend(frameon=False, bbox_to_anchor=(1.02, 1), loc='upper left') despine(ax) - + return ax def plot_topic_spatial_grid( - model: 'SpatialLDA', - data: 'SpatialTissueData', + model: SpatialLDA, + data: SpatialTissueData, ncols: int = 3, size: float = 3, cmap: str = 'viridis', figsize_per_panel: Tuple[float, float] = (4, 4), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Plot all topics in a faceted grid. - + Parameters ---------- model : SpatialLDA @@ -541,7 +545,7 @@ def plot_topic_spatial_grid( Size per panel. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Figure @@ -549,41 +553,41 @@ def plot_topic_spatial_grid( """ _check_matplotlib() import matplotlib.pyplot as plt - + n_topics = model.n_topics nrows = int(np.ceil(n_topics / ncols)) - + fig, axes = plt.subplots( nrows, ncols, figsize=(figsize_per_panel[0] * ncols, figsize_per_panel[1] * nrows), squeeze=False ) - + topic_weights = model.transform(data) coords = data._coordinates - + for t in range(n_topics): row = t // ncols col = t % ncols ax = axes[row, col] - + scatter = ax.scatter( coords[:, 0], coords[:, 1], c=topic_weights[:, t], s=size, cmap=cmap, rasterized=True, **kwargs ) - + ax.set_title(f'Topic {t}') ax.set_aspect('equal') ax.set_xticks([]) ax.set_yticks([]) plt.colorbar(scatter, ax=ax, shrink=0.8) despine(ax, left=True, bottom=True) - + # Hide empty panels for idx in range(n_topics, nrows * ncols): axes[idx // ncols, idx % ncols].set_visible(False) - + fig.tight_layout() - + return fig diff --git a/spatialtissuepy/viz/mapper.py b/spatialtissuepy/viz/mapper.py index 932927e..36d3097 100644 --- a/spatialtissuepy/viz/mapper.py +++ b/spatialtissuepy/viz/mapper.py @@ -10,22 +10,27 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +from typing import TYPE_CHECKING, List, Optional, Tuple + import numpy as np from .config import ( - get_axes, get_categorical_palette, get_cell_type_colors, - despine, _check_matplotlib + _check_matplotlib, + despine, + get_axes, + get_cell_type_colors, ) if TYPE_CHECKING: - from spatialtissuepy.topology import MapperResult - from spatialtissuepy.core import SpatialTissueData import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData + from spatialtissuepy.topology import MapperResult + def plot_mapper_graph( - result: 'MapperResult', + result: MapperResult, layout: str = 'spring', node_size_scale: float = 50.0, color_by: str = 'size', @@ -33,12 +38,12 @@ def plot_mapper_graph( show_labels: bool = False, edge_alpha: float = 0.5, edge_width: float = 1.0, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot the Mapper graph. - + Parameters ---------- result : MapperResult @@ -61,7 +66,7 @@ def plot_mapper_graph( Matplotlib axes. **kwargs Additional arguments to networkx.draw(). - + Returns ------- plt.Axes @@ -70,15 +75,15 @@ def plot_mapper_graph( _check_matplotlib() import matplotlib.pyplot as plt import networkx as nx - + ax = get_axes(ax) - + if result.graph is None: ax.text(0.5, 0.5, 'No graph available', ha='center', va='center') return ax - + G = result.graph - + # Compute layout if layout == 'spring': pos = nx.spring_layout(G, seed=42) @@ -95,11 +100,11 @@ def plot_mapper_graph( pos = {node.node_id: node.spatial_centroid[:2] for node in result.nodes} else: raise ValueError(f"Unknown layout: {layout}") - + # Compute node sizes sizes = np.array([G.nodes[n].get('size', 1) for n in G.nodes()]) sizes = sizes * node_size_scale - + # Compute node colors if color_by == 'size': colors = [G.nodes[n].get('size', 1) for n in G.nodes()] @@ -123,31 +128,31 @@ def plot_mapper_graph( comp = G.nodes[n].get('composition', {}) total = sum(comp.values()) if comp else 1 colors.append(comp.get(color_by, 0) / total) - + # Draw edges nx.draw_networkx_edges(G, pos, ax=ax, alpha=edge_alpha, width=edge_width) - + # Draw nodes nodes = nx.draw_networkx_nodes( G, pos, ax=ax, node_size=sizes, node_color=colors, cmap=cmap, **kwargs ) - + if color_by not in ['component']: plt.colorbar(nodes, ax=ax, label=color_by.replace('_', ' ').title()) - + if show_labels: nx.draw_networkx_labels(G, pos, ax=ax, font_size=8) - + ax.set_title(f'Mapper Graph ({result.n_nodes} nodes, {result.n_edges} edges)') ax.axis('off') - + return ax def plot_mapper_spatial( - result: 'MapperResult', - data: 'SpatialTissueData', + result: MapperResult, + data: SpatialTissueData, color_by: str = 'component', show_edges: bool = True, show_centroids: bool = True, @@ -155,12 +160,12 @@ def plot_mapper_spatial( alpha: float = 0.6, centroid_size: float = 50, edge_alpha: float = 0.3, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot Mapper results in spatial coordinates. - + Parameters ---------- result : MapperResult @@ -185,7 +190,7 @@ def plot_mapper_spatial( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes @@ -194,11 +199,11 @@ def plot_mapper_spatial( _check_matplotlib() import matplotlib.pyplot as plt import networkx as nx - + ax = get_axes(ax) - + coords = data._coordinates - + # Compute cell colors if color_by == 'component': if result.graph is not None: @@ -209,48 +214,48 @@ def plot_mapper_spatial( node_to_comp[node] = i else: node_to_comp = {n.node_id: 0 for n in result.nodes} - + cell_colors = np.full(data.n_cells, -1) for cell_idx, node_ids in result.cell_node_map.items(): if node_ids: cell_colors[cell_idx] = node_to_comp.get(node_ids[0], 0) cmap = 'tab10' - + elif color_by == 'filter': cell_colors = result.filter_values cmap = 'viridis' - + elif color_by == 'n_nodes': cell_colors = np.array([ len(result.cell_node_map.get(i, [])) for i in range(data.n_cells) ]) cmap = 'viridis' - + else: cell_colors = result.filter_values cmap = 'viridis' - + # Plot cells scatter = ax.scatter( coords[:, 0], coords[:, 1], c=cell_colors, s=point_size, alpha=alpha, cmap=cmap, rasterized=True, **kwargs ) - + if color_by != 'component': plt.colorbar(scatter, ax=ax, label=color_by.replace('_', ' ').title()) - + # Plot edges if show_edges and result.edges: centroids = {node.node_id: node.spatial_centroid for node in result.nodes} - + for edge in result.edges: c1 = centroids.get(edge.source) c2 = centroids.get(edge.target) if c1 is not None and c2 is not None: ax.plot([c1[0], c2[0]], [c1[1], c2[1]], 'k-', alpha=edge_alpha, linewidth=1) - + # Plot centroids if show_centroids: for node in result.nodes: @@ -258,27 +263,27 @@ def plot_mapper_spatial( node.spatial_centroid[0], node.spatial_centroid[1], c='red', s=centroid_size, marker='x', zorder=10 ) - + ax.set_xlabel('X (um)') ax.set_ylabel('Y (um)') ax.set_title(f'Mapper Spatial ({result.n_components} components)') ax.set_aspect('equal') despine(ax) - + return ax def plot_filter_distribution( - result: 'MapperResult', + result: MapperResult, show_cover: bool = True, bins: int = 50, color: str = 'steelblue', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot filter value distribution with cover elements. - + Parameters ---------- result : MapperResult @@ -293,7 +298,7 @@ def plot_filter_distribution( Matplotlib axes. **kwargs Additional arguments to hist(). - + Returns ------- plt.Axes @@ -301,40 +306,40 @@ def plot_filter_distribution( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + # Plot histogram ax.hist(result.filter_values, bins=bins, alpha=0.7, density=True, color=color, **kwargs) - + # Show cover elements if show_cover and result.cover is not None: colors = plt.cm.tab10(np.linspace(0, 1, len(result.cover.elements))) - + for i, element in enumerate(result.cover.elements): ax.axvspan(element.lower, element.upper, alpha=0.1, color=colors[i % len(colors)]) ax.axvline(element.lower, color='gray', alpha=0.3, linestyle='--') - + ax.set_xlabel('Filter Value') ax.set_ylabel('Density') ax.set_title('Filter Distribution with Cover') despine(ax) - + return ax def plot_node_composition( - result: 'MapperResult', + result: MapperResult, cell_types: Optional[List[str]] = None, normalize: bool = True, sort_by: str = 'filter', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot cell type composition of each Mapper node. - + Parameters ---------- result : MapperResult @@ -349,24 +354,23 @@ def plot_node_composition( Matplotlib axes. **kwargs Additional arguments to bar(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if result.graph is None: ax.text(0.5, 0.5, 'No graph available', ha='center', va='center') return ax - + G = result.graph nodes = list(G.nodes()) - + # Determine cell types if cell_types is None: all_types = set() @@ -374,66 +378,66 @@ def plot_node_composition( comp = G.nodes[n].get('composition', {}) all_types.update(comp.keys()) cell_types = sorted(list(all_types)) - + if not cell_types: ax.text(0.5, 0.5, 'No composition data', ha='center', va='center') return ax - + # Sort nodes if sort_by == 'filter': - filter_means = [(node.node_id, np.mean(result.filter_values[node.members])) + filter_means = [(node.node_id, np.mean(result.filter_values[node.members])) for node in result.nodes] filter_means.sort(key=lambda x: x[1]) nodes = [n for n, _ in filter_means] elif sort_by == 'size': nodes = sorted(nodes, key=lambda n: G.nodes[n].get('size', 0), reverse=True) - + # Build composition matrix n_nodes = len(nodes) n_types = len(cell_types) type_to_idx = {t: i for i, t in enumerate(cell_types)} - + comp_matrix = np.zeros((n_nodes, n_types)) - + for i, node_id in enumerate(nodes): comp = G.nodes[node_id].get('composition', {}) for ct, count in comp.items(): if ct in type_to_idx: comp_matrix[i, type_to_idx[ct]] = count - + if normalize: row_sums = comp_matrix.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 comp_matrix = comp_matrix / row_sums - + # Create stacked bar chart x = np.arange(n_nodes) bottom = np.zeros(n_nodes) colors = get_cell_type_colors(cell_types) - + for j, ct in enumerate(cell_types): ax.bar(x, comp_matrix[:, j], bottom=bottom, label=ct, color=colors.get(ct, f'C{j}'), width=0.8, **kwargs) bottom += comp_matrix[:, j] - + ax.set_xlabel('Node (sorted)') ax.set_ylabel('Proportion' if normalize else 'Count') ax.set_title('Node Composition') ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False) despine(ax) - + return ax def plot_mapper_diagnostics( - result: 'MapperResult', - data: 'SpatialTissueData', + result: MapperResult, + data: SpatialTissueData, figsize: Tuple[float, float] = (16, 12), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create comprehensive Mapper diagnostic plots. - + Parameters ---------- result : MapperResult @@ -444,7 +448,7 @@ def plot_mapper_diagnostics( Figure size. **kwargs Additional arguments. - + Returns ------- plt.Figure @@ -452,34 +456,33 @@ def plot_mapper_diagnostics( """ _check_matplotlib() import matplotlib.pyplot as plt - import networkx as nx - + fig, axes = plt.subplots(2, 3, figsize=figsize) - + # 1. Mapper graph try: plot_mapper_graph(result, ax=axes[0, 0], layout='spring') except Exception as e: axes[0, 0].text(0.5, 0.5, f'Error: {e}', ha='center', va='center') - + # 2. Spatial view try: plot_mapper_spatial(result, data, ax=axes[0, 1]) except Exception as e: axes[0, 1].text(0.5, 0.5, f'Error: {e}', ha='center', va='center') - + # 3. Filter distribution try: plot_filter_distribution(result, ax=axes[0, 2]) except Exception as e: axes[0, 2].text(0.5, 0.5, f'Error: {e}', ha='center', va='center') - + # 4. Node composition try: plot_node_composition(result, ax=axes[1, 0]) except Exception as e: axes[1, 0].text(0.5, 0.5, f'Error: {e}', ha='center', va='center') - + # 5. Node size distribution ax = axes[1, 1] if result.nodes: @@ -493,13 +496,13 @@ def plot_mapper_diagnostics( despine(ax) else: ax.text(0.5, 0.5, 'No nodes', ha='center', va='center') - + # 6. Summary stats ax = axes[1, 2] ax.axis('off') - + stats = result.statistics - + stats_text = [ 'Mapper Summary', '=' * 35, @@ -521,25 +524,25 @@ def plot_mapper_diagnostics( f"Clustering: {result.parameters.get('clustering', 'N/A')}", f"Radius: {result.parameters.get('neighborhood_radius', 'N/A')}", ] - + ax.text(0.05, 0.95, '\n'.join([s for s in stats_text if s]), transform=ax.transAxes, fontfamily='monospace', fontsize=9, verticalalignment='top') - + fig.tight_layout() - + return fig def create_mapper_report( - result: 'MapperResult', - data: 'SpatialTissueData', + result: MapperResult, + data: SpatialTissueData, output_path: Optional[str] = None, **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create and optionally save a comprehensive Mapper report. - + Parameters ---------- result : MapperResult @@ -550,15 +553,15 @@ def create_mapper_report( If provided, save figure to this path. **kwargs Additional arguments to plot_mapper_diagnostics. - + Returns ------- plt.Figure Figure with report. """ fig = plot_mapper_diagnostics(result, data, **kwargs) - + if output_path: fig.savefig(output_path, dpi=150, bbox_inches='tight') - + return fig diff --git a/spatialtissuepy/viz/network.py b/spatialtissuepy/viz/network.py index 7403600..12afad2 100644 --- a/spatialtissuepy/viz/network.py +++ b/spatialtissuepy/viz/network.py @@ -7,23 +7,27 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + import numpy as np from .config import ( - get_axes, get_cell_type_colors, get_categorical_palette, - get_diverging_cmap, despine, _check_matplotlib + _check_matplotlib, + despine, + get_axes, + get_cell_type_colors, ) if TYPE_CHECKING: + import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData from spatialtissuepy.network import CellGraph - import matplotlib.pyplot as plt - import networkx as nx def plot_cell_graph( - graph: 'CellGraph', + graph: CellGraph, layout: str = 'spatial', node_color: str = 'cell_type', node_size: float = 20, @@ -31,12 +35,12 @@ def plot_cell_graph( edge_width: float = 0.5, colors: Optional[Dict[str, str]] = None, show_legend: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot cell graph with various layout options. - + Parameters ---------- graph : CellGraph @@ -59,7 +63,7 @@ def plot_cell_graph( Matplotlib axes. **kwargs Additional arguments to networkx.draw(). - + Returns ------- plt.Axes @@ -68,11 +72,11 @@ def plot_cell_graph( _check_matplotlib() import matplotlib.pyplot as plt import networkx as nx - + ax = get_axes(ax) - + G = graph.G - + # Determine layout if layout == 'spatial': pos = {i: graph._coordinates[i, :2] for i in range(len(graph._coordinates))} @@ -84,22 +88,22 @@ def plot_cell_graph( pos = nx.circular_layout(G) else: raise ValueError(f"Unknown layout: {layout}") - + # Determine node colors if node_color == 'cell_type': cell_types = graph._cell_types unique_types = np.unique(cell_types) - + if colors is None: colors = get_cell_type_colors(list(unique_types)) - + node_colors = [colors.get(cell_types[n], '#888888') for n in G.nodes()] - + # Draw edges first nx.draw_networkx_edges( G, pos, ax=ax, alpha=edge_alpha, width=edge_width ) - + # Draw nodes by type for legend for ct in unique_types: nodes_of_type = [n for n in G.nodes() if cell_types[n] == ct] @@ -109,42 +113,42 @@ def plot_cell_graph( node_size=node_size, node_color=colors.get(ct, '#888888'), label=ct, **kwargs ) - + if show_legend: ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False) - + elif node_color == 'degree': degrees = dict(G.degree()) node_colors = [degrees[n] for n in G.nodes()] - + nx.draw_networkx_edges( G, pos, ax=ax, alpha=edge_alpha, width=edge_width ) - + nodes = nx.draw_networkx_nodes( G, pos, ax=ax, node_size=node_size, node_color=node_colors, cmap='viridis', **kwargs ) plt.colorbar(nodes, ax=ax, label='Degree') - + elif node_color == 'component': components = list(nx.connected_components(G)) node_to_comp = {} for i, comp in enumerate(components): for node in comp: node_to_comp[node] = i - + node_colors = [node_to_comp.get(n, 0) for n in G.nodes()] - + nx.draw_networkx_edges( G, pos, ax=ax, alpha=edge_alpha, width=edge_width ) - + nx.draw_networkx_nodes( G, pos, ax=ax, node_size=node_size, node_color=node_colors, cmap='tab10', **kwargs ) - + else: # Assume it's a centrality metric try: @@ -156,36 +160,36 @@ def plot_cell_graph( centrality = nx.eigenvector_centrality(G, max_iter=500) else: centrality = {n: 0 for n in G.nodes()} - + node_colors = [centrality.get(n, 0) for n in G.nodes()] - + nx.draw_networkx_edges( G, pos, ax=ax, alpha=edge_alpha, width=edge_width ) - + nodes = nx.draw_networkx_nodes( G, pos, ax=ax, node_size=node_size, node_color=node_colors, cmap='plasma', **kwargs ) plt.colorbar(nodes, ax=ax, label=node_color.capitalize()) - + except Exception: # Fallback to single color nx.draw(G, pos, ax=ax, node_size=node_size, **kwargs) - + if layout == 'spatial': ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_aspect('equal') - + ax.set_title(f'Cell Graph ({G.number_of_nodes()} nodes, {G.number_of_edges()} edges)') - + return ax def plot_graph_on_tissue( - data: 'SpatialTissueData', - graph: 'CellGraph', + data: SpatialTissueData, + graph: CellGraph, color_by: str = 'cell_type', colors: Optional[Dict[str, str]] = None, point_size: float = 10, @@ -193,12 +197,12 @@ def plot_graph_on_tissue( edge_width: float = 0.3, highlight_edges: Optional[List[Tuple[int, int]]] = None, highlight_color: str = 'red', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Overlay graph edges on spatial tissue plot. - + Parameters ---------- data : SpatialTissueData @@ -223,26 +227,25 @@ def plot_graph_on_tissue( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from matplotlib.collections import LineCollection - + ax = get_axes(ax) - + coords = data._coordinates G = graph.G - + # Collect edges for LineCollection (more efficient than individual lines) edge_segments = [] for u, v in G.edges(): edge_segments.append([coords[u, :2], coords[v, :2]]) - + if edge_segments: lc = LineCollection( edge_segments, @@ -252,13 +255,13 @@ def plot_graph_on_tissue( zorder=1 ) ax.add_collection(lc) - + # Highlight specific edges if highlight_edges: highlight_segments = [] for u, v in highlight_edges: highlight_segments.append([coords[u, :2], coords[v, :2]]) - + if highlight_segments: hl_lc = LineCollection( highlight_segments, @@ -268,15 +271,15 @@ def plot_graph_on_tissue( zorder=2 ) ax.add_collection(hl_lc) - + # Plot points if color_by == 'cell_type': cell_types = data._cell_types unique_types = data.cell_types_unique - + if colors is None: colors = get_cell_type_colors(list(unique_types)) - + for ct in unique_types: mask = cell_types == ct ax.scatter( @@ -284,37 +287,37 @@ def plot_graph_on_tissue( c=colors.get(ct, '#888888'), s=point_size, label=ct, zorder=3, **kwargs ) - + ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False) - + else: ax.scatter( coords[:, 0], coords[:, 1], s=point_size, zorder=3, **kwargs ) - + ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_title('Cell Graph on Tissue') ax.set_aspect('equal') ax.autoscale_view() despine(ax) - + return ax def plot_degree_distribution( - graph: 'CellGraph', + graph: CellGraph, by_type: bool = False, colors: Optional[Dict[str, str]] = None, bins: int = 20, log_scale: bool = False, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot degree distribution of cell graph. - + Parameters ---------- graph : CellGraph @@ -331,28 +334,27 @@ def plot_degree_distribution( Matplotlib axes. **kwargs Additional arguments to hist(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + G = graph.G - + degrees = dict(G.degree()) - + if by_type: cell_types = graph._cell_types unique_types = np.unique(cell_types) - + if colors is None: colors = get_cell_type_colors(list(unique_types)) - + for ct in unique_types: type_degrees = [degrees[n] for n in G.nodes() if cell_types[n] == ct] if type_degrees: @@ -360,35 +362,35 @@ def plot_degree_distribution( type_degrees, bins=bins, alpha=0.6, label=ct, color=colors.get(ct, None), **kwargs ) - + ax.legend(frameon=False) - + else: all_degrees = list(degrees.values()) ax.hist(all_degrees, bins=bins, alpha=0.7, edgecolor='black', **kwargs) - + if log_scale: ax.set_yscale('log') - + ax.set_xlabel('Degree') ax.set_ylabel('Count') ax.set_title('Degree Distribution') despine(ax) - + return ax def plot_centrality_by_type( - graph: 'CellGraph', + graph: CellGraph, metric: str = 'degree', colors: Optional[Dict[str, str]] = None, show_points: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot centrality metrics grouped by cell type (violin/box plot). - + Parameters ---------- graph : CellGraph @@ -403,23 +405,22 @@ def plot_centrality_by_type( Matplotlib axes. **kwargs Additional arguments to violinplot(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt import networkx as nx - + ax = get_axes(ax) - + G = graph.G - + cell_types = graph._cell_types unique_types = sorted(np.unique(cell_types)) - + # Compute centrality if metric == 'degree': centrality = dict(G.degree()) @@ -434,28 +435,28 @@ def plot_centrality_by_type( centrality = {n: 0 for n in G.nodes()} else: raise ValueError(f"Unknown metric: {metric}") - + # Group by cell type data_by_type = [] for ct in unique_types: type_values = [centrality[n] for n in G.nodes() if cell_types[n] == ct] data_by_type.append(type_values) - + if colors is None: colors = get_cell_type_colors(unique_types) - + # Create violin plot positions = range(len(unique_types)) parts = ax.violinplot( data_by_type, positions=positions, showmeans=True, showmedians=True, **kwargs ) - + # Color violins for i, (pc, ct) in enumerate(zip(parts['bodies'], unique_types)): pc.set_facecolor(colors.get(ct, '#888888')) pc.set_alpha(0.7) - + # Add individual points if show_points: for i, (values, ct) in enumerate(zip(data_by_type, unique_types)): @@ -464,29 +465,29 @@ def plot_centrality_by_type( np.full(len(values), i) + jitter, values, c=colors.get(ct, '#888888'), s=3, alpha=0.5, zorder=3 ) - + ax.set_xticks(positions) ax.set_xticklabels(unique_types, rotation=45, ha='right') ax.set_xlabel('Cell Type') ax.set_ylabel(metric.replace('_', ' ').title()) ax.set_title(f'{metric.replace("_", " ").title()} by Cell Type') despine(ax) - + return ax def plot_type_mixing_matrix( - graph: 'CellGraph', + graph: CellGraph, normalize: str = 'row', cmap: str = 'Blues', annot: bool = True, fmt: str = '.2f', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot cell type mixing/interaction matrix. - + Parameters ---------- graph : CellGraph @@ -503,7 +504,7 @@ def plot_type_mixing_matrix( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -511,19 +512,19 @@ def plot_type_mixing_matrix( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + G = graph.G - + cell_types = graph._cell_types unique_types = sorted(np.unique(cell_types)) n_types = len(unique_types) type_to_idx = {t: i for i, t in enumerate(unique_types)} - + # Count edges between types mixing_matrix = np.zeros((n_types, n_types)) - + for u, v in G.edges(): type_u = cell_types[u] type_v = cell_types[v] @@ -531,7 +532,7 @@ def plot_type_mixing_matrix( idx_v = type_to_idx[type_v] mixing_matrix[idx_u, idx_v] += 1 mixing_matrix[idx_v, idx_u] += 1 # Undirected - + # Normalize if normalize == 'row': row_sums = mixing_matrix.sum(axis=1, keepdims=True) @@ -545,11 +546,11 @@ def plot_type_mixing_matrix( total = mixing_matrix.sum() if total > 0: mixing_matrix = mixing_matrix / total - + # Plot heatmap im = ax.imshow(mixing_matrix, cmap=cmap, aspect='auto', **kwargs) plt.colorbar(im, ax=ax, shrink=0.8) - + # Add annotations if annot: for i in range(n_types): @@ -557,7 +558,7 @@ def plot_type_mixing_matrix( value = mixing_matrix[i, j] color = 'white' if value > mixing_matrix.max() / 2 else 'black' ax.text(j, i, format(value, fmt), ha='center', va='center', color=color) - + ax.set_xticks(range(n_types)) ax.set_yticks(range(n_types)) ax.set_xticklabels(unique_types, rotation=45, ha='right') @@ -565,5 +566,5 @@ def plot_type_mixing_matrix( ax.set_xlabel('Cell Type') ax.set_ylabel('Cell Type') ax.set_title('Cell Type Mixing Matrix') - + return ax diff --git a/spatialtissuepy/viz/qc.py b/spatialtissuepy/viz/qc.py index 49ceebe..875c2f2 100644 --- a/spatialtissuepy/viz/qc.py +++ b/spatialtissuepy/viz/qc.py @@ -6,30 +6,31 @@ """ from __future__ import annotations + from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np import pandas as pd -from .config import ( - get_axes, get_categorical_palette, despine, _check_matplotlib -) +from .config import _check_matplotlib, despine, get_axes, get_categorical_palette if TYPE_CHECKING: - from spatialtissuepy.core import SpatialTissueData import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData + def plot_cell_count_summary( - data: 'SpatialTissueData', + data: SpatialTissueData, sort_by: str = 'count', horizontal: bool = True, colors: Optional[Dict[str, str]] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot cell count summary bar chart. - + Parameters ---------- data : SpatialTissueData @@ -44,21 +45,20 @@ def plot_cell_count_summary( Matplotlib axes. **kwargs Additional arguments to bar/barh. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from .config import get_cell_type_colors - + ax = get_axes(ax) - + cell_types = data._cell_types unique_types, counts = np.unique(cell_types, return_counts=True) - + # Sort if sort_by == 'count': sort_idx = np.argsort(counts)[::-1] @@ -66,15 +66,15 @@ def plot_cell_count_summary( sort_idx = np.argsort(unique_types) else: sort_idx = np.arange(len(unique_types)) - + unique_types = unique_types[sort_idx] counts = counts[sort_idx] - + if colors is None: colors = get_cell_type_colors(list(unique_types)) - + bar_colors = [colors.get(ct, '#888888') for ct in unique_types] - + if horizontal: ax.barh(range(len(unique_types)), counts, color=bar_colors, **kwargs) ax.set_yticks(range(len(unique_types))) @@ -86,25 +86,25 @@ def plot_cell_count_summary( ax.set_xticks(range(len(unique_types))) ax.set_xticklabels(unique_types, rotation=45, ha='right') ax.set_ylabel('Count') - + ax.set_title(f'Cell Type Distribution (n={data.n_cells})') despine(ax) - + return ax def plot_spatial_coverage( - data: 'SpatialTissueData', + data: SpatialTissueData, resolution: int = 50, cell_type: Optional[str] = None, cmap: str = 'viridis', show_points: bool = False, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot spatial coverage heatmap. - + Parameters ---------- data : SpatialTissueData @@ -121,7 +121,7 @@ def plot_spatial_coverage( Matplotlib axes. **kwargs Additional arguments to imshow. - + Returns ------- plt.Axes @@ -129,9 +129,9 @@ def plot_spatial_coverage( """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + # Get coordinates if cell_type is not None: mask = data._cell_types == cell_type @@ -140,29 +140,29 @@ def plot_spatial_coverage( else: coords = data._coordinates title_suffix = '' - + bounds = data.bounds - + # Create grid x_edges = np.linspace(bounds['x'][0], bounds['x'][1], resolution + 1) y_edges = np.linspace(bounds['y'][0], bounds['y'][1], resolution + 1) - + # Count cells in each grid cell hist, _, _ = np.histogram2d(coords[:, 0], coords[:, 1], bins=[x_edges, y_edges]) - + # Plot heatmap extent = [bounds['x'][0], bounds['x'][1], bounds['y'][0], bounds['y'][1]] im = ax.imshow(hist.T, origin='lower', extent=extent, cmap=cmap, aspect='auto', **kwargs) plt.colorbar(im, ax=ax, label='Cell Count') - + # Overlay points if show_points: ax.scatter(coords[:, 0], coords[:, 1], c='white', s=1, alpha=0.3, rasterized=True) - + ax.set_xlabel('X (um)') ax.set_ylabel('Y (um)') ax.set_title(f'Spatial Coverage{title_suffix}') - + return ax @@ -171,12 +171,12 @@ def plot_model_selection( metric_cols: Optional[List[str]] = None, x_col: str = 'n_topics', highlight_best: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot model selection metrics (e.g., for choosing number of LDA topics). - + Parameters ---------- metrics_df : pd.DataFrame @@ -191,28 +191,27 @@ def plot_model_selection( Matplotlib axes. **kwargs Additional arguments to plot. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if metric_cols is None: - metric_cols = [c for c in metrics_df.select_dtypes(include=[np.number]).columns + metric_cols = [c for c in metrics_df.select_dtypes(include=[np.number]).columns if c != x_col] - + x = metrics_df[x_col].values colors = get_categorical_palette(len(metric_cols)) - + for i, col in enumerate(metric_cols): y = metrics_df[col].values ax.plot(x, y, 'o-', color=colors[i], label=col, **kwargs) - + if highlight_best: # For perplexity-like metrics (lower is better), find min # For coherence-like metrics (higher is better), find max @@ -220,16 +219,16 @@ def plot_model_selection( best_idx = np.argmin(y) else: best_idx = np.argmax(y) - - ax.scatter([x[best_idx]], [y[best_idx]], s=100, c=colors[i], + + ax.scatter([x[best_idx]], [y[best_idx]], s=100, c=colors[i], marker='*', zorder=10, edgecolor='black') - + ax.set_xlabel(x_col.replace('_', ' ').title()) ax.set_ylabel('Metric Value') ax.set_title('Model Selection') ax.legend(frameon=False) despine(ax) - + return ax @@ -237,12 +236,12 @@ def plot_stability_analysis( stability_df: pd.DataFrame, metric: str = 'n_nodes', x_col: str = 'run', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot stability analysis results from repeated runs. - + Parameters ---------- stability_df : pd.DataFrame @@ -255,43 +254,42 @@ def plot_stability_analysis( Matplotlib axes. **kwargs Additional arguments. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if x_col in stability_df.columns: x = stability_df[x_col].values else: x = np.arange(len(stability_df)) - + y = stability_df[metric].values mean_val = np.mean(y) std_val = np.std(y) cv = std_val / mean_val if mean_val > 0 else 0 - + # Plot points ax.scatter(x, y, alpha=0.6, s=50, **kwargs) - + # Plot mean and std bands ax.axhline(mean_val, color='red', linestyle='-', linewidth=2, label=f'Mean={mean_val:.2f}') ax.axhline(mean_val + std_val, color='red', linestyle='--', alpha=0.5) ax.axhline(mean_val - std_val, color='red', linestyle='--', alpha=0.5) - ax.fill_between(ax.get_xlim(), mean_val - std_val, mean_val + std_val, + ax.fill_between(ax.get_xlim(), mean_val - std_val, mean_val + std_val, alpha=0.1, color='red') - + ax.set_xlabel('Run' if x_col == 'run' else x_col) ax.set_ylabel(metric.replace('_', ' ').title()) ax.set_title(f'Stability Analysis: CV={cv:.3f}') ax.legend(frameon=False) despine(ax) - + return ax @@ -300,12 +298,12 @@ def plot_convergence( metric_name: str = 'Loss', log_scale: bool = False, show_best: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot convergence history. - + Parameters ---------- history : array-like @@ -320,40 +318,39 @@ def plot_convergence( Matplotlib axes. **kwargs Additional arguments to plot. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + history = np.asarray(history) iterations = np.arange(len(history)) - + ax.plot(iterations, history, 'b-', linewidth=1.5, **kwargs) - + if show_best: if 'loss' in metric_name.lower() or 'perplexity' in metric_name.lower(): best_idx = np.argmin(history) else: best_idx = np.argmax(history) - - ax.scatter([best_idx], [history[best_idx]], s=100, c='red', + + ax.scatter([best_idx], [history[best_idx]], s=100, c='red', marker='*', zorder=10, label=f'Best: {history[best_idx]:.4f}') ax.legend(frameon=False) - + if log_scale: ax.set_yscale('log') - + ax.set_xlabel('Iteration') ax.set_ylabel(metric_name) ax.set_title('Convergence') despine(ax) - + return ax @@ -363,12 +360,12 @@ def plot_parameter_sweep( metric_col: str, group_by: Optional[str] = None, colors: Optional[Dict[str, str]] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot results of parameter sweep. - + Parameters ---------- results_df : pd.DataFrame @@ -385,39 +382,38 @@ def plot_parameter_sweep( Matplotlib axes. **kwargs Additional arguments to plot. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - + ax = get_axes(ax) - + if group_by is not None: groups = results_df[group_by].unique() if colors is None: palette = get_categorical_palette(len(groups)) colors = {g: palette[i] for i, g in enumerate(groups)} - + for group in groups: mask = results_df[group_by] == group subset = results_df[mask].sort_values(param_col) ax.plot(subset[param_col], subset[metric_col], 'o-', color=colors.get(group, '#888888'), label=str(group), **kwargs) - + ax.legend(title=group_by, frameon=False) else: subset = results_df.sort_values(param_col) ax.plot(subset[param_col], subset[metric_col], 'o-', **kwargs) - + ax.set_xlabel(param_col.replace('_', ' ').title()) ax.set_ylabel(metric_col.replace('_', ' ').title()) ax.set_title('Parameter Sweep') despine(ax) - + return ax @@ -428,10 +424,10 @@ def plot_sample_qc_summary( sample_col: str = 'sample_id', figsize: Tuple[float, float] = (12, 8), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Plot QC summary for multiple samples. - + Parameters ---------- df : pd.DataFrame @@ -446,7 +442,7 @@ def plot_sample_qc_summary( Figure size. **kwargs Additional arguments. - + Returns ------- plt.Figure @@ -454,53 +450,53 @@ def plot_sample_qc_summary( """ _check_matplotlib() import matplotlib.pyplot as plt - + if metrics is None: metrics = ['n_cells', 'n_cell_types', 'density'] metrics = [m for m in metrics if m in df.columns] - + if not metrics: metrics = df.select_dtypes(include=[np.number]).columns[:4].tolist() - + n_metrics = len(metrics) ncols = min(3, n_metrics) nrows = int(np.ceil(n_metrics / ncols)) - + fig, axes = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False) - + for idx, metric in enumerate(metrics): row = idx // ncols col = idx % ncols ax = axes[row, col] - + values = df[metric].values - samples = df[sample_col].values if sample_col in df.columns else np.arange(len(df)) - + df[sample_col].values if sample_col in df.columns else np.arange(len(df)) + colors = ['steelblue'] * len(values) - + # Check thresholds if thresholds and metric in thresholds: min_val, max_val = thresholds[metric] for i, v in enumerate(values): if v < min_val or v > max_val: colors[i] = 'red' - + ax.bar(range(len(values)), values, color=colors) ax.set_xlabel('Sample') ax.set_ylabel(metric.replace('_', ' ').title()) ax.set_title(metric.replace('_', ' ').title()) - + if thresholds and metric in thresholds: min_val, max_val = thresholds[metric] ax.axhline(min_val, color='red', linestyle='--', alpha=0.5) ax.axhline(max_val, color='red', linestyle='--', alpha=0.5) - + despine(ax) - + # Hide empty panels for idx in range(n_metrics, nrows * ncols): axes[idx // ncols, idx % ncols].set_visible(False) - + fig.tight_layout() - + return fig diff --git a/spatialtissuepy/viz/spatial.py b/spatialtissuepy/viz/spatial.py index 1e2801b..6a83f73 100644 --- a/spatialtissuepy/viz/spatial.py +++ b/spatialtissuepy/viz/spatial.py @@ -7,21 +7,28 @@ """ from __future__ import annotations + from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + import numpy as np from .config import ( - get_axes, get_cell_type_colors, get_sequential_cmap, - despine, add_scalebar, _check_matplotlib + _check_matplotlib, + add_scalebar, + despine, + get_axes, + get_cell_type_colors, + get_sequential_cmap, ) if TYPE_CHECKING: - from spatialtissuepy.core import SpatialTissueData import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData + def plot_spatial_scatter( - data: 'SpatialTissueData', + data: SpatialTissueData, color_by: str = 'cell_type', marker: Optional[str] = None, size: float = 5, @@ -36,12 +43,12 @@ def plot_spatial_scatter( xlabel: str = 'X (µm)', ylabel: str = 'Y (µm)', scalebar: Optional[float] = None, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Basic spatial scatter plot of cells. - + Parameters ---------- data : SpatialTissueData @@ -74,43 +81,43 @@ def plot_spatial_scatter( Matplotlib axes. If None, creates new figure. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. - + Examples -------- >>> # Color by cell type >>> plot_spatial_scatter(data, color_by='cell_type') - >>> + >>> >>> # Color by marker expression >>> plot_spatial_scatter(data, marker='Ki67', cmap='magma') - >>> + >>> >>> # Custom colors >>> colors = {'Tumor': 'red', 'T_cell': 'blue'} >>> plot_spatial_scatter(data, colors=colors) """ _check_matplotlib() import matplotlib.pyplot as plt - + ax = get_axes(ax) - + coords = data._coordinates - + # Determine coloring if marker is not None: color_by = 'marker' - + if color_by == 'cell_type': # Categorical coloring by cell type cell_types = data._cell_types unique_types = data.cell_types_unique - + if colors is None: colors = get_cell_type_colors(list(unique_types)) - + for ct in unique_types: mask = cell_types == ct ax.scatter( @@ -121,20 +128,20 @@ def plot_spatial_scatter( label=ct, **kwargs ) - + if show_legend: ax.legend( bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False, markerscale=2 ) - + elif color_by == 'marker' and marker is not None: # Continuous coloring by marker if data.markers is None or marker not in data.markers.columns: raise ValueError(f"Marker '{marker}' not found in data") - + values = data.markers[marker].values - + scatter = ax.scatter( coords[:, 0], coords[:, 1], c=values, @@ -145,19 +152,19 @@ def plot_spatial_scatter( vmax=vmax, **kwargs ) - + if show_colorbar: plt.colorbar(scatter, ax=ax, label=marker) - + elif color_by == 'density': # Color by local density from scipy.spatial import cKDTree tree = cKDTree(coords) - + # Count neighbors within adaptive radius radius = np.sqrt((coords[:, 0].ptp() * coords[:, 1].ptp()) / len(coords)) * 2 counts = np.array([len(tree.query_ball_point(c, radius)) for c in coords]) - + scatter = ax.scatter( coords[:, 0], coords[:, 1], c=counts, @@ -166,10 +173,10 @@ def plot_spatial_scatter( cmap=get_sequential_cmap('density'), **kwargs ) - + if show_colorbar: plt.colorbar(scatter, ax=ax, label='Local density') - + else: # Single color ax.scatter( @@ -178,24 +185,24 @@ def plot_spatial_scatter( alpha=alpha, **kwargs ) - + ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) ax.set_aspect('equal') - + if title: ax.set_title(title) - + if scalebar is not None: add_scalebar(ax, scalebar) - + despine(ax) - + return ax def plot_cell_types( - data: 'SpatialTissueData', + data: SpatialTissueData, cell_types: Optional[List[str]] = None, colors: Optional[Dict[str, str]] = None, size: float = 5, @@ -203,10 +210,10 @@ def plot_cell_types( ncols: int = 3, figsize_per_panel: Tuple[float, float] = (4, 4), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create faceted plot with one panel per cell type. - + Parameters ---------- data : SpatialTissueData @@ -225,7 +232,7 @@ def plot_cell_types( Size of each panel. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Figure @@ -233,37 +240,37 @@ def plot_cell_types( """ _check_matplotlib() import matplotlib.pyplot as plt - + if cell_types is None: cell_types = list(data.cell_types_unique) - + n_types = len(cell_types) nrows = int(np.ceil(n_types / ncols)) - + if colors is None: colors = get_cell_type_colors(cell_types) - + fig, axes = plt.subplots( nrows, ncols, figsize=(figsize_per_panel[0] * ncols, figsize_per_panel[1] * nrows), squeeze=False ) - + coords = data._coordinates all_types = data._cell_types - + for idx, ct in enumerate(cell_types): row = idx // ncols col = idx % ncols ax = axes[row, col] - + # Plot background (other cells) mask_other = all_types != ct ax.scatter( coords[mask_other, 0], coords[mask_other, 1], c='#e0e0e0', s=size * 0.5, alpha=0.3, rasterized=True ) - + # Plot highlighted cell type mask = all_types == ct n_cells = np.sum(mask) @@ -272,26 +279,26 @@ def plot_cell_types( c=colors.get(ct, '#1f77b4'), s=size, alpha=alpha, label=f'{ct} (n={n_cells})', rasterized=True, **kwargs ) - + ax.set_title(f'{ct} (n={n_cells})') ax.set_aspect('equal') ax.set_xticks([]) ax.set_yticks([]) despine(ax, left=True, bottom=True) - + # Hide empty panels for idx in range(n_types, nrows * ncols): row = idx // ncols col = idx % ncols axes[row, col].set_visible(False) - + fig.tight_layout() - + return fig def plot_marker_expression( - data: 'SpatialTissueData', + data: SpatialTissueData, markers: List[str], ncols: int = 3, cmap: str = 'magma', @@ -300,10 +307,10 @@ def plot_marker_expression( vmax_percentile: float = 99, figsize_per_panel: Tuple[float, float] = (4, 4), **kwargs -) -> 'plt.Figure': +) -> plt.Figure: """ Create faceted plot of marker expression. - + Parameters ---------- data : SpatialTissueData @@ -322,7 +329,7 @@ def plot_marker_expression( Size of each panel. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Figure @@ -330,61 +337,61 @@ def plot_marker_expression( """ _check_matplotlib() import matplotlib.pyplot as plt - + if data.markers is None: raise ValueError("Data has no markers") - + n_markers = len(markers) nrows = int(np.ceil(n_markers / ncols)) - + fig, axes = plt.subplots( nrows, ncols, figsize=(figsize_per_panel[0] * ncols, figsize_per_panel[1] * nrows), squeeze=False ) - + coords = data._coordinates - + for idx, marker in enumerate(markers): row = idx // ncols col = idx % ncols ax = axes[row, col] - + if marker not in data.markers.columns: ax.text(0.5, 0.5, f'{marker}\nnot found', ha='center', va='center') ax.set_title(marker) continue - + values = data.markers[marker].values vmin = np.percentile(values, vmin_percentile) vmax = np.percentile(values, vmax_percentile) - + scatter = ax.scatter( coords[:, 0], coords[:, 1], c=values, s=size, cmap=cmap, vmin=vmin, vmax=vmax, rasterized=True, **kwargs ) - + ax.set_title(marker) ax.set_aspect('equal') ax.set_xticks([]) ax.set_yticks([]) plt.colorbar(scatter, ax=ax, shrink=0.8) despine(ax, left=True, bottom=True) - + # Hide empty panels for idx in range(n_markers, nrows * ncols): row = idx // ncols col = idx % ncols axes[row, col].set_visible(False) - + fig.tight_layout() - + return fig def plot_density_map( - data: 'SpatialTissueData', + data: SpatialTissueData, cell_type: Optional[str] = None, method: str = 'kde', bandwidth: Optional[float] = None, @@ -393,12 +400,12 @@ def plot_density_map( show_points: bool = False, point_size: float = 1, point_alpha: float = 0.3, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot cell density map. - + Parameters ---------- data : SpatialTissueData @@ -421,7 +428,7 @@ def plot_density_map( Matplotlib axes. **kwargs Additional arguments to contourf() or imshow(). - + Returns ------- plt.Axes @@ -430,9 +437,9 @@ def plot_density_map( _check_matplotlib() import matplotlib.pyplot as plt from scipy import stats - + ax = get_axes(ax) - + # Get coordinates if cell_type is not None: mask = data._cell_types == cell_type @@ -441,68 +448,68 @@ def plot_density_map( else: coords = data._coordinates title_suffix = '' - + if len(coords) < 10: ax.text(0.5, 0.5, 'Not enough cells', ha='center', va='center') return ax - + # Create grid bounds = data.bounds x_grid = np.linspace(bounds['x'][0], bounds['x'][1], resolution) y_grid = np.linspace(bounds['y'][0], bounds['y'][1], resolution) xx, yy = np.meshgrid(x_grid, y_grid) - + if method == 'kde': # Kernel density estimation if bandwidth is None: bandwidth = np.sqrt(coords[:, 0].var() + coords[:, 1].var()) / 10 - + kernel = stats.gaussian_kde(coords.T, bw_method=bandwidth) positions = np.vstack([xx.ravel(), yy.ravel()]) density = kernel(positions).reshape(xx.shape) - + elif method == 'histogram': density, _, _ = np.histogram2d( coords[:, 0], coords[:, 1], bins=[x_grid, y_grid] ) density = density.T # Transpose for correct orientation - + else: raise ValueError(f"Unknown method: {method}") - + # Plot density im = ax.contourf(xx, yy, density, levels=20, cmap=cmap, **kwargs) plt.colorbar(im, ax=ax, label='Density') - + # Overlay points if show_points: ax.scatter( coords[:, 0], coords[:, 1], s=point_size, c='white', alpha=point_alpha, rasterized=True ) - + ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_title(f'Cell Density{title_suffix}') ax.set_aspect('equal') - + return ax def plot_voronoi( - data: 'SpatialTissueData', + data: SpatialTissueData, color_by: str = 'cell_type', colors: Optional[Dict[str, str]] = None, edge_color: str = 'black', edge_width: float = 0.1, alpha: float = 0.7, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot Voronoi tessellation of cells. - + Parameters ---------- data : SpatialTissueData @@ -521,57 +528,56 @@ def plot_voronoi( Matplotlib axes. **kwargs Additional arguments to PolyCollection. - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from matplotlib.collections import PolyCollection from scipy.spatial import Voronoi - + ax = get_axes(ax) - + coords = data._coordinates[:, :2] # 2D only - + # Compute Voronoi vor = Voronoi(coords) - + if colors is None and color_by == 'cell_type': colors = get_cell_type_colors(list(data.cell_types_unique)) - + # Get cell colors if color_by == 'cell_type': cell_colors = [colors.get(ct, '#888888') for ct in data._cell_types] else: cell_colors = ['#1f77b4'] * data.n_cells - + # Create polygons for finite regions polygons = [] poly_colors = [] - + bounds = data.bounds - + for idx, region_idx in enumerate(vor.point_region): region = vor.regions[region_idx] - + if -1 in region or len(region) == 0: continue - + vertices = vor.vertices[region] - + # Clip to bounds - if (np.any(vertices[:, 0] < bounds['x'][0] - 100) or + if (np.any(vertices[:, 0] < bounds['x'][0] - 100) or np.any(vertices[:, 0] > bounds['x'][1] + 100) or - np.any(vertices[:, 1] < bounds['y'][0] - 100) or + np.any(vertices[:, 1] < bounds['y'][0] - 100) or np.any(vertices[:, 1] > bounds['y'][1] + 100)): continue - + polygons.append(vertices) poly_colors.append(cell_colors[idx]) - + # Add polygon collection collection = PolyCollection( polygons, @@ -582,30 +588,30 @@ def plot_voronoi( **kwargs ) ax.add_collection(collection) - + ax.set_xlim(bounds['x']) ax.set_ylim(bounds['y']) ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_title('Voronoi Tessellation') ax.set_aspect('equal') - + return ax def plot_spatial_domains( - data: 'SpatialTissueData', + data: SpatialTissueData, domain_labels: np.ndarray, colors: Optional[List[str]] = None, size: float = 5, alpha: float = 0.7, show_boundaries: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot spatial domain assignments. - + Parameters ---------- data : SpatialTissueData @@ -624,25 +630,24 @@ def plot_spatial_domains( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from .config import get_categorical_palette - + ax = get_axes(ax) - + coords = data._coordinates unique_domains = np.unique(domain_labels[domain_labels >= 0]) # Exclude -1 n_domains = len(unique_domains) - + if colors is None: colors = get_categorical_palette(n_domains) - + # Plot each domain for i, domain in enumerate(unique_domains): mask = domain_labels == domain @@ -651,7 +656,7 @@ def plot_spatial_domains( c=colors[i % len(colors)], s=size, alpha=alpha, label=f'Domain {domain}', rasterized=True, **kwargs ) - + # Plot unclustered cells mask_unclustered = domain_labels < 0 if np.any(mask_unclustered): @@ -660,19 +665,19 @@ def plot_spatial_domains( c='#cccccc', s=size * 0.5, alpha=0.3, label='Unclustered', rasterized=True ) - + ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_title(f'Spatial Domains (n={n_domains})') ax.set_aspect('equal') ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False) despine(ax) - + return ax def plot_cell_neighborhoods( - data: 'SpatialTissueData', + data: SpatialTissueData, cell_indices: Union[int, List[int]], radius: float = 50.0, highlight_color: str = 'red', @@ -681,12 +686,12 @@ def plot_cell_neighborhoods( size: float = 20, alpha: float = 0.7, show_radius: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Visualize the neighborhood of specific cells. - + Parameters ---------- data : SpatialTissueData @@ -711,44 +716,43 @@ def plot_cell_neighborhoods( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from matplotlib.patches import Circle from scipy.spatial import cKDTree - + ax = get_axes(ax) - + if isinstance(cell_indices, int): cell_indices = [cell_indices] - + coords = data._coordinates tree = cKDTree(coords) - + # Find all neighbors all_neighbors = set() for idx in cell_indices: neighbors = tree.query_ball_point(coords[idx], radius) all_neighbors.update(neighbors) - + all_neighbors.discard(set(cell_indices)) - + # Plot background cells background_mask = np.ones(data.n_cells, dtype=bool) background_mask[list(all_neighbors)] = False for idx in cell_indices: background_mask[idx] = False - + ax.scatter( coords[background_mask, 0], coords[background_mask, 1], c=background_color, s=size * 0.3, alpha=0.3, rasterized=True ) - + # Plot neighbors neighbor_list = list(all_neighbors) if neighbor_list: @@ -756,7 +760,7 @@ def plot_cell_neighborhoods( coords[neighbor_list, 0], coords[neighbor_list, 1], c=neighbor_color, s=size, alpha=alpha, label='Neighbors', **kwargs ) - + # Plot focal cells and radius circles for idx in cell_indices: ax.scatter( @@ -765,7 +769,7 @@ def plot_cell_neighborhoods( edgecolor='black', linewidth=0.5, zorder=10, label='Focal cell' if idx == cell_indices[0] else None ) - + if show_radius: circle = Circle( (coords[idx, 0], coords[idx, 1]), radius, @@ -773,12 +777,12 @@ def plot_cell_neighborhoods( linewidth=1.5, alpha=0.7 ) ax.add_patch(circle) - + ax.set_xlabel('X (µm)') ax.set_ylabel('Y (µm)') ax.set_title(f'Cell Neighborhood (r={radius})') ax.set_aspect('equal') ax.legend(frameon=False) despine(ax) - + return ax diff --git a/spatialtissuepy/viz/statistics.py b/spatialtissuepy/viz/statistics.py index ac874b0..5b7dc2b 100644 --- a/spatialtissuepy/viz/statistics.py +++ b/spatialtissuepy/viz/statistics.py @@ -7,21 +7,25 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +from typing import TYPE_CHECKING, List, Optional + import numpy as np from .config import ( - get_axes, get_cell_type_colors, get_sequential_cmap, - get_diverging_cmap, despine, _check_matplotlib + _check_matplotlib, + despine, + get_axes, ) if TYPE_CHECKING: - from spatialtissuepy.core import SpatialTissueData import matplotlib.pyplot as plt + from spatialtissuepy.core import SpatialTissueData + def plot_ripleys_curve( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[np.ndarray] = None, cell_type: Optional[str] = None, statistic: str = 'H', @@ -30,12 +34,12 @@ def plot_ripleys_curve( show_envelope: bool = True, show_csr: bool = True, color: str = '#1f77b4', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot Ripley's K, L, or H function with confidence envelope. - + Parameters ---------- data : SpatialTissueData @@ -60,18 +64,17 @@ def plot_ripleys_curve( Matplotlib axes. **kwargs Additional arguments to plot(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - from spatialtissuepy.statistics import ripleys_k, ripleys_l, ripleys_h - + from spatialtissuepy.statistics import ripleys_h, ripleys_k, ripleys_l + ax = get_axes(ax) - + # Get coordinates if cell_type is not None: mask = data._cell_types == cell_type @@ -80,12 +83,12 @@ def plot_ripleys_curve( else: coords = data._coordinates title_suffix = '' - + # Determine radii if radii is None: max_dist = min(np.ptp(coords[:, 0]), np.ptp(coords[:, 1])) / 4 radii = np.linspace(0, max_dist, 50) - + # Compute statistic if statistic == 'K': values = ripleys_k(coords, radii) @@ -101,14 +104,14 @@ def plot_ripleys_curve( csr_values = np.zeros_like(radii) else: raise ValueError(f"Unknown statistic: {statistic}") - + # Plot CSR expectation if show_csr: ax.plot(radii, csr_values, '--', color='gray', alpha=0.7, label='CSR') - + # Plot observed values ax.plot(radii, values, color=color, linewidth=2, label='Observed', **kwargs) - + ax.set_xlabel('Distance r (um)') ax.set_ylabel(ylabel) ax.set_title(f"{ylabel}{title_suffix}") @@ -116,22 +119,22 @@ def plot_ripleys_curve( if statistic == 'H': ax.axhline(y=0, color='gray', linestyle='-', alpha=0.3) despine(ax) - + return ax def plot_pcf_curve( - data: 'SpatialTissueData', + data: SpatialTissueData, radii: Optional[np.ndarray] = None, cell_type: Optional[str] = None, color: str = '#1f77b4', show_csr: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot pair correlation function g(r). - + Parameters ---------- data : SpatialTissueData @@ -148,18 +151,17 @@ def plot_pcf_curve( Matplotlib axes. **kwargs Additional arguments to plot(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from spatialtissuepy.statistics import pair_correlation_function - + ax = get_axes(ax) - + # Get coordinates if cell_type is not None: mask = data._cell_types == cell_type @@ -168,32 +170,32 @@ def plot_pcf_curve( else: coords = data._coordinates title_suffix = '' - + # Determine radii if radii is None: max_dist = min(coords[:, 0].ptp(), coords[:, 1].ptp()) / 4 radii = np.linspace(1, max_dist, 50) - + # Compute PCF g_values = pair_correlation_function(coords, radii) - + # Plot if show_csr: ax.axhline(y=1, color='gray', linestyle='--', alpha=0.7, label='CSR (g=1)') - + ax.plot(radii, g_values, color=color, linewidth=2, label='Observed', **kwargs) - + ax.set_xlabel('Distance r (um)') ax.set_ylabel('g(r)') ax.set_title(f"Pair Correlation Function{title_suffix}") ax.legend(frameon=False) despine(ax) - + return ax def plot_colocalization_heatmap( - data: 'SpatialTissueData', + data: SpatialTissueData, radius: float = 50.0, metric: str = 'clq', cell_types: Optional[List[str]] = None, @@ -201,12 +203,12 @@ def plot_colocalization_heatmap( center: float = 1.0, annot: bool = True, fmt: str = '.2f', - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot co-localization heatmap between cell types. - + Parameters ---------- data : SpatialTissueData @@ -229,7 +231,7 @@ def plot_colocalization_heatmap( Matplotlib axes. **kwargs Additional arguments to imshow(). - + Returns ------- plt.Axes @@ -237,16 +239,17 @@ def plot_colocalization_heatmap( """ _check_matplotlib() import matplotlib.pyplot as plt + from spatialtissuepy.statistics import colocalization_quotient - + ax = get_axes(ax) - + if cell_types is None: cell_types = list(data.cell_types_unique) - + n_types = len(cell_types) matrix = np.zeros((n_types, n_types)) - + # Compute co-localization for each pair for i, type_a in enumerate(cell_types): for j, type_b in enumerate(cell_types): @@ -254,7 +257,7 @@ def plot_colocalization_heatmap( matrix[i, j] = colocalization_quotient(data, type_a, type_b, radius) except Exception: matrix[i, j] = np.nan - + # Determine color limits valid_values = matrix[~np.isnan(matrix)] if len(valid_values) > 0: @@ -262,11 +265,11 @@ def plot_colocalization_heatmap( vmin = 2 * center - vmax else: vmin, vmax = 0, 2 - + # Plot heatmap im = ax.imshow(matrix, cmap=cmap, aspect='auto', vmin=vmin, vmax=vmax, **kwargs) plt.colorbar(im, ax=ax, label='CLQ' if metric == 'clq' else 'Enrichment') - + # Add annotations if annot: for i in range(n_types): @@ -276,7 +279,7 @@ def plot_colocalization_heatmap( continue color = 'white' if abs(value - center) > (vmax - center) * 0.5 else 'black' ax.text(j, i, format(value, fmt), ha='center', va='center', color=color) - + ax.set_xticks(range(n_types)) ax.set_yticks(range(n_types)) ax.set_xticklabels(cell_types, rotation=45, ha='right') @@ -284,22 +287,22 @@ def plot_colocalization_heatmap( ax.set_xlabel('Cell Type B') ax.set_ylabel('Cell Type A') ax.set_title(f'Co-localization (r={radius}um)') - + return ax def plot_neighborhood_enrichment( - data: 'SpatialTissueData', + data: SpatialTissueData, type_a: str, type_b: str, radius: float = 50.0, n_permutations: int = 999, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot neighborhood enrichment test result. - + Parameters ---------- data : SpatialTissueData @@ -316,57 +319,56 @@ def plot_neighborhood_enrichment( Matplotlib axes. **kwargs Additional arguments to hist(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from spatialtissuepy.statistics import neighborhood_enrichment_test - + ax = get_axes(ax) - + # Run enrichment test result = neighborhood_enrichment_test(data, type_a, type_b, radius, n_permutations) - + # Plot null distribution - ax.hist(result['null_distribution'], bins=30, alpha=0.7, color='gray', + ax.hist(result['null_distribution'], bins=30, alpha=0.7, color='gray', label='Null distribution', **kwargs) - + # Plot observed value ax.axvline(result['observed'], color='red', linewidth=2, label=f"Observed ({result['observed']:.2f})") - + # Add p-value annotation pval = result['pvalue'] pval_str = 'p < 0.001' if pval < 0.001 else f'p = {pval:.3f}' - + ax.text(0.95, 0.95, pval_str, transform=ax.transAxes, ha='right', va='top', fontsize=10, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) - + ax.set_xlabel('Number of Neighbors') ax.set_ylabel('Frequency') ax.set_title(f'Neighborhood Enrichment: {type_a} -> {type_b}') ax.legend(frameon=False) despine(ax) - + return ax def plot_hotspot_map( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float = 50.0, alpha_level: float = 0.05, size: float = 10, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot hotspot analysis results. - + Parameters ---------- data : SpatialTissueData @@ -383,64 +385,63 @@ def plot_hotspot_map( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt from spatialtissuepy.statistics import detect_hotspots - + ax = get_axes(ax) coords = data._coordinates - + # Detect hotspots result = detect_hotspots(data, values, radius, alpha_level) - + # Plot non-significant points ns_mask = result['classification'] == 'not_significant' - ax.scatter(coords[ns_mask, 0], coords[ns_mask, 1], c='#cccccc', s=size * 0.5, + ax.scatter(coords[ns_mask, 0], coords[ns_mask, 1], c='#cccccc', s=size * 0.5, alpha=0.5, label='Not significant', rasterized=True) - + # Plot hotspots hot_mask = result['classification'] == 'hotspot' if np.any(hot_mask): - ax.scatter(coords[hot_mask, 0], coords[hot_mask, 1], + ax.scatter(coords[hot_mask, 0], coords[hot_mask, 1], c=result['z_scores'][hot_mask], cmap='Reds', s=size, vmin=0, label=f'Hotspots (n={np.sum(hot_mask)})', rasterized=True, **kwargs) - + # Plot coldspots cold_mask = result['classification'] == 'coldspot' if np.any(cold_mask): ax.scatter(coords[cold_mask, 0], coords[cold_mask, 1], c=result['z_scores'][cold_mask], cmap='Blues_r', s=size, vmax=0, label=f'Coldspots (n={np.sum(cold_mask)})', rasterized=True, **kwargs) - + ax.set_xlabel('X (um)') ax.set_ylabel('Y (um)') ax.set_title(f'Hotspot Analysis (alpha={alpha_level})') ax.legend(frameon=False, loc='upper left') ax.set_aspect('equal') despine(ax) - + return ax def plot_morans_scatter( - data: 'SpatialTissueData', + data: SpatialTissueData, values: np.ndarray, radius: float = 50.0, standardize: bool = True, color: str = '#1f77b4', show_regression: bool = True, - ax: Optional['plt.Axes'] = None, + ax: Optional[plt.Axes] = None, **kwargs -) -> 'plt.Axes': +) -> plt.Axes: """ Plot Moran's I scatter plot (spatial lag vs. value). - + Parameters ---------- data : SpatialTissueData @@ -459,37 +460,36 @@ def plot_morans_scatter( Matplotlib axes. **kwargs Additional arguments to scatter(). - + Returns ------- plt.Axes Matplotlib axes with the plot. """ _check_matplotlib() - import matplotlib.pyplot as plt - from scipy.spatial import cKDTree from scipy import stats - + from scipy.spatial import cKDTree + ax = get_axes(ax) coords = data._coordinates - + # Standardize values if standardize: values = (values - np.mean(values)) / np.std(values) - + # Compute spatial lag tree = cKDTree(coords) spatial_lag = np.zeros_like(values) - + for i, coord in enumerate(coords): neighbors = tree.query_ball_point(coord, radius) neighbors = [n for n in neighbors if n != i] if neighbors: spatial_lag[i] = np.mean(values[neighbors]) - + # Plot scatter ax.scatter(values, spatial_lag, c=color, s=10, alpha=0.5, rasterized=True, **kwargs) - + # Add regression line if show_regression: slope, intercept, r_value, p_value, std_err = stats.linregress(values, spatial_lag) @@ -497,14 +497,14 @@ def plot_morans_scatter( y_line = slope * x_line + intercept ax.plot(x_line, y_line, 'r-', linewidth=2, label=f"Slope={slope:.3f} (Moran's I)") ax.legend(frameon=False) - + # Add reference lines ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5) ax.axvline(x=0, color='gray', linestyle='--', alpha=0.5) - + ax.set_xlabel('Value (z-score)' if standardize else 'Value') ax.set_ylabel('Spatial Lag') ax.set_title("Moran's I Scatter Plot") despine(ax) - + return ax diff --git a/tests/conftest.py b/tests/conftest.py index fe3f196..c1bdc20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,16 +5,15 @@ for all test modules. """ -import pytest +import json +from pathlib import Path + import numpy as np import pandas as pd -from pathlib import Path -import tempfile -import json +import pytest from spatialtissuepy import SpatialTissueData - # ============================================================================= # Pytest Configuration # ============================================================================= @@ -22,7 +21,7 @@ def pytest_configure(config): """Register custom markers.""" config.addinivalue_line( - "markers", + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" ) @@ -163,12 +162,12 @@ def multisample_cohort(): sample_id = f'sample_{i}' sample = SpatialTissueData(coords, types, sample_ids=[sample_id] * n) samples.append(sample) - + # Combine all samples all_coords = np.vstack([s.coordinates for s in samples]) all_types = np.concatenate([s.cell_types for s in samples]) all_samples = np.concatenate([s.sample_ids for s in samples]) - + return SpatialTissueData(all_coords, all_types, sample_ids=all_samples) @@ -182,14 +181,14 @@ def tissue_with_markers(): np.random.seed(42) coords = np.random.rand(100, 2) * 500 types = np.random.choice(['T_cell', 'Tumor', 'Stromal'], 100) - + markers = pd.DataFrame({ 'CD3': np.random.rand(100), 'CD8': np.random.rand(100), 'PD1': np.random.rand(100), 'Ki67': np.random.rand(100), }) - + return SpatialTissueData(coords, types, markers=markers) @@ -201,7 +200,7 @@ def tissue_with_markers(): def clustered_pattern(): """Tissue with clustered spatial pattern.""" np.random.seed(42) - + # Create 3 clusters clusters = [] for center_x, center_y in [(200, 200), (600, 200), (400, 600)]: @@ -209,10 +208,10 @@ def clustered_pattern(): x = np.random.normal(center_x, 30, n) y = np.random.normal(center_y, 30, n) clusters.append(np.column_stack([x, y])) - + coords = np.vstack(clusters) types = np.array(['Cluster_A'] * 50 + ['Cluster_B'] * 50 + ['Cluster_C'] * 50) - + return SpatialTissueData(coords, types) @@ -244,14 +243,14 @@ def regular_grid(): def temp_csv_file(temp_dir, simple_tissue_2d): """Temporary CSV file with sample data.""" filepath = temp_dir / "test_data.csv" - + df = pd.DataFrame({ 'x': simple_tissue_2d.coordinates[:, 0], 'y': simple_tissue_2d.coordinates[:, 1], 'cell_type': simple_tissue_2d.cell_types, }) df.to_csv(filepath, index=False) - + return filepath @@ -259,19 +258,19 @@ def temp_csv_file(temp_dir, simple_tissue_2d): def temp_csv_with_markers(temp_dir, tissue_with_markers): """Temporary CSV file with marker data.""" filepath = temp_dir / "test_data_markers.csv" - + df = pd.DataFrame({ 'x': tissue_with_markers.coordinates[:, 0], 'y': tissue_with_markers.coordinates[:, 1], 'cell_type': tissue_with_markers.cell_types, }) - + # Add marker columns for col in tissue_with_markers.marker_names: df[col] = tissue_with_markers.markers[col] - + df.to_csv(filepath, index=False) - + return filepath @@ -279,7 +278,7 @@ def temp_csv_with_markers(temp_dir, tissue_with_markers): def temp_json_file(temp_dir, simple_tissue_2d): """Temporary JSON file with sample data.""" filepath = temp_dir / "test_data.json" - + cells = [] for i in range(simple_tissue_2d.n_cells): cell = { @@ -288,7 +287,7 @@ def temp_json_file(temp_dir, simple_tissue_2d): 'cell_type': str(simple_tissue_2d.cell_types[i]), } cells.append(cell) - + data = { 'cells': cells, 'metadata': { @@ -296,10 +295,10 @@ def temp_json_file(temp_dir, simple_tissue_2d): 'n_cells': simple_tissue_2d.n_cells, } } - + with open(filepath, 'w') as f: json.dump(data, f) - + return filepath @@ -310,7 +309,7 @@ def temp_json_file(temp_dir, simple_tissue_2d): def assert_tissues_equal(tissue1, tissue2, check_markers=True): """ Assert that two SpatialTissueData objects are equal. - + Parameters ---------- tissue1, tissue2 : SpatialTissueData @@ -320,24 +319,24 @@ def assert_tissues_equal(tissue1, tissue2, check_markers=True): """ assert tissue1.n_cells == tissue2.n_cells assert tissue1.n_dims == tissue2.n_dims - + np.testing.assert_array_almost_equal( - tissue1.coordinates, + tissue1.coordinates, tissue2.coordinates ) - + np.testing.assert_array_equal( tissue1.cell_types, tissue2.cell_types ) - + if tissue1.is_multisample or tissue2.is_multisample: assert tissue1.is_multisample == tissue2.is_multisample np.testing.assert_array_equal( tissue1.sample_ids, tissue2.sample_ids ) - + if check_markers: if tissue1.markers is not None and tissue2.markers is not None: pd.testing.assert_frame_equal( diff --git a/tests/test_core.py b/tests/test_core.py index 057d5bb..c7350a5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,24 +4,22 @@ Tests SpatialTissueData, Cell, and validators. """ -import pytest + import numpy as np import pandas as pd -import tempfile -from pathlib import Path +import pytest -from spatialtissuepy.core.spatial_data import SpatialTissueData from spatialtissuepy.core.cell import Cell +from spatialtissuepy.core.spatial_data import SpatialTissueData from spatialtissuepy.core.validators import ( ValidationError, - validate_coordinates, validate_cell_types, - validate_sample_ids, + validate_coordinates, validate_marker_data, validate_positive_number, + validate_sample_ids, ) - # ============================================================================= # Fixtures # ============================================================================= @@ -205,7 +203,7 @@ def test_cell_distance_different_dims(self): cell1.distance_to(cell2) def test_cell_markers(self): - cell = Cell(0, x=0, y=0, cell_type='T_cell', + cell = Cell(0, x=0, y=0, cell_type='T_cell', markers={'CD3': 0.8, 'CD8': 0.5}) assert cell.get_marker('CD3') == 0.8 assert np.isnan(cell.get_marker('CD4')) diff --git a/tests/test_core_extended.py b/tests/test_core_extended.py index 51d8497..ea624f0 100644 --- a/tests/test_core_extended.py +++ b/tests/test_core_extended.py @@ -4,18 +4,17 @@ Additional tests for iteration, spatial queries, I/O, and neighborhoods. """ -import pytest -import numpy as np -import pandas as pd import tempfile -import json from pathlib import Path -from spatialtissuepy.core.spatial_data import SpatialTissueData +import numpy as np +import pandas as pd +import pytest + from spatialtissuepy.core.cell import Cell +from spatialtissuepy.core.spatial_data import SpatialTissueData from spatialtissuepy.core.validators import ValidationError - # ============================================================================= # Fixtures # ============================================================================= @@ -145,11 +144,11 @@ def test_to_dataframe_multisample(self, multisample_data): def test_to_csv_and_from_csv(self, simple_data): with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f: filepath = Path(f.name) - + try: simple_data.to_csv(filepath) loaded = SpatialTissueData.from_csv(filepath) - + assert loaded.n_cells == 100 assert loaded.n_cell_types == simple_data.n_cell_types np.testing.assert_array_almost_equal( @@ -168,7 +167,7 @@ def test_from_csv_missing_column(self): with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as f: f.write("x,y\n1,2\n3,4\n") filepath = Path(f.name) - + try: with pytest.raises(ValidationError, match="cell_type"): SpatialTissueData.from_csv(filepath) @@ -188,7 +187,7 @@ def test_add_neighborhoods(self, simple_data): n_types = simple_data.n_cell_types neigh = np.random.rand(100, n_types) data_with_neigh = simple_data.add_neighborhoods(neigh) - + assert data_with_neigh.has_neighborhoods assert data_with_neigh.neighborhoods.shape == (100, n_types) # Original should be unchanged (immutability) @@ -203,7 +202,7 @@ def test_neighborhoods_with_params(self, simple_data): neigh = np.random.rand(100, 4) params = {'method': 'knn', 'k': 30} data_with_neigh = simple_data.add_neighborhoods(neigh, params=params) - + assert data_with_neigh._neighborhood_params == params @@ -248,7 +247,7 @@ def test_single_cell(self): coords = np.array([[100.0, 200.0]]) types = ['T_cell'] data = SpatialTissueData(coords, types) - + assert data.n_cells == 1 assert data.n_cell_types == 1 @@ -256,7 +255,7 @@ def test_single_cell_type(self): coords = np.random.rand(100, 2) types = ['T_cell'] * 100 data = SpatialTissueData(coords, types) - + assert data.n_cell_types == 1 assert data.cell_type_counts['T_cell'] == 100 @@ -264,10 +263,10 @@ def test_3d_data(self): coords = np.random.rand(50, 3) * 100 types = ['A', 'B'] * 25 data = SpatialTissueData(coords, types) - + assert data.n_dims == 3 assert 'z' in data.bounds - + cell = data.get_cell(0) assert cell.z is not None assert cell.ndim == 3 @@ -276,7 +275,7 @@ def test_unicode_cell_types(self): coords = np.random.rand(10, 2) types = ['T细胞', 'Célula', 'κύτταρο'] * 3 + ['cell'] data = SpatialTissueData(coords, types) - + assert data.n_cell_types == 4 def test_very_close_cells(self): @@ -284,7 +283,7 @@ def test_very_close_cells(self): coords = np.random.rand(100, 2) * 0.001 types = ['A'] * 100 data = SpatialTissueData(coords, types) - + # All cells should be in a very small neighborhood indices = data.query_radius(coords[0], radius=0.1) assert len(indices) == 100 diff --git a/tests/test_custom_metrics.py b/tests/test_custom_metrics.py index b223fa9..543d226 100644 --- a/tests/test_custom_metrics.py +++ b/tests/test_custom_metrics.py @@ -5,29 +5,29 @@ including both decorator-based global registration and inline panel functions. """ -import pytest -import numpy as np from typing import Dict +import numpy as np +import pytest + from spatialtissuepy import SpatialTissueData from spatialtissuepy.summary import ( - # Custom metric API - register_custom_metric, - unregister_custom_metric, - list_custom_metrics, - clear_custom_metrics, - get_metric, - list_metrics, - describe_metric, + MetricRegistrationError, # Exceptions MetricValidationError, - MetricRegistrationError, + SpatialSummary, # Panel StatisticsPanel, - SpatialSummary, + clear_custom_metrics, + describe_metric, + get_metric, + list_custom_metrics, + list_metrics, + # Custom metric API + register_custom_metric, + unregister_custom_metric, ) - # ============================================================================= # Fixtures # ============================================================================= diff --git a/tests/test_io.py b/tests/test_io.py index 67aeed9..0189c0a 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -4,33 +4,31 @@ Tests reading and writing spatial data in various formats (CSV, JSON, AnnData). """ -import pytest +import json + import numpy as np import pandas as pd -import json -from pathlib import Path +import pytest -from spatialtissuepy import SpatialTissueData -from spatialtissuepy.io import read_csv, read_json, write_csv, write_json from spatialtissuepy.core.validators import ValidationError +from spatialtissuepy.io import read_csv, read_json, write_csv, write_json from tests.conftest import assert_tissues_equal - # ============================================================================= # CSV Reader Tests # ============================================================================= class TestReadCSV: """Tests for read_csv function.""" - + def test_read_csv_basic(self, temp_csv_file): """Test basic CSV reading.""" data = read_csv(temp_csv_file) - + assert data.n_cells == 100 assert data.n_dims == 2 assert len(data.cell_types_unique) == 4 - + def test_read_csv_custom_columns(self, temp_dir): """Test CSV reading with custom column names.""" # Create CSV with custom column names @@ -41,7 +39,7 @@ def test_read_csv_custom_columns(self, temp_dir): 'phenotype': ['A'] * 25 + ['B'] * 25, }) df.to_csv(filepath, index=False) - + # Read with custom column mapping data = read_csv( filepath, @@ -49,10 +47,10 @@ def test_read_csv_custom_columns(self, temp_dir): y_col='Y_centroid', celltype_col='phenotype' ) - + assert data.n_cells == 50 assert set(data.cell_types_unique) == {'A', 'B'} - + def test_read_csv_3d(self, temp_dir): """Test reading 3D coordinates from CSV.""" filepath = temp_dir / "data_3d.csv" @@ -63,21 +61,21 @@ def test_read_csv_3d(self, temp_dir): 'cell_type': ['A'] * 50, }) df.to_csv(filepath, index=False) - + data = read_csv(filepath, z_col='z') - + assert data.n_dims == 3 assert data.coordinates.shape == (50, 3) - + def test_read_csv_with_markers(self, temp_csv_with_markers): """Test reading CSV with marker expression data.""" data = read_csv(temp_csv_with_markers) - + assert data.markers is not None assert len(data.marker_names) == 4 assert 'CD3' in data.marker_names assert 'Ki67' in data.marker_names - + def test_read_csv_with_sample_ids(self, temp_dir): """Test reading CSV with sample IDs.""" filepath = temp_dir / "multisample.csv" @@ -88,13 +86,13 @@ def test_read_csv_with_sample_ids(self, temp_dir): 'patient_id': ['patient_1'] * 50 + ['patient_2'] * 50, }) df.to_csv(filepath, index=False) - + data = read_csv(filepath, sample_col='patient_id') - + assert data.is_multisample assert data.n_samples == 2 assert set(data.sample_ids_unique) == {'patient_1', 'patient_2'} - + def test_read_csv_missing_required_columns(self, temp_dir): """Test that missing required columns raises error.""" filepath = temp_dir / "bad_csv.csv" @@ -103,19 +101,19 @@ def test_read_csv_missing_required_columns(self, temp_dir): # Missing 'y' and 'cell_type' }) df.to_csv(filepath, index=False) - + with pytest.raises((ValidationError, KeyError)): read_csv(filepath) - + def test_read_csv_empty_file(self, temp_dir): """Test that empty CSV raises error.""" filepath = temp_dir / "empty.csv" df = pd.DataFrame(columns=['x', 'y', 'cell_type']) df.to_csv(filepath, index=False) - + with pytest.raises(ValidationError): read_csv(filepath) - + def test_read_csv_with_nan_coordinates(self, temp_dir): """Test that NaN coordinates raise error.""" filepath = temp_dir / "nan_coords.csv" @@ -125,10 +123,10 @@ def test_read_csv_with_nan_coordinates(self, temp_dir): 'cell_type': ['A', 'A', 'A'], }) df.to_csv(filepath, index=False) - + with pytest.raises(ValidationError, match="NaN"): read_csv(filepath) - + def test_read_csv_explicit_marker_cols(self, temp_dir): """Test specifying marker columns explicitly.""" filepath = temp_dir / "markers.csv" @@ -141,10 +139,10 @@ def test_read_csv_explicit_marker_cols(self, temp_dir): 'noise_column': ['text'] * 20, # Non-numeric, should be excluded }) df.to_csv(filepath, index=False) - + # Explicitly specify markers data = read_csv(filepath, marker_cols=['CD3', 'CD8']) - + assert set(data.marker_names) == {'CD3', 'CD8'} assert 'noise_column' not in data.marker_names @@ -155,41 +153,41 @@ def test_read_csv_explicit_marker_cols(self, temp_dir): class TestWriteCSV: """Tests for write_csv function.""" - + def test_write_csv_basic(self, simple_tissue_2d, temp_dir): """Test basic CSV writing.""" filepath = temp_dir / "output.csv" write_csv(simple_tissue_2d, filepath) - + assert filepath.exists() - + # Read back and verify data = read_csv(filepath) assert_tissues_equal(data, simple_tissue_2d, check_markers=False) - + def test_write_csv_with_markers(self, tissue_with_markers, temp_dir): """Test writing CSV with markers.""" filepath = temp_dir / "output_markers.csv" write_csv(tissue_with_markers, filepath) - + # Read back data = read_csv(filepath) assert_tissues_equal(data, tissue_with_markers, check_markers=True) - + def test_write_csv_multisample(self, multisample_tissue, temp_dir): """Test writing multi-sample CSV.""" filepath = temp_dir / "output_multisample.csv" write_csv(multisample_tissue, filepath) - + # Read back data = read_csv(filepath, sample_col='sample_id') assert_tissues_equal(data, multisample_tissue, check_markers=False) - + def test_write_csv_3d(self, simple_tissue_3d, temp_dir): """Test writing 3D coordinates.""" filepath = temp_dir / "output_3d.csv" write_csv(simple_tissue_3d, filepath) - + # Read back data = read_csv(filepath, z_col='z') assert data.n_dims == 3 @@ -197,17 +195,17 @@ def test_write_csv_3d(self, simple_tissue_3d, temp_dir): data.coordinates, simple_tissue_3d.coordinates ) - + def test_roundtrip_csv(self, tissue_with_markers, temp_dir): """Test complete roundtrip: write → read → verify equality.""" filepath = temp_dir / "roundtrip.csv" - + # Write write_csv(tissue_with_markers, filepath) - + # Read data = read_csv(filepath) - + # Verify assert_tissues_equal(data, tissue_with_markers, check_markers=True) @@ -218,131 +216,131 @@ def test_roundtrip_csv(self, tissue_with_markers, temp_dir): class TestReadJSON: """Tests for read_json function.""" - + def test_read_json_basic(self, temp_json_file): """Test basic JSON reading.""" data = read_json(temp_json_file) - + assert data.n_cells == 100 assert data.n_dims == 2 - + def test_read_json_custom_keys(self, temp_dir): """Test JSON reading with custom key names.""" filepath = temp_dir / "custom_keys.json" - + cells = [ {'X': 10, 'Y': 20, 'phenotype': 'A'}, {'X': 30, 'Y': 40, 'phenotype': 'B'}, ] - + with open(filepath, 'w') as f: json.dump({'cells': cells}, f) - + data = read_json( filepath, x_key='X', y_key='Y', celltype_key='phenotype' ) - + assert data.n_cells == 2 assert data.coordinates[0, 0] == 10 - + def test_read_json_flat_array(self, temp_dir): """Test reading JSON with flat array (no 'cells' wrapper).""" filepath = temp_dir / "flat.json" - + cells = [ {'x': 10, 'y': 20, 'cell_type': 'A'}, {'x': 30, 'y': 40, 'cell_type': 'B'}, ] - + with open(filepath, 'w') as f: json.dump(cells, f) - + data = read_json(filepath) assert data.n_cells == 2 - + def test_read_json_3d(self, temp_dir): """Test reading 3D coordinates from JSON.""" filepath = temp_dir / "3d.json" - + cells = [ {'x': 10, 'y': 20, 'z': 5, 'cell_type': 'A'}, {'x': 30, 'y': 40, 'z': 15, 'cell_type': 'B'}, ] - + with open(filepath, 'w') as f: json.dump({'cells': cells}, f) - + data = read_json(filepath, z_key='z') - + assert data.n_dims == 3 assert data.coordinates.shape == (2, 3) - + def test_read_json_with_markers(self, temp_dir): """Test reading JSON with marker data.""" filepath = temp_dir / "markers.json" - + cells = [ {'x': 10, 'y': 20, 'cell_type': 'A', 'CD3': 0.8, 'CD8': 0.5}, {'x': 30, 'y': 40, 'cell_type': 'B', 'CD3': 0.2, 'CD8': 0.9}, ] - + with open(filepath, 'w') as f: json.dump({'cells': cells}, f) - + data = read_json(filepath) - + assert data.markers is not None assert set(data.marker_names) == {'CD3', 'CD8'} - + def test_read_json_with_sample_ids(self, temp_dir): """Test reading JSON with sample IDs.""" filepath = temp_dir / "samples.json" - + cells = [ {'x': 10, 'y': 20, 'cell_type': 'A', 'sample': 's1'}, {'x': 30, 'y': 40, 'cell_type': 'B', 'sample': 's2'}, ] - + with open(filepath, 'w') as f: json.dump({'cells': cells}, f) - + data = read_json(filepath, sample_key='sample') - + assert data.is_multisample assert data.n_samples == 2 - + def test_read_json_missing_coordinates(self, temp_dir): """Test that missing coordinates raise error.""" filepath = temp_dir / "bad.json" - + cells = [ {'x': 10}, # Missing y {'y': 20}, # Missing x ] - + with open(filepath, 'w') as f: json.dump({'cells': cells}, f) - + with pytest.raises(ValidationError, match="missing coordinates"): read_json(filepath) - + def test_read_json_empty(self, temp_dir): """Test that empty JSON raises error.""" filepath = temp_dir / "empty.json" - + with open(filepath, 'w') as f: json.dump({'cells': []}, f) - + with pytest.raises(ValidationError, match="No cells"): read_json(filepath) - + def test_read_json_metadata(self, temp_dir): """Test that metadata is preserved.""" filepath = temp_dir / "meta.json" - + data_dict = { 'cells': [ {'x': 10, 'y': 20, 'cell_type': 'A'}, @@ -352,12 +350,12 @@ def test_read_json_metadata(self, temp_dir): 'patient': 'P001', } } - + with open(filepath, 'w') as f: json.dump(data_dict, f) - + data = read_json(filepath) - + assert 'tissue' in data.metadata assert data.metadata['tissue'] == 'lung' @@ -368,46 +366,46 @@ def test_read_json_metadata(self, temp_dir): class TestWriteJSON: """Tests for write_json function.""" - + def test_write_json_basic(self, simple_tissue_2d, temp_dir): """Test basic JSON writing.""" filepath = temp_dir / "output.json" write_json(simple_tissue_2d, filepath) - + assert filepath.exists() - + # Read back and verify data = read_json(filepath) assert_tissues_equal(data, simple_tissue_2d, check_markers=False) - + def test_write_json_with_markers(self, tissue_with_markers, temp_dir): """Test writing JSON with markers.""" filepath = temp_dir / "output_markers.json" write_json(tissue_with_markers, filepath) - + # Read back data = read_json(filepath) assert_tissues_equal(data, tissue_with_markers, check_markers=True) - + def test_write_json_multisample(self, multisample_tissue, temp_dir): """Test writing multi-sample JSON.""" filepath = temp_dir / "output_multisample.json" write_json(multisample_tissue, filepath) - + # Read back data = read_json(filepath, sample_key='sample_id') assert_tissues_equal(data, multisample_tissue, check_markers=False) - + def test_roundtrip_json(self, tissue_with_markers, temp_dir): """Test complete roundtrip: write → read → verify equality.""" filepath = temp_dir / "roundtrip.json" - + # Write write_json(tissue_with_markers, filepath) - + # Read data = read_json(filepath) - + # Verify assert_tissues_equal(data, tissue_with_markers, check_markers=True) @@ -418,16 +416,16 @@ def test_roundtrip_json(self, tissue_with_markers, temp_dir): class TestRealDataLoading: """Tests with real sample data from examples/sample_data/.""" - + def test_load_sample_csv(self, sample_data_dir): """Test loading the included sample CSV data.""" filepath = sample_data_dir / 'random_sample_data.csv' - + if not filepath.exists(): pytest.skip("Sample data file not found") - + data = read_csv(filepath) - + assert data.n_cells > 0 assert data.n_dims == 2 assert len(data.cell_types_unique) > 0 @@ -439,19 +437,19 @@ def test_load_sample_csv(self, sample_data_dir): class TestCrossFormat: """Test data consistency across different file formats.""" - + def test_csv_json_equivalence(self, simple_tissue_2d, temp_dir): """Test that CSV and JSON produce equivalent data.""" csv_path = temp_dir / "data.csv" json_path = temp_dir / "data.json" - + # Write in both formats write_csv(simple_tissue_2d, csv_path) write_json(simple_tissue_2d, json_path) - + # Read back data_csv = read_csv(csv_path) data_json = read_json(json_path) - + # Compare assert_tissues_equal(data_csv, data_json, check_markers=False) diff --git a/tests/test_lda.py b/tests/test_lda.py index 3326985..c8dfd10 100644 --- a/tests/test_lda.py +++ b/tests/test_lda.py @@ -5,13 +5,15 @@ including model fitting, transformation, topic analysis, and metrics. """ -import pytest import numpy as np import pandas as pd +import pytest # Check if sklearn is available try: - from sklearn.decomposition import LatentDirichletAllocation + from sklearn.decomposition import ( + LatentDirichletAllocation, # noqa: F401 (optional dependency probe) + ) HAS_SKLEARN = True except ImportError: HAS_SKLEARN = False @@ -22,28 +24,27 @@ from spatialtissuepy.lda import ( # Main class and functions SpatialLDA, - fit_spatial_lda, - compute_neighborhood_features, + compare_topics_across_samples, + compute_model_selection_metrics, compute_neighborhood_counts, + compute_neighborhood_features, + dominant_topic_per_cell, + fit_spatial_lda, + grid_sample, # Sampling poisson_disk_sample, - grid_sample, random_sample, + spatial_topic_consistency, stratified_sample, + topic_assignment_uncertainty, # Analysis topic_cell_type_matrix, - topic_enrichment, - dominant_topic_per_cell, - topic_assignment_uncertainty, - topic_spatial_distribution, - compare_topics_across_samples, - topic_prevalence_by_cell_type, # Metrics topic_coherence, topic_diversity, topic_exclusivity, - spatial_topic_consistency, - compute_model_selection_metrics, + topic_prevalence_by_cell_type, + topic_spatial_distribution, ) @@ -59,7 +60,7 @@ class TestNeighborhoodFeatures: """Tests for neighborhood feature computation.""" - + def test_compute_neighborhood_features_basic(self, small_tissue): """Test basic neighborhood feature computation.""" features = compute_neighborhood_features( @@ -68,13 +69,13 @@ def test_compute_neighborhood_features_basic(self, small_tissue): radius=50.0, normalize=True ) - + assert features.shape[0] == small_tissue.n_cells assert features.shape[1] == len(small_tissue.cell_types_unique) # Should be normalized (rows sum to ~1) row_sums = features.sum(axis=1) np.testing.assert_array_almost_equal(row_sums, np.ones(small_tissue.n_cells)) - + def test_compute_neighborhood_features_unnormalized(self, small_tissue): """Test unnormalized neighborhood features.""" features = compute_neighborhood_features( @@ -83,13 +84,13 @@ def test_compute_neighborhood_features_unnormalized(self, small_tissue): radius=50.0, normalize=False ) - + # Should be counts (integers or floats representing counts) assert features.shape[0] == small_tissue.n_cells assert features.shape[1] == len(small_tissue.cell_types_unique) # Values should be non-negative assert np.all(features >= 0) - + def test_compute_neighborhood_features_knn(self, small_tissue): """Test k-NN neighborhood method.""" features = compute_neighborhood_features( @@ -98,9 +99,9 @@ def test_compute_neighborhood_features_knn(self, small_tissue): k=10, normalize=True ) - + assert features.shape == (small_tissue.n_cells, len(small_tissue.cell_types_unique)) - + def test_compute_neighborhood_features_include_self(self, small_tissue): """Test including/excluding self in neighborhood.""" features_with_self = compute_neighborhood_features( @@ -109,17 +110,17 @@ def test_compute_neighborhood_features_include_self(self, small_tissue): include_self=True, normalize=False ) - + features_without_self = compute_neighborhood_features( small_tissue, radius=50, include_self=False, normalize=False ) - + # With self should have higher counts assert np.mean(features_with_self) >= np.mean(features_without_self) - + def test_compute_neighborhood_counts(self, small_tissue): """Test integer count computation.""" counts = compute_neighborhood_counts( @@ -128,7 +129,7 @@ def test_compute_neighborhood_counts(self, small_tissue): radius=50, include_self=True ) - + assert counts.shape == (small_tissue.n_cells, len(small_tissue.cell_types_unique)) # Should be integer-like assert np.allclose(counts, counts.astype(int)) @@ -140,7 +141,7 @@ def test_compute_neighborhood_counts(self, small_tissue): class TestSpatialLDA: """Tests for SpatialLDA class.""" - + def test_spatial_lda_initialization(self): """Test SpatialLDA initialization.""" model = SpatialLDA( @@ -148,12 +149,12 @@ def test_spatial_lda_initialization(self): neighborhood_radius=50, random_state=42 ) - + assert model.n_topics == 5 assert model.neighborhood_radius == 50 assert model.random_state == 42 assert not model._is_fitted - + def test_spatial_lda_fit_basic(self, small_tissue): """Test basic model fitting.""" model = SpatialLDA( @@ -161,21 +162,21 @@ def test_spatial_lda_fit_basic(self, small_tissue): neighborhood_radius=50, random_state=42 ) - + model.fit(small_tissue) - + assert model._is_fitted assert len(model.cell_types_) == len(small_tissue.cell_types_unique) assert model.topic_cell_type_matrix_ is not None assert model.topic_cell_type_matrix_.shape == (3, len(small_tissue.cell_types_unique)) - + def test_spatial_lda_transform(self, small_tissue): """Test transforming data to topic weights.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + topic_weights = model.transform(small_tissue) - + assert topic_weights.shape == (small_tissue.n_cells, 3) # Rows should sum to 1 (probability distribution) row_sums = topic_weights.sum(axis=1) @@ -183,53 +184,53 @@ def test_spatial_lda_transform(self, small_tissue): # All values should be in [0, 1] assert np.all(topic_weights >= 0) assert np.all(topic_weights <= 1) - + def test_spatial_lda_fit_transform(self, small_tissue): """Test fit_transform method.""" model = SpatialLDA(n_topics=3, random_state=42) - + topic_weights = model.fit_transform(small_tissue) - + assert model._is_fitted assert topic_weights.shape == (small_tissue.n_cells, 3) - + def test_spatial_lda_predict(self, small_tissue): """Test predicting dominant topics.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + dominant_topics = model.predict(small_tissue) - + assert len(dominant_topics) == small_tissue.n_cells assert dominant_topics.dtype == int assert np.all(dominant_topics >= 0) assert np.all(dominant_topics < 3) - + def test_spatial_lda_not_fitted_error(self, small_tissue): """Test error when transforming before fitting.""" model = SpatialLDA(n_topics=3) - + with pytest.raises(RuntimeError, match="not fitted"): model.transform(small_tissue) - + def test_spatial_lda_topic_summary(self, small_tissue): """Test topic summary generation.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + summary = model.topic_summary() - + assert isinstance(summary, pd.DataFrame) assert summary.shape == (3, len(small_tissue.cell_types_unique)) assert list(summary.columns) == model.cell_types_ - + def test_spatial_lda_top_cell_types(self, small_tissue): """Test getting top cell types per topic.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + top_types = model.top_cell_types_per_topic(n_top=3) - + assert len(top_types) == 3 # 3 topics for topic_idx in range(3): assert len(top_types[topic_idx]) == 3 # 3 top types @@ -237,35 +238,35 @@ def test_spatial_lda_top_cell_types(self, small_tissue): for cell_type, weight in top_types[topic_idx]: assert isinstance(cell_type, str) assert 0 <= weight <= 1 - + def test_spatial_lda_perplexity(self, small_tissue): """Test perplexity calculation.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + perplexity = model.perplexity(small_tissue) - + assert isinstance(perplexity, float) assert perplexity > 0 - + def test_spatial_lda_score(self, small_tissue): """Test log-likelihood score.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + score = model.score(small_tissue) - + assert isinstance(score, float) # Log-likelihood is typically negative assert score < 0 - + def test_spatial_lda_add_topics_to_data(self, small_tissue): """Test adding topic weights to data.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + data_with_topics = model.add_topics_to_data(small_tissue, prefix='topic') - + assert isinstance(data_with_topics, SpatialTissueData) assert data_with_topics.markers is not None # Should have topic columns @@ -277,53 +278,53 @@ def test_spatial_lda_add_topics_to_data(self, small_tissue): class TestSpatialLDAMultiSample: """Tests for multi-sample LDA fitting.""" - + def test_fit_multi_sample_basic(self, multisample_cohort): """Test fitting on multiple samples.""" model = SpatialLDA(n_topics=3, random_state=42) - + # Get list of samples - samples = [multisample_cohort.subset_sample(sid) + samples = [multisample_cohort.subset_sample(sid) for sid in multisample_cohort.sample_ids_unique] - + model.fit(samples) - + assert model._is_fitted # Cell types should be union of all samples all_types = set() for sample in samples: all_types.update(sample.cell_types_unique) assert set(model.cell_types_) == all_types - + def test_transform_after_multi_fit(self, multisample_cohort): """Test transforming individual samples after multi-fit.""" - samples = [multisample_cohort.subset_sample(sid) + samples = [multisample_cohort.subset_sample(sid) for sid in multisample_cohort.sample_ids_unique] - + model = SpatialLDA(n_topics=3, random_state=42) model.fit(samples) - + # Transform first sample topic_weights = model.transform(samples[0]) - + assert topic_weights.shape[0] == samples[0].n_cells assert topic_weights.shape[1] == 3 class TestSpatialLDAParameters: """Tests for different SpatialLDA parameters.""" - + def test_different_n_topics(self, small_tissue): """Test with different numbers of topics.""" for n_topics in [2, 5, 10]: model = SpatialLDA(n_topics=n_topics, random_state=42) model.fit(small_tissue) - + assert model.topic_cell_type_matrix_.shape[0] == n_topics - + topic_weights = model.transform(small_tissue) assert topic_weights.shape[1] == n_topics - + def test_different_neighborhood_methods(self, small_tissue): """Test different neighborhood methods.""" model_radius = SpatialLDA( @@ -333,7 +334,7 @@ def test_different_neighborhood_methods(self, small_tissue): random_state=42 ) model_radius.fit(small_tissue) - + model_knn = SpatialLDA( n_topics=3, neighborhood_method='knn', @@ -341,11 +342,11 @@ def test_different_neighborhood_methods(self, small_tissue): random_state=42 ) model_knn.fit(small_tissue) - + # Both should produce valid models assert model_radius._is_fitted assert model_knn._is_fitted - + def test_different_lda_hyperparameters(self, small_tissue): """Test different LDA hyperparameters.""" model = SpatialLDA( @@ -355,20 +356,20 @@ def test_different_lda_hyperparameters(self, small_tissue): max_iter=50, random_state=42 ) - + model.fit(small_tissue) assert model._is_fitted - + def test_reproducibility_with_seed(self, small_tissue): """Test reproducibility with random seed.""" model1 = SpatialLDA(n_topics=3, random_state=42) model1.fit(small_tissue) weights1 = model1.transform(small_tissue) - + model2 = SpatialLDA(n_topics=3, random_state=42) model2.fit(small_tissue) weights2 = model2.transform(small_tissue) - + # Should produce identical results np.testing.assert_array_almost_equal(weights1, weights2) @@ -379,7 +380,7 @@ def test_reproducibility_with_seed(self, small_tissue): class TestFitSpatialLDA: """Tests for fit_spatial_lda convenience function.""" - + def test_fit_spatial_lda_basic(self, small_tissue): """Test basic usage of fit_spatial_lda.""" model = fit_spatial_lda( @@ -388,11 +389,11 @@ def test_fit_spatial_lda_basic(self, small_tissue): neighborhood_radius=50, random_state=42 ) - + assert isinstance(model, SpatialLDA) assert model._is_fitted assert model.n_topics == 3 - + def test_fit_spatial_lda_with_kwargs(self, small_tissue): """Test passing additional kwargs.""" model = fit_spatial_lda( @@ -402,7 +403,7 @@ def test_fit_spatial_lda_with_kwargs(self, small_tissue): max_iter=50, random_state=42 ) - + assert model.max_iter == 50 @@ -412,25 +413,25 @@ def test_fit_spatial_lda_with_kwargs(self, small_tissue): class TestSampling: """Tests for spatial sampling methods.""" - + def test_random_sample(self, medium_tissue): """Test random sampling.""" indices = random_sample(medium_tissue, n_samples=50, seed=42) - + assert len(indices) == 50 assert np.all(indices >= 0) assert np.all(indices < medium_tissue.n_cells) # Should be unique assert len(set(indices)) == 50 - + def test_grid_sample(self, medium_tissue): """Test grid-based sampling.""" indices = grid_sample(medium_tissue, grid_size=5) - + assert len(indices) > 0 assert np.all(indices >= 0) assert np.all(indices < medium_tissue.n_cells) - + def test_stratified_sample(self, medium_tissue): """Test stratified sampling by cell type.""" indices = stratified_sample( @@ -438,12 +439,12 @@ def test_stratified_sample(self, medium_tissue): n_per_type=10, seed=42 ) - + assert len(indices) > 0 # Check that we have samples from each type sampled_types = set(medium_tissue.cell_types[indices]) assert len(sampled_types) >= 1 - + def test_poisson_disk_sample(self, medium_tissue): """Test Poisson disk sampling.""" indices = poisson_disk_sample( @@ -451,7 +452,7 @@ def test_poisson_disk_sample(self, medium_tissue): min_distance=30, seed=42 ) - + assert len(indices) > 0 # Verify minimum distance constraint coords = medium_tissue.coordinates[indices] @@ -467,78 +468,78 @@ def test_poisson_disk_sample(self, medium_tissue): class TestAnalysisFunctions: """Tests for topic analysis functions.""" - + def test_topic_cell_type_matrix(self, small_tissue): """Test extracting topic-cell type matrix.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + matrix = topic_cell_type_matrix(model) - + assert isinstance(matrix, pd.DataFrame) assert matrix.shape == (3, len(small_tissue.cell_types_unique)) - + def test_dominant_topic_per_cell(self, small_tissue): """Test getting dominant topics.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + dominant = dominant_topic_per_cell(model, small_tissue) - + assert len(dominant) == small_tissue.n_cells assert np.all(dominant >= 0) assert np.all(dominant < 3) - + def test_topic_assignment_uncertainty(self, small_tissue): """Test computing topic assignment uncertainty.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + uncertainty = topic_assignment_uncertainty(model, small_tissue) - + assert len(uncertainty) == small_tissue.n_cells assert np.all(uncertainty >= 0) # Maximum entropy for 3 topics max_entropy = np.log2(3) assert np.all(uncertainty <= max_entropy + 1e-6) - + def test_topic_prevalence_by_cell_type(self, small_tissue): """Test computing topic prevalence by cell type.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + prevalence = topic_prevalence_by_cell_type(model, small_tissue) - + assert isinstance(prevalence, pd.DataFrame) assert prevalence.shape[0] == len(small_tissue.cell_types_unique) # Should have cell_type, n_cells, and 2 columns per topic (mean, std) assert prevalence.shape[1] == 2 + 2 * 3 - + def test_topic_spatial_distribution(self, small_tissue): """Test spatial distribution of topics.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + result = topic_spatial_distribution( model, small_tissue, topic_idx=0 ) - + assert 'positions' in result assert 'centroid' in result assert 'spread' in result - + def test_compare_topics_across_samples(self, multisample_cohort): """Test comparing topics across samples.""" - samples = [multisample_cohort.subset_sample(sid) + samples = [multisample_cohort.subset_sample(sid) for sid in multisample_cohort.sample_ids_unique[:2]] - + model = SpatialLDA(n_topics=3, random_state=42) model.fit(samples) - + comparison = compare_topics_across_samples(model, samples) - + assert isinstance(comparison, pd.DataFrame) # Should have entries for each sample assert len(comparison) >= 2 @@ -550,50 +551,50 @@ def test_compare_topics_across_samples(self, multisample_cohort): class TestMetrics: """Tests for topic quality metrics.""" - + def test_topic_coherence(self, small_tissue): """Test topic coherence calculation.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + coherence = topic_coherence(model, small_tissue) - + assert isinstance(coherence, float) - + def test_topic_diversity(self, small_tissue): """Test topic diversity calculation.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + diversity = topic_diversity(model) - + assert isinstance(diversity, float) assert diversity >= 0 - + def test_topic_exclusivity(self, small_tissue): """Test topic exclusivity calculation.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + exclusivity = topic_exclusivity(model) - + assert isinstance(exclusivity, dict) assert len(exclusivity) == 3 - + def test_spatial_topic_consistency(self, small_tissue): """Test spatial consistency of topics.""" model = SpatialLDA(n_topics=3, random_state=42) model.fit(small_tissue) - + consistency = spatial_topic_consistency( model, small_tissue, radius=50 ) - + assert isinstance(consistency, dict) assert 'agreement_rate' in consistency - + def test_compute_model_selection_metrics(self, small_tissue): """Test computing multiple metrics for model selection.""" # Using list signature @@ -603,7 +604,7 @@ def test_compute_model_selection_metrics(self, small_tissue): neighborhood_radius=50, random_state=42 ) - + assert isinstance(metrics, pd.DataFrame) assert 'perplexity' in metrics.columns assert 'log_likelihood' in metrics.columns @@ -616,7 +617,7 @@ def test_compute_model_selection_metrics(self, small_tissue): class TestLDAIntegration: """Integration tests for LDA workflow.""" - + def test_complete_lda_workflow(self, medium_tissue): """Test complete LDA analysis workflow.""" # 1. Fit model @@ -626,36 +627,36 @@ def test_complete_lda_workflow(self, medium_tissue): random_state=42 ) model.fit(medium_tissue) - + # 2. Get topic assignments topic_weights = model.transform(medium_tissue) assert topic_weights.shape == (medium_tissue.n_cells, 5) - + # 3. Get dominant topics dominant_topics = model.predict(medium_tissue) assert len(dominant_topics) == medium_tissue.n_cells - + # 4. Analyze topics summary = model.topic_summary() assert isinstance(summary, pd.DataFrame) - + top_types = model.top_cell_types_per_topic(n_top=3) assert len(top_types) == 5 - + # 5. Compute metrics diversity = topic_diversity(model) assert isinstance(diversity, float) - + coherence = topic_coherence(model, medium_tissue) assert isinstance(coherence, float) - + # 6. Add to data data_with_topics = model.add_topics_to_data(medium_tissue) assert data_with_topics.markers is not None - + def test_model_comparison_workflow(self, medium_tissue): """Test comparing models with different n_topics.""" - + # Correct usage of compute_model_selection_metrics metrics_df = compute_model_selection_metrics( [3, 5], @@ -663,27 +664,27 @@ def test_model_comparison_workflow(self, medium_tissue): neighborhood_radius=50, random_state=42 ) - + assert isinstance(metrics_df, pd.DataFrame) assert len(metrics_df) == 2 assert 'perplexity' in metrics_df.columns - + def test_multi_sample_workflow(self, multisample_cohort): """Test multi-sample analysis workflow.""" # Get samples sample_ids = multisample_cohort.sample_ids_unique[:3] samples = [multisample_cohort.subset_sample(sid) for sid in sample_ids] - + # Fit joint model model = SpatialLDA(n_topics=4, random_state=42) model.fit(samples) - + # Transform each sample sample_topics = [] for sample in samples: weights = model.transform(sample) sample_topics.append(weights) - + # Compare across samples comparison = compare_topics_across_samples(model, samples) assert isinstance(comparison, pd.DataFrame) @@ -695,61 +696,61 @@ def test_multi_sample_workflow(self, multisample_cohort): class TestLDAEdgeCases: """Tests for edge cases and error handling.""" - + def test_single_cell_type(self): """Test with tissue containing single cell type.""" coords = np.random.rand(50, 2) * 100 types = np.array(['A'] * 50) data = SpatialTissueData(coords, types) - + model = SpatialLDA(n_topics=2, random_state=42) model.fit(data) - + # Should still work but topics may be similar assert model._is_fitted assert model.topic_cell_type_matrix_.shape == (2, 1) - + def test_more_topics_than_cell_types(self, small_tissue): """Test with more topics than cell types.""" n_cell_types = len(small_tissue.cell_types_unique) - + model = SpatialLDA( n_topics=n_cell_types + 2, random_state=42 ) model.fit(small_tissue) - + # Should fit but may have redundant topics assert model._is_fitted - + def test_small_sample(self): """Test with very small sample.""" coords = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]]) types = np.array(['A', 'B', 'A', 'B', 'A']) data = SpatialTissueData(coords, types) - + model = SpatialLDA(n_topics=2, random_state=42) - + try: model.fit(data) assert model._is_fitted except Exception: # May fail with very small sample - acceptable pytest.skip("Sample too small for LDA") - + def test_isolated_cells(self): """Test with isolated cells (no neighbors).""" # Create widely spaced cells coords = np.array([[0, 0], [1000, 1000], [2000, 2000]]) types = np.array(['A', 'B', 'C']) data = SpatialTissueData(coords, types) - + model = SpatialLDA( n_topics=2, neighborhood_radius=10, # Small radius random_state=42 ) - + model.fit(data) # Should work even with sparse neighborhoods assert model._is_fitted @@ -762,37 +763,37 @@ def test_isolated_cells(self): @pytest.mark.slow class TestLDAPerformance: """Performance tests for LDA operations.""" - + def test_fit_performance(self, large_tissue): """Test fitting performance with large dataset.""" import time - + model = SpatialLDA( n_topics=5, neighborhood_radius=50, max_iter=50, # Limit iterations for speed random_state=42 ) - + start = time.time() model.fit(large_tissue) elapsed = time.time() - start - + # Should complete in reasonable time assert elapsed < 150.0 # Increased for slow CI assert model._is_fitted - + def test_transform_performance(self, large_tissue): """Test transform performance.""" import time - + model = SpatialLDA(n_topics=5, max_iter=50, random_state=42) model.fit(large_tissue) - + start = time.time() topic_weights = model.transform(large_tissue) elapsed = time.time() - start - + # Should be fast assert elapsed < 10.0 assert topic_weights.shape[0] == large_tissue.n_cells diff --git a/tests/test_network.py b/tests/test_network.py index b513936..5460ad5 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,8 +5,8 @@ and assortativity analysis. """ -import pytest import numpy as np +import pytest # Check if networkx is available try: @@ -15,54 +15,53 @@ except ImportError: HAS_NETWORKX = False -from spatialtissuepy import SpatialTissueData if HAS_NETWORKX: from spatialtissuepy.network import ( + # CellGraph + CellGraph, # Graph construction GraphMethod, - build_graph, - build_proximity_graph, - build_knn_graph, + articulation_points, + attribute_mixing_matrix, + average_clustering, + average_neighbor_degree, + average_shortest_path_length, + betweenness_centrality, + bridges, build_delaunay_graph, build_gabriel_graph, - # CellGraph - CellGraph, - # Centrality - degree_centrality, - betweenness_centrality, - closeness_centrality, - eigenvector_centrality, - pagerank, + build_graph, + build_knn_graph, + build_proximity_graph, centrality_by_type, - mean_centrality_by_type, + closeness_centrality, + clustering_by_type, # Clustering clustering_coefficient, - average_clustering, - transitivity, - triangles, - clustering_by_type, - connected_components, - n_connected_components, - largest_component_size, - bridges, - articulation_points, # Communicability communicability, communicability_between_types, - shortest_path_length_between_types, - average_shortest_path_length, - diameter, - global_efficiency, - local_efficiency, + connected_components, # Assortativity degree_assortativity, - type_assortativity, - attribute_mixing_matrix, - homophily_ratio, + # Centrality + degree_centrality, + diameter, + eigenvector_centrality, + global_efficiency, heterophily_ratio, - average_neighbor_degree, + homophily_ratio, + largest_component_size, + local_efficiency, + mean_centrality_by_type, + n_connected_components, neighbor_type_distribution, + pagerank, + shortest_path_length_between_types, + transitivity, + triangles, + type_assortativity, ) @@ -78,53 +77,53 @@ class TestGraphConstruction: """Tests for graph construction methods.""" - + def test_build_proximity_graph_basic(self, small_tissue): """Test basic proximity graph construction.""" G = build_proximity_graph( small_tissue.coordinates, radius=50.0 ) - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == small_tissue.n_cells assert G.number_of_edges() > 0 - + def test_build_proximity_graph_radius_effect(self, small_tissue): """Test that larger radius creates more edges.""" G_small = build_proximity_graph(small_tissue.coordinates, radius=20) G_large = build_proximity_graph(small_tissue.coordinates, radius=50) - + # Larger radius should create more connections assert G_large.number_of_edges() >= G_small.number_of_edges() - + def test_build_proximity_graph_empty(self): """Test proximity graph with no points.""" coords = np.array([]).reshape(0, 2) G = build_proximity_graph(coords, radius=50) - + assert G.number_of_nodes() == 0 assert G.number_of_edges() == 0 - + def test_build_proximity_graph_isolated(self): """Test proximity graph with isolated points.""" coords = np.array([[0, 0], [1000, 1000]]) G = build_proximity_graph(coords, radius=10) - + assert G.number_of_nodes() == 2 assert G.number_of_edges() == 0 - + def test_build_knn_graph_basic(self, small_tissue): """Test k-nearest neighbors graph.""" G = build_knn_graph(small_tissue.coordinates, k=5) - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == small_tissue.n_cells - + # Each node should have at most k neighbors (directed) # In undirected graph, may have more assert G.number_of_edges() > 0 - + def test_build_knn_graph_mutual(self, small_tissue): """Test mutual k-NN graph.""" G_mutual = build_knn_graph( @@ -137,26 +136,26 @@ def test_build_knn_graph_mutual(self, small_tissue): k=5, mutual_knn=False ) - + # Mutual should have fewer edges assert G_mutual.number_of_edges() <= G_regular.number_of_edges() - + def test_build_knn_graph_k_larger_than_n(self): """Test k-NN with k larger than number of points.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) G = build_knn_graph(coords, k=10) - + # Should connect to all available neighbors assert G.number_of_nodes() == 3 - + def test_build_delaunay_graph_basic(self, small_tissue): """Test Delaunay triangulation graph.""" G = build_delaunay_graph(small_tissue.coordinates[:, :2]) - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == small_tissue.n_cells assert G.number_of_edges() > 0 - + def test_build_delaunay_graph_pruning(self, small_tissue): """Test Delaunay graph with edge length pruning.""" G_full = build_delaunay_graph(small_tissue.coordinates[:, :2]) @@ -164,35 +163,35 @@ def test_build_delaunay_graph_pruning(self, small_tissue): small_tissue.coordinates[:, :2], max_edge_length=30 ) - + # Pruned should have fewer or equal edges assert G_pruned.number_of_edges() <= G_full.number_of_edges() - + def test_build_delaunay_graph_insufficient_points(self): """Test Delaunay with too few points.""" coords = np.array([[0, 0], [1, 1]]) G = build_delaunay_graph(coords) - + # Delaunay needs at least 3 non-collinear points # Should return empty or minimal graph assert G.number_of_nodes() == 2 - + def test_build_gabriel_graph_basic(self, small_tissue): """Test Gabriel graph construction.""" G = build_gabriel_graph(small_tissue.coordinates[:, :2]) - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == small_tissue.n_cells - + def test_build_gabriel_subset_of_delaunay(self, small_tissue): """Test Gabriel graph is subset of Delaunay.""" coords = small_tissue.coordinates[:, :2] G_delaunay = build_delaunay_graph(coords) G_gabriel = build_gabriel_graph(coords) - + # Gabriel should have fewer or equal edges assert G_gabriel.number_of_edges() <= G_delaunay.number_of_edges() - + def test_build_graph_with_method_enum(self, small_tissue): """Test build_graph with GraphMethod enum.""" G = build_graph( @@ -200,10 +199,10 @@ def test_build_graph_with_method_enum(self, small_tissue): method=GraphMethod.PROXIMITY, radius=50 ) - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == small_tissue.n_cells - + def test_build_graph_with_string_method(self, small_tissue): """Test build_graph with string method.""" G = build_graph( @@ -211,9 +210,9 @@ def test_build_graph_with_string_method(self, small_tissue): method='knn', k=5 ) - + assert isinstance(G, nx.Graph) - + def test_build_graph_invalid_method(self, small_tissue): """Test error with invalid method.""" with pytest.raises((ValueError, AttributeError)): @@ -229,7 +228,7 @@ def test_build_graph_invalid_method(self, small_tissue): class TestCellGraph: """Tests for CellGraph class.""" - + def test_cellgraph_from_spatial_data(self, small_tissue): """Test creating CellGraph from SpatialTissueData.""" graph = CellGraph.from_spatial_data( @@ -237,121 +236,121 @@ def test_cellgraph_from_spatial_data(self, small_tissue): method='proximity', radius=50 ) - + assert isinstance(graph, CellGraph) assert graph.n_nodes == small_tissue.n_cells assert graph.n_edges > 0 - + def test_cellgraph_from_coordinates(self): """Test creating CellGraph from coordinates.""" coords = np.random.rand(50, 2) * 100 types = np.array(['A', 'B'] * 25) - + graph = CellGraph.from_coordinates( coords, types, method='knn', k=5 ) - + assert graph.n_nodes == 50 assert len(graph.cell_types_unique) == 2 - + def test_cellgraph_properties(self, small_tissue): """Test CellGraph properties.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + assert graph.n_nodes > 0 assert graph.n_edges >= 0 assert graph.method == 'proximity' assert 0 <= graph.density <= 1 assert len(graph.cell_types_unique) > 0 - + def test_cellgraph_get_nodes_by_type(self, small_tissue): """Test getting nodes by cell type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + cell_type = graph.cell_types_unique[0] nodes = graph.get_nodes_by_type(cell_type) - + assert len(nodes) > 0 assert all(graph.cell_types[i] == cell_type for i in nodes) - + def test_cellgraph_subgraph_by_type(self, small_tissue): """Test extracting subgraph by cell type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + if len(graph.cell_types_unique) < 2: pytest.skip("Need multiple cell types") - + cell_type = graph.cell_types_unique[0] subgraph = graph.subgraph_by_type(cell_type) - + assert subgraph.n_nodes < graph.n_nodes assert len(subgraph.cell_types_unique) == 1 assert subgraph.cell_types_unique[0] == cell_type - + def test_cellgraph_subgraph_multiple_types(self, small_tissue): """Test subgraph with multiple types.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + if len(graph.cell_types_unique) < 2: pytest.skip("Need multiple cell types") - + types_to_keep = graph.cell_types_unique[:2] subgraph = graph.subgraph_by_type(types_to_keep) - + assert set(subgraph.cell_types_unique).issubset(set(types_to_keep)) - + def test_cellgraph_neighbors_of_type(self, small_tissue): """Test getting neighbors filtered by type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + # Get node with neighbors node = 0 while graph.G.degree(node) == 0 and node < graph.n_nodes - 1: node += 1 - + if graph.G.degree(node) == 0: pytest.skip("No connected nodes") - + all_neighbors = graph.neighbors_of_type(node) assert len(all_neighbors) > 0 - + # Filter by type if len(graph.cell_types_unique) > 1: cell_type = graph.cell_types_unique[0] filtered_neighbors = graph.neighbors_of_type(node, cell_type) assert len(filtered_neighbors) <= len(all_neighbors) - + def test_cellgraph_edge_type_counts(self, small_tissue): """Test counting edges by type pairs.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + counts = graph.edge_type_counts() - + assert isinstance(counts, dict) # Total edges should match total_edges = sum(counts.values()) assert total_edges == graph.n_edges - + def test_cellgraph_to_networkx(self, small_tissue): """Test converting to NetworkX graph.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + G = graph.to_networkx() - + assert isinstance(G, nx.Graph) assert G.number_of_nodes() == graph.n_nodes assert G.number_of_edges() == graph.n_edges - + def test_cellgraph_repr_str(self, small_tissue): """Test string representations.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + repr_str = repr(graph) str_str = str(graph) - + assert 'CellGraph' in repr_str assert 'CellGraph' in str_str assert str(graph.n_nodes) in str_str @@ -363,72 +362,72 @@ def test_cellgraph_repr_str(self, small_tissue): class TestCentrality: """Tests for centrality measures.""" - + def test_degree_centrality_basic(self, small_tissue): """Test degree centrality calculation.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + centrality = degree_centrality(graph.G) - + assert len(centrality) == graph.n_nodes assert all(0 <= v <= 1 for v in centrality.values()) - + def test_betweenness_centrality_basic(self, small_tissue): """Test betweenness centrality.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + centrality = betweenness_centrality(graph.G) - + assert len(centrality) == graph.n_nodes assert all(0 <= v <= 1 for v in centrality.values()) - + def test_closeness_centrality_basic(self, small_tissue): """Test closeness centrality.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + centrality = closeness_centrality(graph.G) - + assert len(centrality) == graph.n_nodes assert all(0 <= v <= 1 for v in centrality.values()) - + def test_eigenvector_centrality_basic(self, small_tissue): """Test eigenvector centrality.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + try: centrality = eigenvector_centrality(graph.G) assert len(centrality) == graph.n_nodes except nx.PowerIterationFailedConvergence: pytest.skip("Eigenvector centrality did not converge") - + def test_pagerank_basic(self, small_tissue): """Test PageRank centrality.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + centrality = pagerank(graph.G) - + assert len(centrality) == graph.n_nodes # PageRank sums to 1 assert abs(sum(centrality.values()) - 1.0) < 0.01 - + def test_centrality_by_type(self, small_tissue): """Test centrality aggregated by cell type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + result = centrality_by_type(graph, metric='degree') - + assert isinstance(result, dict) for cell_type in graph.cell_types_unique: assert cell_type in result assert isinstance(result[cell_type], dict) assert 'mean' in result[cell_type] - + def test_mean_centrality_by_type(self, small_tissue): """Test mean centrality by type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + result = mean_centrality_by_type(graph, metric='betweenness') - + assert isinstance(result, dict) for cell_type in graph.cell_types_unique: assert cell_type in result @@ -442,101 +441,101 @@ def test_mean_centrality_by_type(self, small_tissue): class TestClustering: """Tests for clustering metrics.""" - + def test_clustering_coefficient_basic(self, small_tissue): """Test local clustering coefficient.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + clustering = clustering_coefficient(graph.G) - + assert len(clustering) == graph.n_nodes assert all(0 <= v <= 1 for v in clustering.values()) - + def test_average_clustering_basic(self, small_tissue): """Test average clustering coefficient.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + avg_clustering = average_clustering(graph.G) - + assert isinstance(avg_clustering, float) assert 0 <= avg_clustering <= 1 - + def test_transitivity_basic(self, small_tissue): """Test transitivity (global clustering).""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + trans = transitivity(graph.G) - + assert isinstance(trans, float) assert 0 <= trans <= 1 - + def test_triangles_basic(self, small_tissue): """Test triangle counting.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + tri = triangles(graph.G) - + assert len(tri) == graph.n_nodes assert all(v >= 0 for v in tri.values()) - + def test_clustering_by_type(self, small_tissue): """Test clustering by cell type.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + result = clustering_by_type(graph) - + assert isinstance(result, dict) for cell_type in graph.cell_types_unique: assert cell_type in result - + def test_connected_components(self, small_tissue): """Test connected component detection.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + components = connected_components(graph.G) - + assert isinstance(components, list) # Union of components should be all nodes all_nodes = set() for comp in components: all_nodes.update(comp) assert len(all_nodes) <= graph.n_nodes - + def test_n_connected_components(self, small_tissue): """Test counting connected components.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + n_comp = n_connected_components(graph.G) - + assert isinstance(n_comp, int) assert n_comp >= 1 assert n_comp <= graph.n_nodes - + def test_largest_component_size(self, small_tissue): """Test largest component size.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + size = largest_component_size(graph.G) - + assert isinstance(size, int) assert 1 <= size <= graph.n_nodes - + def test_bridges(self, small_tissue): """Test bridge detection.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + bridge_edges = bridges(graph.G) - + assert isinstance(bridge_edges, list) # Bridges should be subset of edges assert len(bridge_edges) <= graph.n_edges - + def test_articulation_points(self, small_tissue): """Test articulation point detection.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + art_points = articulation_points(graph.G) - + assert isinstance(art_points, list) # Articulation points should be subset of nodes assert len(art_points) <= graph.n_nodes @@ -548,17 +547,17 @@ def test_articulation_points(self, small_tissue): class TestCommunicability: """Tests for communicability metrics.""" - + def test_communicability_basic(self, small_tissue): """Test communicability calculation.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + # Only test on small graphs (computationally expensive) if graph.n_nodes > 50: pytest.skip("Graph too large for communicability") - + comm = communicability(graph.G) - + assert isinstance(comm, dict) # Diagonal elements should be largest for node in graph.G.nodes(): @@ -567,49 +566,49 @@ def test_communicability_basic(self, small_tissue): (comm[node][j] for j in comm[node] if j != node), default=0 ) - + def test_communicability_between_types(self, small_tissue): """Test communicability between cell types.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + if len(graph.cell_types_unique) < 2: pytest.skip("Need multiple cell types") - + type_a = graph.cell_types_unique[0] type_b = graph.cell_types_unique[1] - + if graph.n_nodes > 50: pytest.skip("Graph too large") - + comm = communicability_between_types(graph, type_a, type_b) - + assert isinstance(comm, float) assert comm >= 0 - + def test_shortest_path_length_basic(self, small_tissue): """Test shortest path lengths.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + if len(graph.cell_types_unique) < 2: pytest.skip("Need multiple cell types") - + type_a = graph.cell_types_unique[0] type_b = graph.cell_types_unique[1] - + result = shortest_path_length_between_types(graph, type_a, type_b) - + assert 'mean' in result assert 'median' in result assert 'min' in result assert 'max' in result - + if result['mean'] is not None: assert result['mean'] >= 0 - + def test_average_shortest_path_length(self, small_tissue): """Test average shortest path length.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + try: avg_path = average_shortest_path_length(graph.G) assert isinstance(avg_path, float) @@ -617,11 +616,11 @@ def test_average_shortest_path_length(self, small_tissue): except nx.NetworkXError: # Graph is not connected pytest.skip("Graph is not connected") - + def test_diameter(self, small_tissue): """Test graph diameter.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + try: diam = diameter(graph.G) assert isinstance(diam, int) @@ -629,22 +628,22 @@ def test_diameter(self, small_tissue): except nx.NetworkXError: # Graph is not connected pytest.skip("Graph is not connected") - + def test_global_efficiency(self, small_tissue): """Test global efficiency.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + eff = global_efficiency(graph.G) - + assert isinstance(eff, float) assert 0 <= eff <= 1 - + def test_local_efficiency(self, small_tissue): """Test local efficiency.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + eff = local_efficiency(graph.G) - + assert isinstance(eff, float) assert 0 <= eff <= 1 @@ -655,80 +654,80 @@ def test_local_efficiency(self, small_tissue): class TestAssortativity: """Tests for assortativity and mixing.""" - + def test_degree_assortativity_basic(self, small_tissue): """Test degree assortativity.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + assort = degree_assortativity(graph.G) - + assert isinstance(assort, float) assert -1 <= assort <= 1 - + def test_type_assortativity_basic(self, small_tissue): """Test cell type assortativity.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + assort = type_assortativity(graph) - + assert isinstance(assort, float) assert -1 <= assort <= 1 - + def test_attribute_mixing_matrix(self, small_tissue): """Test mixing matrix calculation.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + matrix = attribute_mixing_matrix(graph) - + n_types = len(graph.cell_types_unique) assert matrix.shape == (n_types, n_types) # Matrix should sum to 1 assert abs(matrix.values.sum() - 1.0) < 0.01 - + def test_homophily_ratio(self, small_tissue): """Test homophily ratio.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + homophily = homophily_ratio(graph) - + assert isinstance(homophily, float) assert 0 <= homophily <= 1 - + def test_heterophily_ratio(self, small_tissue): """Test heterophily ratio.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + heterophily = heterophily_ratio(graph) - + assert isinstance(heterophily, float) assert 0 <= heterophily <= 1 - + def test_homophily_heterophily_sum(self, small_tissue): """Test homophily + heterophily = 1.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + homophily = homophily_ratio(graph) heterophily = heterophily_ratio(graph) - + assert abs(homophily + heterophily - 1.0) < 0.01 - + def test_average_neighbor_degree(self, small_tissue): """Test average neighbor degree.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + avg_deg = average_neighbor_degree(graph.G) - + assert isinstance(avg_deg, dict) assert len(avg_deg) == graph.n_nodes assert all(v >= 0 for v in avg_deg.values()) - + def test_neighbor_type_distribution(self, small_tissue): """Test neighbor type distribution.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + cell_type = graph.cell_types_unique[0] dist = neighbor_type_distribution(graph, cell_type=cell_type) - + assert isinstance(dist, dict) # Should sum to 1 assert abs(sum(dist.values()) - 1.0) < 0.01 @@ -740,7 +739,7 @@ def test_neighbor_type_distribution(self, small_tissue): class TestNetworkIntegration: """Integration tests for network module.""" - + def test_complete_network_workflow(self, medium_tissue): """Test complete network analysis workflow.""" # 1. Build graph @@ -750,28 +749,28 @@ def test_complete_network_workflow(self, medium_tissue): radius=50 ) assert graph.n_nodes == medium_tissue.n_cells - + # 2. Compute centrality degree_cent = centrality_by_type(graph, metric='degree') assert len(degree_cent) == len(graph.cell_types_unique) - + # 3. Compute clustering avg_clust = average_clustering(graph.G) assert 0 <= avg_clust <= 1 - + # 4. Analyze assortativity type_assort = type_assortativity(graph) assert -1 <= type_assort <= 1 - + # 5. Mixing matrix mixing = attribute_mixing_matrix(graph) assert mixing.shape[0] == len(graph.cell_types_unique) - + def test_graph_method_comparison(self, small_tissue): """Test different graph construction methods.""" methods = ['proximity', 'knn', 'delaunay'] graphs = {} - + for method in methods: if method == 'proximity': graphs[method] = CellGraph.from_spatial_data( @@ -785,27 +784,27 @@ def test_graph_method_comparison(self, small_tissue): graphs[method] = CellGraph.from_spatial_data( small_tissue, method=method ) - + # All should produce valid graphs for method, graph in graphs.items(): assert graph.n_nodes == small_tissue.n_cells assert graph.n_edges > 0 - + def test_subgraph_analysis(self, small_tissue): """Test analyzing subgraphs.""" graph = CellGraph.from_spatial_data(small_tissue, radius=50) - + if len(graph.cell_types_unique) < 2: pytest.skip("Need multiple cell types") - + # Extract subgraph cell_type = graph.cell_types_unique[0] subgraph = graph.subgraph_by_type(cell_type) - + # Analyze subgraph degree_cent = degree_centrality(subgraph.G) assert len(degree_cent) == subgraph.n_nodes - + avg_clust = average_clustering(subgraph.G) assert 0 <= avg_clust <= 1 @@ -816,27 +815,27 @@ def test_subgraph_analysis(self, small_tissue): class TestNetworkEdgeCases: """Tests for edge cases and error handling.""" - + def test_empty_graph(self): """Test with empty graph.""" G = nx.Graph() - + centrality = degree_centrality(G) assert len(centrality) == 0 - + avg_clust = average_clustering(G) assert avg_clust == 0.0 - + def test_single_node_graph(self): """Test with single node.""" coords = np.array([[0, 0]]) types = np.array(['A']) - + graph = CellGraph.from_coordinates(coords, types, radius=50) - + assert graph.n_nodes == 1 assert graph.n_edges == 0 - + def test_disconnected_graph(self): """Test with disconnected components.""" # Create two separate clusters @@ -845,19 +844,19 @@ def test_disconnected_graph(self): np.random.uniform([100, 100], [110, 110], (20, 2)) ]) types = np.array(['A'] * 40) - + graph = CellGraph.from_coordinates(coords, types, radius=5) - + n_comp = n_connected_components(graph.G) assert n_comp >= 2 - + def test_fully_connected_graph(self): """Test with fully connected graph.""" coords = np.random.uniform(0, 10, (10, 2)) types = np.array(['A'] * 10) - + graph = CellGraph.from_coordinates(coords, types, radius=100) - + # Should be fully connected expected_edges = 10 * 9 / 2 # Complete graph assert graph.n_edges == expected_edges @@ -870,11 +869,11 @@ def test_fully_connected_graph(self): @pytest.mark.slow class TestNetworkPerformance: """Performance tests for network operations.""" - + def test_graph_construction_performance(self, large_tissue): """Test graph construction with 10k cells.""" import time - + start = time.time() graph = CellGraph.from_spatial_data( large_tissue, @@ -882,35 +881,35 @@ def test_graph_construction_performance(self, large_tissue): radius=50 ) elapsed = time.time() - start - + # Should complete in reasonable time assert elapsed < 10.0 assert graph.n_nodes == large_tissue.n_cells - + def test_centrality_performance(self, large_tissue): """Test centrality computation performance.""" import time - + graph = CellGraph.from_spatial_data(large_tissue, radius=50) - + start = time.time() centrality = degree_centrality(graph.G) elapsed = time.time() - start - + # Should be fast assert elapsed < 1.0 assert len(centrality) == graph.n_nodes - + def test_clustering_performance(self, large_tissue): """Test clustering computation performance.""" import time - + graph = CellGraph.from_spatial_data(large_tissue, radius=50) - + start = time.time() avg_clust = average_clustering(graph.G) elapsed = time.time() - start - + # Should complete in reasonable time assert elapsed < 5.0 assert isinstance(avg_clust, float) diff --git a/tests/test_physicell_io.py b/tests/test_physicell_io.py index 03da249..6795f07 100644 --- a/tests/test_physicell_io.py +++ b/tests/test_physicell_io.py @@ -5,29 +5,30 @@ simulation data in examples/sample_data/example_physicell_sim. """ -import pytest -import numpy as np -import pandas as pd from pathlib import Path + import matplotlib +import numpy as np +import pandas as pd +import pytest + matplotlib.use('Agg') # Non-interactive backend for testing import matplotlib.pyplot as plt from spatialtissuepy import SpatialTissueData from spatialtissuepy.synthetic.physicell import ( - PhysiCellTimeStep, PhysiCellSimulation, - read_physicell_timestep, - read_physicell_simulation, + PhysiCellTimeStep, discover_physicell_timesteps, - parse_physicell_xml, - parse_cells_mat, get_cell_type_mapping, is_alive, is_dead, + parse_cells_mat, + parse_physicell_xml, + read_physicell_simulation, + read_physicell_timestep, ) - # ============================================================================= # Fixtures # ============================================================================= @@ -56,7 +57,7 @@ def final_timestep_xml(example_physicell_dir): # Find the highest numbered output file xml_files = sorted(example_physicell_dir.glob('output*.xml')) # Filter to numbered output files only - numbered_files = [f for f in xml_files if f.stem.startswith('output') + numbered_files = [f for f in xml_files if f.stem.startswith('output') and f.stem[6:].isdigit()] if not numbered_files: pytest.skip("No numbered output XML files found") @@ -75,32 +76,32 @@ def loaded_simulation(example_physicell_dir): class TestPhysiCellDiscovery: """Tests for discovering PhysiCell output files.""" - + def test_discover_timesteps_finds_files(self, example_physicell_dir): """Test that discover_physicell_timesteps finds output files.""" timesteps = discover_physicell_timesteps(example_physicell_dir) - + assert len(timesteps) > 0, "Should find at least one timestep" - + # Each entry should be (index, xml_path, mat_path) for index, xml_path, mat_path in timesteps: assert isinstance(index, int) assert xml_path.exists(), f"XML file should exist: {xml_path}" assert mat_path.exists(), f"MAT file should exist: {mat_path}" - + def test_discover_timesteps_sorted(self, example_physicell_dir): """Test that discovered timesteps are sorted by index.""" timesteps = discover_physicell_timesteps(example_physicell_dir) - + indices = [idx for idx, _, _ in timesteps] assert indices == sorted(indices), "Timesteps should be sorted by index" - + def test_discover_timesteps_mat_file_naming(self, example_physicell_dir): """Test that both MAT file naming conventions are supported.""" timesteps = discover_physicell_timesteps(example_physicell_dir) - + assert len(timesteps) > 0, "Should find timesteps" - + # Check that MAT files have expected naming for index, xml_path, mat_path in timesteps: mat_name = mat_path.name @@ -115,45 +116,45 @@ def test_discover_timesteps_mat_file_naming(self, example_physicell_dir): class TestPhysiCellXMLParsing: """Tests for parsing PhysiCell XML files.""" - + def test_parse_xml_returns_metadata(self, first_timestep_xml): """Test that parse_physicell_xml returns metadata object.""" metadata = parse_physicell_xml(first_timestep_xml) - + assert metadata is not None assert hasattr(metadata, 'time') assert hasattr(metadata, 'time_units') assert hasattr(metadata, 'space_units') assert hasattr(metadata, 'domain_min') assert hasattr(metadata, 'domain_max') - + def test_parse_xml_time_values(self, first_timestep_xml, final_timestep_xml): """Test that time values are parsed correctly.""" meta_first = parse_physicell_xml(first_timestep_xml) meta_final = parse_physicell_xml(final_timestep_xml) - + # First timestep should have time close to 0 assert meta_first.time >= 0 - + # Final timestep should have larger time assert meta_final.time > meta_first.time - + def test_parse_xml_domain_bounds(self, first_timestep_xml): """Test that domain bounds are parsed.""" metadata = parse_physicell_xml(first_timestep_xml) - + # Domain should have valid bounds assert len(metadata.domain_min) == 3 assert len(metadata.domain_max) == 3 - + # Max should be greater than min for i in range(3): assert metadata.domain_max[i] >= metadata.domain_min[i] - + def test_get_cell_type_mapping(self, first_timestep_xml): """Test getting cell type ID to name mapping.""" mapping = get_cell_type_mapping(first_timestep_xml) - + assert isinstance(mapping, dict) # Should have at least default mapping assert len(mapping) > 0 @@ -165,14 +166,14 @@ def test_get_cell_type_mapping(self, first_timestep_xml): class TestPhysiCellMATParsing: """Tests for parsing PhysiCell MAT files.""" - + def test_parse_cells_mat_structure(self, example_physicell_dir): """Test that parse_cells_mat returns expected structure.""" timesteps = discover_physicell_timesteps(example_physicell_dir) _, _, mat_path = timesteps[0] - + data = parse_cells_mat(mat_path) - + # Check required keys assert 'positions' in data assert 'cell_types' in data @@ -181,25 +182,25 @@ def test_parse_cells_mat_structure(self, example_physicell_dir): assert 'radii' in data assert 'phases' in data assert 'ids' in data - + def test_parse_cells_mat_positions_shape(self, example_physicell_dir): """Test that positions have correct shape.""" timesteps = discover_physicell_timesteps(example_physicell_dir) _, _, mat_path = timesteps[0] - + data = parse_cells_mat(mat_path) - + positions = data['positions'] assert positions.ndim == 2 assert positions.shape[1] == 3, "Should have x, y, z coordinates" - + def test_parse_cells_mat_consistent_lengths(self, example_physicell_dir): """Test that all arrays have consistent length.""" timesteps = discover_physicell_timesteps(example_physicell_dir) _, _, mat_path = timesteps[0] - + data = parse_cells_mat(mat_path) - + n_cells = len(data['positions']) assert len(data['cell_types']) == n_cells assert len(data['cell_type_ids']) == n_cells @@ -207,14 +208,14 @@ def test_parse_cells_mat_consistent_lengths(self, example_physicell_dir): assert len(data['radii']) == n_cells assert len(data['phases']) == n_cells assert len(data['ids']) == n_cells - + def test_parse_cells_mat_positive_volumes(self, example_physicell_dir): """Test that cell volumes are positive.""" timesteps = discover_physicell_timesteps(example_physicell_dir) _, _, mat_path = timesteps[0] - + data = parse_cells_mat(mat_path) - + if len(data['volumes']) > 0: assert np.all(data['volumes'] > 0), "All volumes should be positive" @@ -225,55 +226,55 @@ def test_parse_cells_mat_positive_volumes(self, example_physicell_dir): class TestReadPhysiCellTimestep: """Tests for reading individual PhysiCell timesteps.""" - + def test_read_timestep_returns_object(self, first_timestep_xml): """Test that read_physicell_timestep returns PhysiCellTimeStep.""" timestep = read_physicell_timestep(first_timestep_xml) - + assert isinstance(timestep, PhysiCellTimeStep) - + def test_read_timestep_has_cells(self, first_timestep_xml): """Test that timestep has cells.""" timestep = read_physicell_timestep(first_timestep_xml) - + assert timestep.n_cells >= 0 - + def test_read_timestep_time_index(self, first_timestep_xml): """Test that time index is extracted from filename.""" timestep = read_physicell_timestep(first_timestep_xml) - + assert timestep.time_index == 0 - + def test_read_timestep_to_spatial_data(self, first_timestep_xml): """Test converting timestep to SpatialTissueData.""" timestep = read_physicell_timestep(first_timestep_xml) - + spatial_data = timestep.to_spatial_data() - + assert isinstance(spatial_data, SpatialTissueData) assert spatial_data.n_cells == timestep.n_cells - + def test_read_timestep_to_dataframe(self, first_timestep_xml): """Test converting timestep to DataFrame.""" timestep = read_physicell_timestep(first_timestep_xml) - + df = timestep.to_dataframe() - + assert isinstance(df, pd.DataFrame) assert 'x' in df.columns assert 'y' in df.columns assert 'z' in df.columns assert 'cell_type' in df.columns assert len(df) == timestep.n_cells_total - + def test_read_timestep_exclude_dead_cells(self, example_physicell_dir): """Test that dead cells can be excluded.""" timesteps = discover_physicell_timesteps(example_physicell_dir) _, xml_path, _ = timesteps[-1] # Use later timestep more likely to have dead cells - + timestep_with_dead = read_physicell_timestep(xml_path, include_dead_cells=True) timestep_without_dead = read_physicell_timestep(xml_path, include_dead_cells=False) - + # Without dead should have <= cells as with dead assert timestep_without_dead.n_cells <= timestep_with_dead.n_cells @@ -284,45 +285,45 @@ def test_read_timestep_exclude_dead_cells(self, example_physicell_dir): class TestReadPhysiCellSimulation: """Tests for reading complete PhysiCell simulations.""" - + def test_read_simulation_returns_object(self, example_physicell_dir): """Test that read_physicell_simulation returns PhysiCellSimulation.""" sim = read_physicell_simulation(example_physicell_dir) - + assert isinstance(sim, PhysiCellSimulation) - + def test_read_simulation_has_timesteps(self, example_physicell_dir): """Test that simulation has multiple timesteps.""" sim = read_physicell_simulation(example_physicell_dir) - + assert sim.n_timesteps > 0 - + def test_read_simulation_times_array(self, loaded_simulation): """Test that simulation has times array.""" times = loaded_simulation.times - + assert len(times) == loaded_simulation.n_timesteps # Times should be monotonically increasing assert np.all(np.diff(times) >= 0) - + def test_read_simulation_get_timestep(self, loaded_simulation): """Test getting specific timestep by index.""" timestep = loaded_simulation.get_timestep(0) - + assert isinstance(timestep, PhysiCellTimeStep) assert timestep.time_index == 0 - + def test_read_simulation_get_timestep_by_time(self, loaded_simulation): """Test getting timestep by time value.""" # Get time of middle timestep mid_idx = loaded_simulation.n_timesteps // 2 target_time = loaded_simulation.times[mid_idx] - + timestep = loaded_simulation.get_timestep_by_time(target_time) - + # Should return timestep close to requested time assert abs(timestep.time - target_time) < 1.0 # Within 1 time unit - + def test_read_simulation_iteration(self, loaded_simulation): """Test iterating over simulation timesteps.""" count = 0 @@ -331,7 +332,7 @@ def test_read_simulation_iteration(self, loaded_simulation): count += 1 if count > 5: # Don't iterate through all for speed break - + assert count > 0 @@ -341,56 +342,56 @@ def test_read_simulation_iteration(self, loaded_simulation): class TestPhysiCellStatistics: """Tests for computing statistics on PhysiCell data.""" - + def test_cell_counts_over_time(self, loaded_simulation): """Test computing cell counts over time.""" # Sample a few timesteps to check indices = [0, loaded_simulation.n_timesteps // 2, loaded_simulation.n_timesteps - 1] - + for idx in indices: timestep = loaded_simulation.get_timestep(idx) assert timestep.n_cells >= 0 assert timestep.n_cells_total >= timestep.n_cells - + def test_cell_counts_by_type(self, first_timestep_xml): """Test getting cell counts by type.""" timestep = read_physicell_timestep(first_timestep_xml) - + counts = timestep.cell_counts_by_type() - + assert isinstance(counts, dict) total = sum(counts.values()) assert total == timestep.n_cells - + def test_spatial_statistics_on_timestep(self, first_timestep_xml): """Test computing spatial statistics on PhysiCell timestep.""" from spatialtissuepy.spatial import pairwise_distances - + timestep = read_physicell_timestep(first_timestep_xml) spatial_data = timestep.to_spatial_data() - + if spatial_data.n_cells > 1: # pairwise_distances expects coordinates array, not SpatialTissueData dists = pairwise_distances(spatial_data.coordinates) - + assert dists.shape == (spatial_data.n_cells, spatial_data.n_cells) # Diagonal should be 0 np.testing.assert_array_almost_equal(np.diag(dists), 0) - + def test_ripley_statistics_on_timestep(self, first_timestep_xml): """Test computing Ripley's K on PhysiCell timestep.""" from spatialtissuepy.statistics import ripleys_k - + timestep = read_physicell_timestep(first_timestep_xml) spatial_data = timestep.to_spatial_data() - + if spatial_data.n_cells > 10: # Use 2D projection for Ripley's K coords_2d = spatial_data.coordinates[:, :2] radii = np.linspace(10, 100, 5) - + K = ripleys_k(coords_2d, radii) - + assert len(K) == len(radii) assert np.all(K >= 0) # K should be non-negative @@ -401,110 +402,110 @@ def test_ripley_statistics_on_timestep(self, first_timestep_xml): class TestPhysiCellVisualization: """Tests for visualizing PhysiCell data.""" - + def test_plot_cell_positions_2d(self, first_timestep_xml, tmp_path): """Test plotting 2D cell positions from PhysiCell timestep.""" timestep = read_physicell_timestep(first_timestep_xml) spatial_data = timestep.to_spatial_data() - + if spatial_data.n_cells == 0: pytest.skip("No cells in timestep") - + fig, ax = plt.subplots(figsize=(8, 8)) - + # Plot x, y positions coords = spatial_data.coordinates[:, :2] cell_types = spatial_data.cell_types unique_types = np.unique(cell_types) - + colors = plt.cm.tab10(np.linspace(0, 1, len(unique_types))) - + for i, ct in enumerate(unique_types): mask = cell_types == ct - ax.scatter(coords[mask, 0], coords[mask, 1], + ax.scatter(coords[mask, 0], coords[mask, 1], c=[colors[i]], label=ct, alpha=0.6, s=10) - + ax.set_xlabel('X (μm)') ax.set_ylabel('Y (μm)') ax.set_title(f'PhysiCell Timestep (t={timestep.time:.1f})') ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left') ax.set_aspect('equal') - + plt.tight_layout() - + # Save figure fig_path = tmp_path / 'physicell_positions_2d.png' fig.savefig(fig_path, dpi=100, bbox_inches='tight') plt.close(fig) - + assert fig_path.exists() - + def test_plot_cell_positions_3d(self, first_timestep_xml, tmp_path): """Test plotting 3D cell positions from PhysiCell timestep.""" timestep = read_physicell_timestep(first_timestep_xml) spatial_data = timestep.to_spatial_data() - + if spatial_data.n_cells == 0: pytest.skip("No cells in timestep") - + fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') - + coords = spatial_data.coordinates cell_types = spatial_data.cell_types unique_types = np.unique(cell_types) - + colors = plt.cm.tab10(np.linspace(0, 1, len(unique_types))) - + for i, ct in enumerate(unique_types): mask = cell_types == ct ax.scatter(coords[mask, 0], coords[mask, 1], coords[mask, 2], c=[colors[i]], label=ct, alpha=0.6, s=10) - + ax.set_xlabel('X (μm)') ax.set_ylabel('Y (μm)') ax.set_zlabel('Z (μm)') ax.set_title(f'PhysiCell Timestep 3D (t={timestep.time:.1f})') - + plt.tight_layout() - + # Save figure fig_path = tmp_path / 'physicell_positions_3d.png' fig.savefig(fig_path, dpi=100, bbox_inches='tight') plt.close(fig) - + assert fig_path.exists() - + def test_plot_cell_growth_trajectory(self, loaded_simulation, tmp_path): """Test plotting cell count trajectory over time.""" # Sample timesteps for faster test n_samples = min(20, loaded_simulation.n_timesteps) - sample_indices = np.linspace(0, loaded_simulation.n_timesteps - 1, + sample_indices = np.linspace(0, loaded_simulation.n_timesteps - 1, n_samples, dtype=int) - + times = [] cell_counts = [] - + for idx in sample_indices: timestep = loaded_simulation.get_timestep(idx) times.append(timestep.time) cell_counts.append(timestep.n_cells) - + fig, ax = plt.subplots(figsize=(10, 6)) - + ax.plot(times, cell_counts, 'b-o', linewidth=2, markersize=4) ax.set_xlabel('Time (min)') ax.set_ylabel('Number of Cells') ax.set_title('Cell Population Growth Over Time') ax.grid(True, alpha=0.3) - + plt.tight_layout() - + # Save figure fig_path = tmp_path / 'physicell_growth_trajectory.png' fig.savefig(fig_path, dpi=100) plt.close(fig) - + assert fig_path.exists() @@ -514,28 +515,28 @@ def test_plot_cell_growth_trajectory(self, loaded_simulation, tmp_path): class TestPhysiCellHelpers: """Tests for helper functions.""" - + def test_is_alive_function(self): """Test is_alive helper function.""" # Live phases (< 100) - assert is_alive(0) == True - assert is_alive(14) == True # 'live' phase - assert is_alive(5) == True # G0 phase - + assert is_alive(0) is True + assert is_alive(14) is True # 'live' phase + assert is_alive(5) is True # G0 phase + # Dead phases (>= 100) - assert is_alive(100) == False # apoptotic - assert is_alive(103) == False # necrotic - + assert is_alive(100) is False # apoptotic + assert is_alive(103) is False # necrotic + def test_is_dead_function(self): """Test is_dead helper function.""" # Live phases - assert is_dead(0) == False - assert is_dead(14) == False - + assert is_dead(0) is False + assert is_dead(14) is False + # Dead phases - assert is_dead(100) == True - assert is_dead(101) == True - assert is_dead(104) == True # debris + assert is_dead(100) is True + assert is_dead(101) is True + assert is_dead(104) is True # debris # ============================================================================= @@ -544,29 +545,29 @@ def test_is_dead_function(self): class TestPhysiCellErrorHandling: """Tests for error handling in PhysiCell module.""" - + def test_read_nonexistent_file(self, tmp_path): """Test error when reading non-existent file.""" fake_path = tmp_path / 'nonexistent.xml' - + with pytest.raises(FileNotFoundError): read_physicell_timestep(fake_path) - + def test_read_nonexistent_folder(self, tmp_path): """Test error when reading non-existent folder.""" fake_path = tmp_path / 'nonexistent_folder' - + with pytest.raises(FileNotFoundError): read_physicell_simulation(fake_path) - + def test_read_empty_folder(self, tmp_path): """Test error when reading folder with no PhysiCell files.""" empty_dir = tmp_path / 'empty_output' empty_dir.mkdir() - + with pytest.raises(ValueError, match="No PhysiCell output files"): read_physicell_simulation(empty_dir) - + def test_get_timestep_out_of_range(self, loaded_simulation): """Test error when requesting out-of-range timestep.""" with pytest.raises(IndexError): @@ -579,43 +580,43 @@ def test_get_timestep_out_of_range(self, loaded_simulation): class TestPhysiCellIntegration: """Integration tests for PhysiCell workflow.""" - + def test_full_analysis_workflow(self, example_physicell_dir): """Test complete analysis workflow from loading to statistics.""" # 1. Load simulation sim = read_physicell_simulation(example_physicell_dir) assert sim.n_timesteps > 0 - + # 2. Get first and last timesteps first_ts = sim.get_timestep(0) last_ts = sim.get_timestep(sim.n_timesteps - 1) - + # 3. Convert to SpatialTissueData first_spatial = first_ts.to_spatial_data() - last_spatial = last_ts.to_spatial_data() - + last_ts.to_spatial_data() + # 4. Compare basic properties print(f"First timestep: {first_ts.n_cells} cells at t={first_ts.time}") print(f"Last timestep: {last_ts.n_cells} cells at t={last_ts.time}") - + # 5. Cell types should be consistent (same types present) assert len(first_spatial.cell_types_unique) > 0 - + def test_trajectory_dataframe_workflow(self, loaded_simulation): """Test creating trajectory DataFrame from simulation.""" # Sample a few timesteps n_samples = min(5, loaded_simulation.n_timesteps) - sample_indices = np.linspace(0, loaded_simulation.n_timesteps - 1, + sample_indices = np.linspace(0, loaded_simulation.n_timesteps - 1, n_samples, dtype=int) - + dfs = [] for idx in sample_indices: ts = loaded_simulation.get_timestep(idx) df = ts.to_dataframe() dfs.append(df) - + combined_df = pd.concat(dfs, ignore_index=True) - + assert 'time' in combined_df.columns assert 'x' in combined_df.columns assert 'cell_type' in combined_df.columns @@ -629,33 +630,33 @@ def test_trajectory_dataframe_workflow(self, loaded_simulation): @pytest.mark.slow class TestPhysiCellPerformance: """Performance tests for PhysiCell module.""" - + def test_load_simulation_speed(self, example_physicell_dir): """Test that simulation loading is reasonably fast.""" import time - + start = time.time() sim = read_physicell_simulation(example_physicell_dir) load_time = time.time() - start - + # Loading should complete within reasonable time # (adjust threshold based on expected data size) assert load_time < 60, f"Loading took too long: {load_time:.1f}s" print(f"Loaded {sim.n_timesteps} timesteps in {load_time:.2f}s") - + def test_timestep_access_speed(self, loaded_simulation): """Test that accessing timesteps is fast.""" import time - + n_accesses = min(50, loaded_simulation.n_timesteps) - + start = time.time() for i in range(n_accesses): idx = i % loaded_simulation.n_timesteps ts = loaded_simulation.get_timestep(idx) _ = ts.n_cells # Access a property access_time = time.time() - start - + per_access = access_time / n_accesses assert per_access < 1.0, f"Timestep access too slow: {per_access:.3f}s each" print(f"Average timestep access time: {per_access*1000:.1f}ms") diff --git a/tests/test_spatial.py b/tests/test_spatial.py index c29b224..86a353f 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -4,143 +4,142 @@ Tests distance calculations, nearest neighbor queries, and spatial metrics. """ -import pytest import numpy as np +import pytest from scipy.spatial import cKDTree from spatialtissuepy import SpatialTissueData from spatialtissuepy.spatial import ( - # Distance matrices - pairwise_distances, - pairwise_distances_between, - condensed_distances, + bounding_box, # KD-tree build_kdtree, - # Nearest neighbors - nearest_neighbors, - radius_neighbors, - nearest_neighbor_distances, - mean_nearest_neighbor_distance, - # Distance to types - distance_to_type, - distance_to_nearest_different_type, - distance_matrix_by_type, # Utilities centroid, centroid_by_type, - bounding_box, + condensed_distances, convex_hull_area, + distance_matrix_by_type, + distance_to_nearest_different_type, + # Distance to types + distance_to_type, + mean_nearest_neighbor_distance, + nearest_neighbor_distances, + # Nearest neighbors + nearest_neighbors, + # Distance matrices + pairwise_distances, + pairwise_distances_between, point_density, + radius_neighbors, ) - # ============================================================================= # Distance Matrix Tests # ============================================================================= class TestPairwiseDistances: """Tests for pairwise distance calculations.""" - + def test_pairwise_distances_basic(self): """Test basic pairwise distance matrix.""" coords = np.array([[0, 0], [1, 0], [0, 1]]) - + D = pairwise_distances(coords) - + # Check shape assert D.shape == (3, 3) - + # Check diagonal is zero np.testing.assert_array_almost_equal(np.diag(D), [0, 0, 0]) - + # Check symmetry np.testing.assert_array_almost_equal(D, D.T) - + # Check specific distances assert D[0, 1] == 1.0 # Euclidean distance assert D[0, 2] == 1.0 np.testing.assert_almost_equal(D[1, 2], np.sqrt(2)) - + def test_pairwise_distances_euclidean(self): """Test Euclidean distance metric.""" coords = np.array([[0, 0], [3, 4]]) D = pairwise_distances(coords, metric='euclidean') - + assert D[0, 1] == 5.0 # 3-4-5 triangle - + def test_pairwise_distances_manhattan(self): """Test Manhattan (L1) distance metric.""" coords = np.array([[0, 0], [3, 4]]) D = pairwise_distances(coords, metric='manhattan') - + assert D[0, 1] == 7.0 # |3| + |4| - + def test_pairwise_distances_chebyshev(self): """Test Chebyshev (L∞) distance metric.""" coords = np.array([[0, 0], [3, 4]]) D = pairwise_distances(coords, metric='chebyshev') - + assert D[0, 1] == 4.0 # max(3, 4) - + def test_pairwise_distances_single_point(self): """Test with single point.""" coords = np.array([[0, 0]]) D = pairwise_distances(coords) - + assert D.shape == (1, 1) assert D[0, 0] == 0.0 class TestPairwiseDistancesBetween: """Tests for pairwise distances between two sets.""" - + def test_distances_between_basic(self): """Test distances between two point sets.""" a = np.array([[0, 0], [1, 1]]) b = np.array([[2, 0], [0, 2]]) - + D = pairwise_distances_between(a, b) - + assert D.shape == (2, 2) assert D[0, 0] == 2.0 # (0,0) to (2,0) assert D[0, 1] == 2.0 # (0,0) to (0,2) - + def test_distances_between_different_sizes(self): """Test with different sized sets.""" a = np.array([[0, 0]]) b = np.array([[1, 0], [0, 1], [1, 1]]) - + D = pairwise_distances_between(a, b) - + assert D.shape == (1, 3) class TestCondensedDistances: """Tests for condensed distance vector.""" - + def test_condensed_distances_basic(self): """Test condensed distance format.""" coords = np.array([[0, 0], [1, 0], [0, 1]]) - + condensed = condensed_distances(coords) - + # 3 points → 3 distances in upper triangle assert len(condensed) == 3 - + # Check values assert condensed[0] == 1.0 # d(0,1) assert condensed[1] == 1.0 # d(0,2) np.testing.assert_almost_equal(condensed[2], np.sqrt(2)) # d(1,2) - + def test_condensed_to_square(self): """Test conversion from condensed to square format.""" from scipy.spatial.distance import squareform - + coords = np.array([[0, 0], [1, 0], [0, 1]]) - + condensed = condensed_distances(coords) square = squareform(condensed) - + # Should match pairwise_distances expected = pairwise_distances(coords) np.testing.assert_array_almost_equal(square, expected) @@ -152,22 +151,22 @@ def test_condensed_to_square(self): class TestKDTree: """Tests for KD-tree construction.""" - + def test_build_kdtree(self): """Test KD-tree construction.""" coords = np.random.rand(100, 2) tree = build_kdtree(coords) - + assert isinstance(tree, cKDTree) - + def test_kdtree_query(self): """Test querying KD-tree.""" coords = np.array([[0, 0], [1, 0], [0, 1], [1, 1]]) tree = build_kdtree(coords) - + # Query nearest to (0.5, 0.5) dist, idx = tree.query([0.5, 0.5], k=1) - + # Could be any of the 4 corners (equidistant) assert idx in [0, 1, 2, 3] np.testing.assert_almost_equal(dist, np.sqrt(0.5)) @@ -179,123 +178,123 @@ def test_kdtree_query(self): class TestNearestNeighbors: """Tests for nearest neighbor queries.""" - + def test_nearest_neighbors_basic(self): """Test basic k-NN query.""" coords = np.array([[0, 0], [1, 0], [2, 0], [3, 0]]) - + distances, indices = nearest_neighbors(coords, k=2) - + assert distances.shape == (4, 2) assert indices.shape == (4, 2) - + def test_nearest_neighbors_exclude_self(self): """Test that self is excluded by default.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + distances, indices = nearest_neighbors(coords, k=1, include_self=False) - + # No point should be its own neighbor for i in range(len(coords)): assert i not in indices[i] - + def test_nearest_neighbors_include_self(self): """Test including self as neighbor.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + distances, indices = nearest_neighbors(coords, k=1, include_self=True) - + # Each point should be its own nearest neighbor for i in range(len(coords)): assert indices[i, 0] == i assert distances[i, 0] == 0.0 - + def test_nearest_neighbors_no_distances(self): """Test returning only indices.""" coords = np.random.rand(50, 2) - + indices = nearest_neighbors(coords, k=3, return_distances=False) - + assert isinstance(indices, np.ndarray) assert indices.shape == (50, 3) - + def test_nearest_neighbors_order(self): """Test that neighbors are ordered by distance.""" coords = np.array([[0, 0], [1, 0], [3, 0], [2, 0]]) - + distances, indices = nearest_neighbors(coords, k=3) - + # For point 0, neighbors should be ordered: 1 (dist=1), 3 (dist=2), 2 (dist=3) assert distances[0, 0] < distances[0, 1] < distances[0, 2] - + def test_nearest_neighbors_k_larger_than_n(self): """Test k larger than number of points.""" coords = np.array([[0, 0], [1, 0]]) - + distances, indices = nearest_neighbors(coords, k=5, include_self=False) - + # Should return only available neighbors assert indices.shape[1] <= 1 class TestRadiusNeighbors: """Tests for radius-based neighbor queries.""" - + def test_radius_neighbors_basic(self): """Test basic radius query.""" coords = np.array([[0, 0], [0.5, 0], [2, 0]]) - + indices = radius_neighbors(coords, radius=1.0) - + assert isinstance(indices, list) assert len(indices) == 3 - + # Point 0 should have point 1 as neighbor (distance 0.5) assert 1 in indices[0] # Point 0 should NOT have point 2 (distance 2.0) assert 2 not in indices[0] - + def test_radius_neighbors_exclude_self(self): """Test that self is excluded.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + indices = radius_neighbors(coords, radius=1.5) - + # No point should include itself for i in range(len(coords)): assert i not in indices[i] - + def test_radius_neighbors_with_distances(self): """Test returning distances.""" coords = np.array([[0, 0], [0.5, 0], [1.5, 0]]) - + distances, indices = radius_neighbors(coords, radius=1.0, return_distances=True) - + assert isinstance(distances, list) assert len(distances) == len(indices) - + # Point 0 has one neighbor within radius 1 assert len(indices[0]) == 1 np.testing.assert_almost_equal(distances[0][0], 0.5) - + def test_radius_neighbors_sorted(self): """Test sorting neighbors by distance.""" coords = np.array([[0, 0], [0.5, 0], [0.3, 0], [0.8, 0]]) - + indices = radius_neighbors(coords, radius=1.0, sort_results=True) - + # For point 0, neighbors should be sorted by distance # Nearest: point 2 (0.3), then 1 (0.5), then 3 (0.8) assert indices[0][0] == 2 assert indices[0][1] == 1 assert indices[0][2] == 3 - + def test_radius_neighbors_no_neighbors(self): """Test with isolated points.""" coords = np.array([[0, 0], [10, 0], [20, 0]]) - + indices = radius_neighbors(coords, radius=1.0) - + # All points should have empty neighbor lists for idx_list in indices: assert len(idx_list) == 0 @@ -303,33 +302,33 @@ def test_radius_neighbors_no_neighbors(self): class TestNearestNeighborDistances: """Tests for nearest neighbor distance calculations.""" - + def test_nearest_neighbor_distances_k1(self): """Test 1st nearest neighbor distances.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + distances = nearest_neighbor_distances(coords, k=1) - + assert len(distances) == 3 assert distances[0] == 1.0 # Nearest to point 0 is point 1 assert distances[1] == 1.0 # Point 1 has neighbors on both sides assert distances[2] == 1.0 # Nearest to point 2 is point 1 - + def test_nearest_neighbor_distances_k2(self): """Test 2nd nearest neighbor distances.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + distances = nearest_neighbor_distances(coords, k=2) - + assert len(distances) == 3 assert distances[0] == 2.0 # 2nd nearest to point 0 is point 2 - + def test_mean_nearest_neighbor_distance(self): """Test mean nearest neighbor distance.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) - + mean_dist = mean_nearest_neighbor_distance(coords, k=1) - + assert mean_dist == 1.0 @@ -339,106 +338,106 @@ def test_mean_nearest_neighbor_distance(self): class TestDistanceToType: """Tests for distance to specific cell types.""" - + def test_distance_to_type_basic(self): """Test distance to specific cell type.""" coords = np.array([[0, 0], [1, 0], [2, 0], [3, 0]]) types = np.array(['A', 'B', 'A', 'B']) data = SpatialTissueData(coords, types) - + distances = distance_to_type(data, 'B') - + assert len(distances) == 4 assert distances[1] == 0.0 # Point 1 is type B assert distances[0] == 1.0 # Nearest B to point 0 is at distance 1 - + def test_distance_to_type_from_subset(self): """Test distance from specific indices.""" coords = np.array([[0, 0], [1, 0], [2, 0], [3, 0]]) types = np.array(['A', 'B', 'A', 'B']) data = SpatialTissueData(coords, types) - + # Distance from A cells to B cells a_indices = data.get_cells_by_type('A') distances = distance_to_type(data, 'B', from_indices=a_indices) - + assert len(distances) == 2 # Only 2 A cells assert distances[0] == 1.0 # A at position 0, nearest B at 1 assert distances[1] == 1.0 # A at position 2, nearest B at 3 - + def test_distance_to_type_nonexistent(self): """Test error when target type doesn't exist.""" coords = np.array([[0, 0], [1, 0]]) types = np.array(['A', 'A']) data = SpatialTissueData(coords, types) - + with pytest.raises(ValueError, match="No cells of type"): distance_to_type(data, 'B') class TestDistanceToNearestDifferentType: """Tests for distance to nearest different cell type.""" - + def test_distance_to_nearest_different_type(self): """Test distance to nearest different type.""" coords = np.array([[0, 0], [1, 0], [2, 0]]) types = np.array(['A', 'B', 'A']) data = SpatialTissueData(coords, types) - + distances = distance_to_nearest_different_type(data) - + assert len(distances) == 3 assert distances[0] == 1.0 # A to B assert distances[1] == 1.0 # B to A assert distances[2] == 1.0 # A to B - + def test_distance_to_nearest_different_type_single_type(self): """Test with only one cell type.""" coords = np.array([[0, 0], [1, 0]]) types = np.array(['A', 'A']) data = SpatialTissueData(coords, types) - + distances = distance_to_nearest_different_type(data) - + # All distances should be inf (no different type) assert np.all(np.isinf(distances)) class TestDistanceMatrixByType: """Tests for distance matrix between cell types.""" - + def test_distance_matrix_by_type_mean(self): """Test mean distance matrix.""" coords = np.array([[0, 0], [1, 0], [10, 0], [11, 0]]) types = np.array(['A', 'A', 'B', 'B']) data = SpatialTissueData(coords, types) - + dist_matrix = distance_matrix_by_type(data, metric='mean') - + # Within type A: distance is 1 assert dist_matrix[('A', 'A')] == 1.0 # Within type B: distance is 1 assert dist_matrix[('B', 'B')] == 1.0 # Between A and B: mean of [10, 11, 9, 10] = 10 assert dist_matrix[('A', 'B')] == 10.0 - + def test_distance_matrix_by_type_min(self): """Test minimum distance matrix.""" coords = np.array([[0, 0], [1, 0], [10, 0]]) types = np.array(['A', 'A', 'B']) data = SpatialTissueData(coords, types) - + dist_matrix = distance_matrix_by_type(data, metric='min') - + # Minimum distance from A to B assert dist_matrix[('A', 'B')] == 9.0 # min(10, 9) - + def test_distance_matrix_invalid_metric(self): """Test error with invalid metric.""" coords = np.array([[0, 0], [1, 0]]) types = np.array(['A', 'B']) data = SpatialTissueData(coords, types) - + with pytest.raises(ValueError, match="Unknown metric"): distance_matrix_by_type(data, metric='invalid') @@ -449,35 +448,35 @@ def test_distance_matrix_invalid_metric(self): class TestCentroid: """Tests for centroid calculations.""" - + def test_centroid_basic(self): """Test basic centroid calculation.""" coords = np.array([[0, 0], [2, 0], [1, 2]]) - + c = centroid(coords) - + np.testing.assert_array_almost_equal(c, [1, 2/3]) - + def test_centroid_single_point(self): """Test centroid of single point.""" coords = np.array([[5, 3]]) - + c = centroid(coords) - + np.testing.assert_array_almost_equal(c, [5, 3]) class TestCentroidByType: """Tests for centroid by cell type.""" - + def test_centroid_by_type(self): """Test centroid calculation per type.""" coords = np.array([[0, 0], [2, 0], [10, 10], [12, 10]]) types = np.array(['A', 'A', 'B', 'B']) data = SpatialTissueData(coords, types) - + centroids = centroid_by_type(data) - + assert 'A' in centroids assert 'B' in centroids np.testing.assert_array_almost_equal(centroids['A'], [1, 0]) @@ -486,87 +485,87 @@ def test_centroid_by_type(self): class TestBoundingBox: """Tests for bounding box calculation.""" - + def test_bounding_box_2d(self): """Test 2D bounding box.""" coords = np.array([[0, 0], [5, 3], [2, 7]]) - + min_coords, max_coords = bounding_box(coords) - + np.testing.assert_array_equal(min_coords, [0, 0]) np.testing.assert_array_equal(max_coords, [5, 7]) - + def test_bounding_box_3d(self): """Test 3D bounding box.""" coords = np.array([[0, 0, 0], [1, 2, 3], [2, 1, 1]]) - + min_coords, max_coords = bounding_box(coords) - + assert len(min_coords) == 3 assert len(max_coords) == 3 class TestConvexHullArea: """Tests for convex hull area calculation.""" - + def test_convex_hull_area_triangle(self): """Test area of triangle.""" coords = np.array([[0, 0], [2, 0], [0, 2]]) - + area = convex_hull_area(coords) - + # Triangle with base=2, height=2: area = 2 assert area == 2.0 - + def test_convex_hull_area_square(self): """Test area of square.""" coords = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) - + area = convex_hull_area(coords) - + assert area == 1.0 - + def test_convex_hull_area_insufficient_points(self): """Test with < 3 points.""" coords = np.array([[0, 0], [1, 1]]) - + area = convex_hull_area(coords) - + assert area == 0.0 - + def test_convex_hull_area_3d_raises_error(self): """Test that 3D coordinates raise error.""" coords = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) - + with pytest.raises(ValueError, match="2D coordinates"): convex_hull_area(coords) class TestPointDensity: """Tests for point density calculation.""" - + def test_point_density_bounding_box(self): """Test density using bounding box.""" coords = np.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - + density = point_density(coords, method='bounding_box') - + # 4 points in 10x10 box = 0.04 points per unit² assert density == 0.04 - + def test_point_density_convex_hull(self): """Test density using convex hull.""" coords = np.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - + density = point_density(coords, method='convex_hull') - + # 4 points in square of area 100 assert density == pytest.approx(0.04) - + def test_point_density_invalid_method(self): """Test error with invalid method.""" coords = np.array([[0, 0], [1, 1]]) - + with pytest.raises(ValueError, match="Unknown method"): point_density(coords, method='invalid') @@ -577,39 +576,39 @@ def test_point_density_invalid_method(self): class TestSpatialIntegration: """Integration tests combining multiple spatial functions.""" - + def test_complete_spatial_workflow(self, small_tissue): """Test complete spatial analysis workflow.""" # 1. Build KD-tree tree = build_kdtree(small_tissue.coordinates) assert tree is not None - + # 2. Find nearest neighbors distances, indices = nearest_neighbors(small_tissue.coordinates, k=5) assert distances.shape == (small_tissue.n_cells, 5) - + # 3. Compute pairwise distances D = pairwise_distances(small_tissue.coordinates) assert D.shape == (small_tissue.n_cells, small_tissue.n_cells) - + # 4. Compute centroids centroids = centroid_by_type(small_tissue) assert len(centroids) == len(small_tissue.cell_types_unique) - + # 5. Distance to types for cell_type in small_tissue.cell_types_unique: dist = distance_to_type(small_tissue, cell_type) assert len(dist) == small_tissue.n_cells - + def test_neighborhood_analysis_workflow(self, simple_tissue_2d): """Test neighborhood-based analysis.""" # Find neighbors within radius radius = 50.0 neighbors = radius_neighbors(simple_tissue_2d.coordinates, radius=radius) - + # Compute neighborhood sizes neighborhood_sizes = np.array([len(n) for n in neighbors]) - + assert len(neighborhood_sizes) == simple_tissue_2d.n_cells assert neighborhood_sizes.min() >= 0 @@ -621,25 +620,25 @@ def test_neighborhood_analysis_workflow(self, simple_tissue_2d): @pytest.mark.slow class TestSpatialPerformance: """Performance tests for spatial operations.""" - + def test_kdtree_performance_large(self, large_tissue): """Test KD-tree performance with 10k cells.""" import time - + start = time.time() - tree = build_kdtree(large_tissue.coordinates) + build_kdtree(large_tissue.coordinates) elapsed = time.time() - start - + # Should build in <0.1 seconds assert elapsed < 0.1 - + def test_nearest_neighbors_performance(self, large_tissue): """Test k-NN performance with 10k cells.""" import time - + start = time.time() distances, indices = nearest_neighbors(large_tissue.coordinates, k=10) elapsed = time.time() - start - + # Should complete in <1 second assert elapsed < 1.0 diff --git a/tests/test_statistics.py b/tests/test_statistics.py index a6f2f5f..1c67320 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -5,76 +5,72 @@ and hotspot detection. """ -import pytest import numpy as np -from scipy.spatial import cKDTree +import pytest from spatialtissuepy import SpatialTissueData from spatialtissuepy.statistics import ( - # Ripley's K and variants - ripleys_k, - ripleys_l, - ripleys_h, + colocalization_matrix, + # Co-localization + colocalization_quotient, + cross_h, # Cross-type functions cross_k, cross_l, - cross_h, + cross_type_statistics, + # CSR envelope + csr_envelope, + detect_hotspots, + f_function, # Nearest-neighbor functions g_function, g_function_cross, - f_function, + # Hotspots + getis_ord_gi_star, j_function, - # Pair correlation pair_correlation_function, - # CSR envelope - csr_envelope, + ripleys_h, + # Ripley's K and variants + ripleys_k, + ripleys_l, # High-level functions spatial_statistics, - cross_type_statistics, - # Co-localization - colocalization_quotient, - colocalization_matrix, - neighborhood_enrichment_score, - # Hotspots - getis_ord_gi_star, - detect_hotspots, ) - # ============================================================================= # Ripley's K-function Tests # ============================================================================= class TestRipleysK: """Tests for Ripley's K-function.""" - + def test_ripleys_k_basic(self): """Test basic K-function calculation.""" # Random pattern np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([5, 10, 15, 20]) - + K = ripleys_k(coords, radii, area=100*100) - + assert len(K) == len(radii) assert np.all(K >= 0) # K should increase with radius assert np.all(np.diff(K) >= 0) - + def test_ripleys_k_csr_expectation(self): """Test K matches CSR expectation for random pattern.""" np.random.seed(42) coords = np.random.uniform(0, 100, (500, 2)) radii = np.linspace(5, 30, 10) - + K = ripleys_k(coords, radii, area=100*100, edge_correction='none') K_csr = np.pi * radii**2 - + # Should be close to CSR (within ~20% for random pattern) relative_diff = np.abs(K - K_csr) / K_csr assert np.mean(relative_diff) < 0.3 - + def test_ripleys_k_clustered(self): """Test K is elevated for clustered pattern.""" # Create clustered pattern @@ -84,109 +80,109 @@ def test_ripleys_k_clustered(self): cluster = np.random.normal([center_x, center_y], 5, (50, 2)) clusters.append(cluster) coords = np.vstack(clusters) - + radii = np.array([10, 20, 30]) K = ripleys_k(coords, radii, area=100*100, edge_correction='none') K_csr = np.pi * radii**2 - + # K should be greater than CSR (clustering) assert np.any(K > K_csr * 1.2) - + def test_ripleys_k_edge_correction(self): """Test different edge correction methods.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + K_none = ripleys_k(coords, radii, edge_correction='none') K_ripley = ripleys_k(coords, radii, edge_correction='ripley') - + # Should produce different results assert not np.allclose(K_none, K_ripley) - + def test_ripleys_k_empty(self): """Test K with no points.""" coords = np.array([]).reshape(0, 2) radii = np.array([10, 20]) - + K = ripleys_k(coords, radii) - + assert len(K) == 2 assert np.all(K == 0) - + def test_ripleys_k_single_point(self): """Test K with single point.""" coords = np.array([[50, 50]]) radii = np.array([10, 20]) - + K = ripleys_k(coords, radii) - + assert len(K) == 2 assert np.all(K == 0) class TestRipleysL: """Tests for Ripley's L-function.""" - + def test_ripleys_l_basic(self): """Test L-function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + L = ripleys_l(coords, radii, area=100*100) - + assert len(L) == len(radii) assert np.all(L >= 0) - + def test_ripleys_l_csr_expectation(self): """Test L ≈ r for CSR pattern.""" np.random.seed(42) coords = np.random.uniform(0, 100, (500, 2)) radii = np.linspace(5, 30, 10) - + L = ripleys_l(coords, radii, area=100*100, edge_correction='none') - + # L should be close to r for CSR relative_diff = np.abs(L - radii) / radii assert np.mean(relative_diff) < 0.2 - + def test_ripleys_l_from_k(self): """Test L = sqrt(K/π).""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + K = ripleys_k(coords, radii, area=100*100) L = ripleys_l(coords, radii, area=100*100) - + np.testing.assert_array_almost_equal(L, np.sqrt(K / np.pi)) class TestRipleysH: """Tests for Ripley's H-function.""" - + def test_ripleys_h_basic(self): """Test H-function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + H = ripleys_h(coords, radii, area=100*100) - + assert len(H) == len(radii) - + def test_ripleys_h_csr_expectation(self): """Test H ≈ 0 for CSR pattern.""" np.random.seed(42) coords = np.random.uniform(0, 100, (500, 2)) radii = np.linspace(5, 30, 10) - + H = ripleys_h(coords, radii, area=100*100, edge_correction='none') - + # H should oscillate around 0 for CSR assert np.mean(np.abs(H)) < 5 - + def test_ripleys_h_clustered(self): """Test H > 0 for clustered pattern.""" np.random.seed(42) @@ -197,22 +193,22 @@ def test_ripleys_h_clustered(self): cluster = np.random.normal(center, 3, (20, 2)) clusters.append(cluster) coords = np.vstack(clusters) - + radii = np.array([10, 15, 20]) H = ripleys_h(coords, radii, area=100*100) - + # Should show positive H (clustering) assert np.max(H) > 2 - + def test_ripleys_h_from_l(self): """Test H = L - r.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + L = ripleys_l(coords, radii, area=100*100) H = ripleys_h(coords, radii, area=100*100) - + np.testing.assert_array_almost_equal(H, L - radii) @@ -222,34 +218,34 @@ def test_ripleys_h_from_l(self): class TestCrossK: """Tests for cross-type K-function.""" - + def test_cross_k_basic(self): """Test basic cross-K calculation.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20, 30]) - + K = cross_k(coords_a, coords_b, radii, area=100*100) - + assert len(K) == len(radii) assert np.all(K >= 0) assert np.all(np.diff(K) >= 0) - + def test_cross_k_independence(self): """Test cross-K ≈ π*r² for independent patterns.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (100, 2)) coords_b = np.random.uniform(0, 100, (100, 2)) radii = np.linspace(5, 25, 10) - + K = cross_k(coords_a, coords_b, radii, area=100*100, edge_correction='none') K_expected = np.pi * radii**2 - + # Should be close to independence relative_diff = np.abs(K - K_expected) / K_expected assert np.mean(relative_diff) < 0.3 - + def test_cross_k_attraction(self): """Test cross-K elevated for attracted patterns.""" np.random.seed(42) @@ -260,79 +256,79 @@ def test_cross_k_attraction(self): center = np.random.uniform(20, 80, 2) clusters_a.append(np.random.normal(center, 5, (20, 2))) clusters_b.append(np.random.normal(center, 5, (20, 2))) - + coords_a = np.vstack(clusters_a) coords_b = np.vstack(clusters_b) - + radii = np.array([10, 20, 30]) K = cross_k(coords_a, coords_b, radii, area=100*100) K_expected = np.pi * radii**2 - + # Should show attraction assert np.any(K > K_expected * 1.5) - + def test_cross_k_empty(self): """Test cross-K with empty sets.""" coords_a = np.array([]).reshape(0, 2) coords_b = np.random.rand(10, 2) radii = np.array([10, 20]) - + K = cross_k(coords_a, coords_b, radii) assert np.all(K == 0) class TestCrossL: """Tests for cross-type L-function.""" - + def test_cross_l_basic(self): """Test cross-L calculation.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20, 30]) - + L = cross_l(coords_a, coords_b, radii, area=100*100) - + assert len(L) == len(radii) assert np.all(L >= 0) - + def test_cross_l_from_k(self): """Test L = sqrt(K/π).""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20]) - + K = cross_k(coords_a, coords_b, radii, area=100*100) L = cross_l(coords_a, coords_b, radii, area=100*100) - + np.testing.assert_array_almost_equal(L, np.sqrt(K / np.pi)) class TestCrossH: """Tests for cross-type H-function.""" - + def test_cross_h_basic(self): """Test cross-H calculation.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20, 30]) - + H = cross_h(coords_a, coords_b, radii, area=100*100) - + assert len(H) == len(radii) - + def test_cross_h_from_l(self): """Test H = L - r.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20]) - + L = cross_l(coords_a, coords_b, radii, area=100*100) H = cross_h(coords_a, coords_b, radii, area=100*100) - + np.testing.assert_array_almost_equal(H, L - radii) @@ -342,68 +338,68 @@ def test_cross_h_from_l(self): class TestGFunction: """Tests for G-function.""" - + def test_g_function_basic(self): """Test G-function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([5, 10, 15, 20]) - + G = g_function(coords, radii) - + assert len(G) == len(radii) assert np.all(G >= 0) assert np.all(G <= 1) # G should be non-decreasing assert np.all(np.diff(G) >= -1e-10) - + def test_g_function_cumulative(self): """Test G is cumulative distribution.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.linspace(0, 50, 20) - + G = g_function(coords, radii) - + # Should start near 0 and approach 1 assert G[0] < 0.2 assert G[-1] > 0.7 # Relaxed slightly from 0.8 - + def test_g_function_edge_correction(self): """Test different edge correction methods.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + G_none = g_function(coords, radii, edge_correction='none') G_km = g_function(coords, radii, edge_correction='km') - + # Should produce different results assert not np.allclose(G_none, G_km) class TestGFunctionCross: """Tests for cross-type G-function.""" - + def test_g_function_cross_basic(self): """Test cross-G calculation.""" np.random.seed(42) coords_a = np.random.uniform(0, 100, (50, 2)) coords_b = np.random.uniform(0, 100, (50, 2)) radii = np.array([10, 20, 30]) - + G = g_function_cross(coords_a, coords_b, radii) - + assert len(G) == len(radii) assert np.all(G >= 0) assert np.all(G <= 1) - + def test_g_function_cross_empty(self): """Test with empty sets.""" coords_a = np.array([]).reshape(0, 2) coords_b = np.random.rand(10, 2) radii = np.array([10, 20]) - + G = g_function_cross(coords_a, coords_b, radii) assert np.all(G == 0) @@ -414,52 +410,52 @@ def test_g_function_cross_empty(self): class TestFFunction: """Tests for F-function.""" - + def test_f_function_basic(self): """Test F-function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + F = f_function(coords, radii, n_test_points=500, seed=42) - + assert len(F) == len(radii) assert np.all(F >= 0) assert np.all(F <= 1) - + def test_f_function_reproducibility(self): """Test F-function reproducibility with seed.""" coords = np.random.rand(50, 2) * 100 radii = np.array([10, 20]) - + F1 = f_function(coords, radii, seed=42) F2 = f_function(coords, radii, seed=42) - + np.testing.assert_array_almost_equal(F1, F2) class TestJFunction: """Tests for J-function.""" - + def test_j_function_basic(self): """Test J-function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.array([10, 20, 30]) - + J = j_function(coords, radii, seed=42) - + assert len(J) == len(radii) assert np.all(np.isfinite(J)) - + def test_j_function_csr(self): """Test J ≈ 1 for CSR pattern.""" np.random.seed(42) coords = np.random.uniform(0, 100, (200, 2)) radii = np.linspace(5, 20, 10) - + J = j_function(coords, radii, n_test_points=500, seed=42) - + # J should be around 1, but can be highly variable for small samples # Just check that it's finite and positive assert np.all(np.isfinite(J)) @@ -472,29 +468,29 @@ def test_j_function_csr(self): class TestPairCorrelationFunction: """Tests for pair correlation function.""" - + def test_pcf_basic(self): """Test pair correlation function calculation.""" np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) radii = np.linspace(5, 30, 20) - + g = pair_correlation_function(coords, radii, area=100*100) - + assert len(g) == len(radii) assert np.all(g >= 0) - + def test_pcf_csr(self): """Test g(r) ≈ 1 for CSR pattern.""" np.random.seed(42) coords = np.random.uniform(0, 100, (300, 2)) radii = np.linspace(5, 30, 15) - + g = pair_correlation_function(coords, radii, area=100*100) - + # Should oscillate around 1 for CSR assert np.mean(np.abs(g - 1)) < 0.5 - + def test_pcf_clustered(self): """Test g(r) > 1 at small scales for clustered pattern.""" np.random.seed(42) @@ -502,10 +498,10 @@ def test_pcf_clustered(self): clusters = [np.random.normal([25, 25], 3, (30, 2)), np.random.normal([75, 75], 3, (30, 2))] coords = np.vstack(clusters) - + radii = np.array([5, 10, 15, 20]) g = pair_correlation_function(coords, radii, area=100*100) - + # Should be elevated at small scales assert g[0] > 1.5 @@ -516,11 +512,11 @@ def test_pcf_clustered(self): class TestCSREnvelope: """Tests for CSR envelope generation.""" - + def test_csr_envelope_basic(self): """Test CSR envelope generation.""" radii = np.array([10, 20, 30]) - + envelope = csr_envelope( n_points=100, radii=radii, @@ -529,40 +525,40 @@ def test_csr_envelope_basic(self): statistic='H', seed=42 ) - + assert 'theoretical' in envelope assert 'lower' in envelope assert 'upper' in envelope assert 'simulations' in envelope - + assert len(envelope['theoretical']) == len(radii) assert len(envelope['lower']) == len(radii) assert envelope['simulations'].shape == (19, len(radii)) - + def test_csr_envelope_h_theoretical(self): """Test H theoretical values are 0.""" radii = np.array([10, 20, 30]) - + envelope = csr_envelope(100, radii, 100*100, statistic='H', seed=42) - + np.testing.assert_array_almost_equal(envelope['theoretical'], [0, 0, 0]) - + def test_csr_envelope_k_theoretical(self): """Test K theoretical values.""" radii = np.array([10, 20, 30]) - + envelope = csr_envelope(100, radii, 100*100, statistic='K', seed=42) - + expected = np.pi * radii**2 np.testing.assert_array_almost_equal(envelope['theoretical'], expected) - + def test_csr_envelope_reproducibility(self): """Test envelope reproducibility with seed.""" radii = np.array([10, 20]) - + env1 = csr_envelope(50, radii, 100*100, n_simulations=10, seed=42) env2 = csr_envelope(50, radii, 100*100, n_simulations=10, seed=42) - + np.testing.assert_array_almost_equal(env1['lower'], env2['lower']) np.testing.assert_array_almost_equal(env1['upper'], env2['upper']) @@ -573,41 +569,41 @@ def test_csr_envelope_reproducibility(self): class TestSpatialStatistics: """Tests for high-level spatial_statistics function.""" - + def test_spatial_statistics_basic(self, small_tissue): """Test spatial statistics computation.""" result = spatial_statistics( small_tissue, statistics=['K', 'L', 'H'] ) - + assert 'radii' in result assert 'K' in result assert 'L' in result assert 'H' in result - + assert len(result['K']) == len(result['radii']) - + def test_spatial_statistics_custom_radii(self, small_tissue): """Test with custom radii.""" radii = np.array([10, 20, 30, 40]) - + result = spatial_statistics(small_tissue, radii=radii) - + np.testing.assert_array_equal(result['radii'], radii) - + def test_spatial_statistics_cell_type(self, small_tissue): """Test statistics for specific cell type.""" cell_type = small_tissue.cell_types_unique[0] - + result = spatial_statistics( small_tissue, cell_type=cell_type, statistics=['H'] ) - + assert 'H' in result - + def test_spatial_statistics_all_stats(self, small_tissue): """Test computing all statistics.""" result = spatial_statistics( @@ -615,38 +611,38 @@ def test_spatial_statistics_all_stats(self, small_tissue): statistics=['K', 'L', 'H', 'G', 'F', 'J', 'g'], n_radii=10 ) - + for stat in ['K', 'L', 'H', 'G', 'F', 'J', 'g']: assert stat in result class TestCrossTypeStatistics: """Tests for cross_type_statistics function.""" - + def test_cross_type_statistics_basic(self, small_tissue): """Test cross-type statistics.""" types = small_tissue.cell_types_unique if len(types) < 2: pytest.skip("Need at least 2 cell types") - + result = cross_type_statistics( small_tissue, type_a=types[0], type_b=types[1], statistics=['K', 'L', 'H'] ) - + assert 'radii' in result assert 'K' in result assert 'L' in result assert 'H' in result - + def test_cross_type_statistics_g_function(self, small_tissue): """Test cross-G function.""" types = small_tissue.cell_types_unique if len(types) < 2: pytest.skip("Need at least 2 cell types") - + result = cross_type_statistics( small_tissue, type_a=types[0], @@ -654,7 +650,7 @@ def test_cross_type_statistics_g_function(self, small_tissue): statistics=['G'], n_radii=10 ) - + assert 'G' in result assert np.all(result['G'] >= 0) assert np.all(result['G'] <= 1) @@ -666,73 +662,73 @@ def test_cross_type_statistics_g_function(self, small_tissue): class TestColocalizationQuotient: """Tests for colocalization quotient.""" - + def test_clq_basic(self, small_tissue): """Test basic CLQ calculation.""" types = small_tissue.cell_types_unique if len(types) < 2: pytest.skip("Need at least 2 cell types") - + clq = colocalization_quotient( small_tissue, type_a=types[0], type_b=types[1], radius=30 ) - + assert isinstance(clq, float) assert clq >= 0 - + def test_clq_attracted(self): """Test CLQ > 1 for attracted pattern.""" # Create attracted pattern np.random.seed(42) coords_a = np.random.normal([30, 30], 10, (40, 2)) coords_b = np.random.normal([30, 30], 10, (40, 2)) - + coords = np.vstack([coords_a, coords_b]) types = np.array(['A']*40 + ['B']*40) data = SpatialTissueData(coords, types) - + clq = colocalization_quotient(data, 'A', 'B', radius=20) - + # Should show attraction assert clq > 1.0 - + def test_clq_repulsed(self): """Test CLQ < 1 for repulsed pattern.""" # Create segregated pattern coords_a = np.random.uniform([0, 0], [30, 100], (40, 2)) coords_b = np.random.uniform([70, 0], [100, 100], (40, 2)) - + coords = np.vstack([coords_a, coords_b]) types = np.array(['A']*40 + ['B']*40) data = SpatialTissueData(coords, types) - + clq = colocalization_quotient(data, 'A', 'B', radius=20) - + # Should show repulsion assert clq < 0.5 class TestColocalizationMatrix: """Tests for colocalization matrix.""" - + def test_clq_matrix_basic(self, small_tissue): """Test CLQ matrix calculation.""" matrix = colocalization_matrix(small_tissue, radius=30) - + n_types = len(small_tissue.cell_types_unique) assert matrix.shape == (n_types, n_types) - + # Diagonal should be ~1 (self-CLQ) # Off-diagonal varies based on pattern assert np.all(matrix >= 0) - + def test_clq_matrix_symmetry(self, small_tissue): """Test CLQ matrix is symmetric.""" matrix = colocalization_matrix(small_tissue, radius=30) - + np.testing.assert_array_almost_equal(matrix, matrix.T, decimal=10) @@ -742,60 +738,60 @@ def test_clq_matrix_symmetry(self, small_tissue): class TestGetisOrdGiStar: """Tests for Getis-Ord Gi* statistic.""" - + def test_gi_star_basic(self, small_tissue): """Test Gi* calculation.""" # Create some values values = np.random.rand(small_tissue.n_cells) - + # Use return_dict=True to match test expectation result = getis_ord_gi_star(small_tissue, values, radius=30, return_dict=True) - + assert 'gi_star' in result assert len(result['gi_star']) == small_tissue.n_cells - + def test_gi_star_hotspot(self): """Test Gi* detects hotspot.""" # Create pattern with hotspot np.random.seed(42) coords = np.random.uniform(0, 100, (100, 2)) - + # High values in one region values = np.ones(100) center_mask = (coords[:, 0] < 30) & (coords[:, 1] < 30) values[center_mask] = 10.0 - + data = SpatialTissueData(coords, ['A']*100) result = getis_ord_gi_star(data, values, radius=20, return_dict=True) - + # Points in hotspot should have high Gi* assert np.mean(result['gi_star'][center_mask]) > np.mean(result['gi_star'][~center_mask]) class TestDetectHotspots: """Tests for detect_hotspots function.""" - + def test_detect_hotspots_basic(self, small_tissue): """Test hotspot detection.""" values = np.random.rand(small_tissue.n_cells) - + result = detect_hotspots( small_tissue, values, radius=30, significance=0.05 ) - + assert 'hotspot_idx' in result assert 'coldspot_idx' in result assert 'statistic' in result - + def test_detect_hotspots_no_hotspots(self, small_tissue): """Test with uniform values (no hotspots).""" values = np.ones(small_tissue.n_cells) - + result = detect_hotspots(small_tissue, values, radius=30) - + # Should find few or no hotspots assert len(result['hotspot_idx']) < small_tissue.n_cells * 0.1 @@ -806,7 +802,7 @@ def test_detect_hotspots_no_hotspots(self, small_tissue): class TestStatisticsIntegration: """Integration tests for statistics module.""" - + def test_complete_spatial_analysis(self, medium_tissue): """Test complete spatial analysis workflow.""" # 1. Compute basic statistics @@ -816,7 +812,7 @@ def test_complete_spatial_analysis(self, medium_tissue): n_radii=20 ) assert 'H' in result - + # 2. Cross-type analysis types = medium_tissue.cell_types_unique if len(types) >= 2: @@ -827,7 +823,7 @@ def test_complete_spatial_analysis(self, medium_tissue): n_radii=20 ) assert 'H' in cross_result - + # 3. Co-localization if len(types) >= 2: clq = colocalization_quotient( @@ -837,7 +833,7 @@ def test_complete_spatial_analysis(self, medium_tissue): radius=50 ) assert isinstance(clq, float) - + def test_clustering_detection_workflow(self, clustered_pattern): """Test detecting clustering with multiple methods.""" # Method 1: Ripley's H @@ -846,10 +842,10 @@ def test_clustering_detection_workflow(self, clustered_pattern): statistics=['H'], n_radii=30 ) - + # Should show positive H assert np.max(result_h['H']) > 0 - + # Method 2: Pair correlation radii = result_h['radii'] g = pair_correlation_function( @@ -857,7 +853,7 @@ def test_clustering_detection_workflow(self, clustered_pattern): radii, area=clustered_pattern.extent['x'] * clustered_pattern.extent['y'] ) - + # Should show g > 1 at some scales assert np.max(g) > 1.0 @@ -868,24 +864,24 @@ def test_clustering_detection_workflow(self, clustered_pattern): class TestStatisticsReproducibility: """Tests for reproducible results with random seeds.""" - + def test_f_function_reproducibility(self): """Test F-function reproducibility.""" coords = np.random.rand(50, 2) * 100 radii = np.array([10, 20, 30]) - + F1 = f_function(coords, radii, n_test_points=100, seed=42) F2 = f_function(coords, radii, n_test_points=100, seed=42) - + np.testing.assert_array_almost_equal(F1, F2) - + def test_csr_envelope_reproducibility(self): """Test CSR envelope reproducibility.""" radii = np.array([10, 20]) - + env1 = csr_envelope(50, radii, 10000, n_simulations=10, seed=42) env2 = csr_envelope(50, radii, 10000, n_simulations=10, seed=42) - + np.testing.assert_array_almost_equal( env1['simulations'], env2['simulations'] @@ -899,28 +895,28 @@ def test_csr_envelope_reproducibility(self): @pytest.mark.slow class TestStatisticsPerformance: """Performance tests for statistics computations.""" - + def test_ripleys_k_performance(self, large_tissue): """Test K-function performance with 10k cells.""" import time - + coords = large_tissue.coordinates[:, :2] radii = np.linspace(0, 100, 20) - + start = time.time() - K = ripleys_k(coords, radii) + ripleys_k(coords, radii) elapsed = time.time() - start - + # Should complete in reasonable time assert elapsed < 10.0 - + def test_clq_matrix_performance(self, large_tissue): """Test CLQ matrix performance.""" import time - + start = time.time() - matrix = colocalization_matrix(large_tissue, radius=50) + colocalization_matrix(large_tissue, radius=50) elapsed = time.time() - start - + # Should complete in reasonable time assert elapsed < 20.0 diff --git a/tests/test_summary.py b/tests/test_summary.py index f9568b5..7d4fb34 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -5,100 +5,95 @@ for creating ML-ready feature vectors from spatial tissue data. """ -import pytest import numpy as np import pandas as pd +import pytest -from spatialtissuepy import SpatialTissueData from spatialtissuepy.summary import ( - # Registry - register_metric, - get_metric, - list_metrics, - list_categories, - get_registry, MetricInfo, - register_custom_metric, - unregister_custom_metric, + MultiSampleSummary, + SpatialSummary, # Panel StatisticsPanel, - PanelMetric, - load_panel, - list_panels, - # Summary - SpatialSummary, - MultiSampleSummary, - compute_summary, compute_multi_summary, + compute_summary, + get_metric, + get_registry, + list_categories, + list_metrics, + list_panels, + load_panel, + register_custom_metric, + # Registry + unregister_custom_metric, ) - # ============================================================================= # Metric Registry Tests # ============================================================================= class TestMetricRegistry: """Tests for metric registration system.""" - + def test_list_metrics(self): """Test listing all registered metrics.""" metrics = list_metrics() - + assert isinstance(metrics, list) assert len(metrics) > 0 # Should have basic metrics assert 'cell_counts' in metrics assert 'cell_proportions' in metrics - + def test_list_categories(self): """Test listing metric categories.""" categories = list_categories() - + assert isinstance(categories, list) assert len(categories) > 0 # Should have standard categories assert 'population' in categories assert 'spatial' in categories assert 'neighborhood' in categories - + def test_get_metric(self): """Test retrieving a specific metric.""" metric_info = get_metric('cell_counts') - + assert isinstance(metric_info, MetricInfo) assert metric_info.name == 'cell_counts' assert metric_info.func is not None assert metric_info.category == 'population' - + def test_get_metric_not_found(self): """Test error when metric doesn't exist.""" with pytest.raises(KeyError): get_metric('nonexistent_metric_xyz') - + def test_get_registry(self): """Test getting full registry.""" registry = get_registry() assert registry is not None assert 'cell_counts' in registry - + def test_register_custom_metric(self): """Test registering a custom metric.""" # Define custom metric def custom_metric(data): return {'custom_value': 42.0} - + # Register it register_custom_metric( name='test_custom_metric', fn=custom_metric, category='test' ) - + # Should be retrievable metric = get_metric('test_custom_metric') assert metric.name == 'test_custom_metric' assert metric.category == 'test' - + # Clean up unregister_custom_metric('test_custom_metric') @@ -109,51 +104,51 @@ def custom_metric(data): class TestStatisticsPanel: """Tests for StatisticsPanel class.""" - + def test_panel_initialization(self): """Test creating a statistics panel.""" panel = StatisticsPanel(name='my_panel') - + assert isinstance(panel, StatisticsPanel) assert panel.name == 'my_panel' assert len(panel.metrics) == 0 - + def test_panel_add_metric(self): """Test adding metrics to panel.""" panel = StatisticsPanel() panel.add('cell_counts') - + assert len(panel.metrics) == 1 assert panel.metrics[0].name == 'cell_counts' - + def test_panel_add_metric_with_params(self): """Test adding metric with parameters.""" panel = StatisticsPanel() panel.add('ripleys_k', radii=[50, 100, 200]) - + assert len(panel.metrics) == 1 assert panel.metrics[0].params['radii'] == [50, 100, 200] - + def test_panel_add_custom_function(self): """Test adding custom inline function to panel.""" panel = StatisticsPanel() panel.add_custom_function('my_metric', lambda d: {'val': 1.0}) - + assert len(panel.metrics) == 1 assert panel.metrics[0].name == 'my_metric' assert panel.metrics[0].is_inline - + def test_panel_remove_metric(self): """Test removing metrics from panel.""" panel = StatisticsPanel() panel.add('cell_counts') panel.add('cell_proportions') assert len(panel.metrics) == 2 - + panel.remove('cell_counts') assert len(panel.metrics) == 1 assert panel.metrics[0].name == 'cell_proportions' - + def test_panel_clear(self): """Test clearing all metrics.""" panel = StatisticsPanel() @@ -164,14 +159,14 @@ def test_panel_clear(self): class TestPanelPresets: """Tests for predefined panel presets.""" - + def test_load_panel_basic(self): """Test loading basic panel preset.""" panel = load_panel('basic') assert isinstance(panel, StatisticsPanel) assert len(panel.metrics) > 0 assert any(m.name == 'cell_counts' for m in panel.metrics) - + def test_list_panels(self): """Test listing available panel presets.""" panels = list_panels() @@ -186,39 +181,39 @@ def test_list_panels(self): class TestSpatialSummary: """Tests for single-sample spatial summary.""" - + def test_spatial_summary_basic(self, small_tissue): """Test computing summary for a sample.""" panel = StatisticsPanel() panel.add('cell_counts') - + summary = SpatialSummary(small_tissue, panel) assert isinstance(summary, SpatialSummary) - + results = summary.to_dict() assert 'n_cells' in results assert results['n_cells'] == small_tissue.n_cells - + def test_spatial_summary_to_series(self, small_tissue): """Test converting summary to pandas Series.""" panel = StatisticsPanel() panel.add('cell_counts') - + summary = SpatialSummary(small_tissue, panel) series = summary.to_series(name='sample1') - + assert isinstance(series, pd.Series) assert series.name == 'sample1' assert 'n_cells' in series.index - + def test_spatial_summary_to_array(self, small_tissue): """Test converting summary to numpy array.""" panel = StatisticsPanel() panel.add('cell_counts') - + summary = SpatialSummary(small_tissue, panel) array = summary.to_array() - + assert isinstance(array, np.ndarray) assert len(array) > 0 @@ -229,34 +224,34 @@ def test_spatial_summary_to_array(self, small_tissue): class TestMultiSampleSummary: """Tests for multi-sample summary.""" - + def test_multi_sample_summary_basic(self, multisample_cohort): """Test computing summaries for multiple samples.""" panel = StatisticsPanel() panel.add('cell_counts') - + summary = MultiSampleSummary.from_multisample(multisample_cohort, panel) - + assert isinstance(summary, MultiSampleSummary) assert summary.n_samples == multisample_cohort.n_samples - + def test_multi_sample_summary_to_dataframe(self, multisample_cohort): """Test converting multi-sample summary to DataFrame.""" panel = StatisticsPanel() panel.add('cell_counts') - + summary = MultiSampleSummary.from_multisample(multisample_cohort, panel) df = summary.to_dataframe() - + assert isinstance(df, pd.DataFrame) assert len(df) == multisample_cohort.n_samples assert 'n_cells' in df.columns - + def test_multi_sample_summary_parallel(self, multisample_cohort): """Test parallel computation of summaries.""" panel = StatisticsPanel() panel.add('cell_counts') - + # Parallel if joblib available summary = MultiSampleSummary.from_multisample( multisample_cohort, panel, n_jobs=2 @@ -270,17 +265,17 @@ def test_multi_sample_summary_parallel(self, multisample_cohort): class TestConvenienceFunctions: """Tests for convenience functions.""" - + def test_compute_summary(self, small_tissue): """Test compute_summary convenience function.""" series = compute_summary(small_tissue, panel='basic') assert isinstance(series, pd.Series) assert 'n_cells' in series.index - + def test_compute_multi_summary(self, multisample_cohort): """Test compute_multi_summary convenience function.""" df = compute_multi_summary( - [s for _, s in multisample_cohort.iter_samples()], + [s for _, s in multisample_cohort.iter_samples()], panel='basic' ) assert isinstance(df, pd.DataFrame) @@ -293,7 +288,7 @@ def test_compute_multi_summary(self, multisample_cohort): class TestSummaryIntegration: """Integration tests for summary module.""" - + def test_complete_summary_workflow(self, medium_tissue): """Test complete summary workflow.""" panel = StatisticsPanel(name='analysis_panel') @@ -301,10 +296,10 @@ def test_complete_summary_workflow(self, medium_tissue): panel.add('cell_proportions') panel.add('shannon_diversity') panel.add('mean_nearest_neighbor_distance') - + summary = SpatialSummary(medium_tissue, panel) df_row = summary.to_series() - + assert 'n_cells' in df_row assert 'shannon_diversity' in df_row # Implementation returns the full name as the primary key @@ -317,13 +312,13 @@ def test_complete_summary_workflow(self, medium_tissue): class TestSummaryEdgeCases: """Tests for edge cases and error handling.""" - + def test_empty_panel(self, small_tissue): """Test summary with empty panel.""" panel = StatisticsPanel() summary = SpatialSummary(small_tissue, panel) assert len(summary.to_dict()) == 0 - + def test_invalid_panel_name(self, small_tissue): """Test error with invalid panel name.""" with pytest.raises(ValueError): diff --git a/tests/test_synthetic.py b/tests/test_synthetic.py index d85fd2e..b89bd31 100644 --- a/tests/test_synthetic.py +++ b/tests/test_synthetic.py @@ -5,31 +5,26 @@ simulation outputs into SpatialTissueData format. """ -import pytest -import numpy as np -import pandas as pd from pathlib import Path +import numpy as np +import pytest + from spatialtissuepy import SpatialTissueData from spatialtissuepy.synthetic import ( - # Base classes ABMTimeStep, - ABMSimulation, - ABMExperiment, + PhysiCellSimulation, # PhysiCell PhysiCellTimeStep, - PhysiCellSimulation, - PhysiCellExperiment, ) - # ============================================================================= # Base Class Tests # ============================================================================= class TestABMBaseClasses: """Tests for base ABM classes.""" - + def test_abm_timestep_interface(self): """Test ABMTimeStep is an abstract base class.""" # Should not be instantiable directly @@ -43,7 +38,7 @@ def test_abm_timestep_interface(self): class TestPhysiCellTimeStep: """Tests for PhysiCell timestep class logic.""" - + @pytest.fixture def mock_timestep(self): """Create a mock timestep with pre-loaded data.""" @@ -53,7 +48,7 @@ def mock_timestep(self): source_path=Path("output00000010.xml"), cells_mat_path=Path("output00000010_cells.mat") ) - + # Inject mock pre-loaded data to avoid actual file parsing ts._cell_data = { 'positions': np.random.rand(50, 3) * 1000, @@ -76,7 +71,7 @@ def test_physicell_timestep_properties(self, mock_timestep): def test_physicell_timestep_to_spatial_data(self, mock_timestep): """Test converting timestep to SpatialTissueData.""" data = mock_timestep.to_spatial_data() - + assert isinstance(data, SpatialTissueData) assert data.n_cells == 50 assert data.n_dims == 3 @@ -89,27 +84,27 @@ def test_physicell_timestep_to_spatial_data(self, mock_timestep): class TestPhysiCellSimulation: """Tests for PhysiCell simulation class logic.""" - + def test_simulation_iteration(self): """Test that simulation can be iterated over.""" # Mock class since we can't easily create valid PhysiCell folders here class MockSim(PhysiCellSimulation): def __init__(self): self.output_folder = Path("test") - self._timestep_files = [(0, Path("0.xml"), Path("0.mat")), + self._timestep_files = [(0, Path("0.xml"), Path("0.mat")), (1, Path("1.xml"), Path("1.mat"))] self.cell_type_mapping = {0: 'A'} self.include_dead_cells = False self.metadata = {} - + @property def n_timesteps(self): return 2 - + def get_timestep(self, idx): - ts = PhysiCellTimeStep(time=float(idx*10), time_index=idx, + ts = PhysiCellTimeStep(time=float(idx*10), time_index=idx, source_path=self._timestep_files[idx][1]) ts._cell_data = {'positions': np.zeros((10,3)), 'cell_types': np.array(['A']*10), - 'dead_flags': np.zeros(10), 'volumes': np.ones(10), + 'dead_flags': np.zeros(10), 'volumes': np.ones(10), 'radii': np.ones(10), 'ids': np.arange(10), 'phases': np.ones(10)} return ts @@ -125,7 +120,7 @@ def get_timestep(self, idx): class TestSyntheticIntegration: """Integration tests for synthetic module.""" - + def test_timestep_to_analysis_workflow(self): """Test converting timestep to SpatialTissueData for analysis.""" ts = PhysiCellTimeStep(time=0.0, time_index=0, source_path=Path("test.xml")) @@ -138,10 +133,10 @@ def test_timestep_to_analysis_workflow(self): 'ids': np.arange(10), 'phases': np.ones(10) } - + data = ts.to_spatial_data() assert data.n_cells == 10 - + # Verify can compute basic statistics from spatialtissuepy.summary import StatisticsPanel panel = StatisticsPanel().add('cell_counts') diff --git a/tests/test_topology.py b/tests/test_topology.py index ad3a898..d6467e6 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -5,56 +5,40 @@ topological data analysis for discovering cell communities. """ -import pytest import numpy as np import pandas as pd - -from spatialtissuepy import SpatialTissueData +import pytest # Import topology module from spatialtissuepy.topology import ( + AdaptiveCover, + # Cover classes + MapperResult, # Main classes SpatialMapper, - MapperResult, - spatial_mapper, - # Cover classes - Cover, UniformCover, - AdaptiveCover, create_cover, # Standard filters density_filter, + extract_mapper_features, + find_hub_nodes, + node_summary_dataframe, pca_filter, - eccentricity_filter, - entropy_filter, + radial_filter, # Spatial filters spatial_coordinate_filter, - radial_filter, - distance_to_type_filter, - distance_to_boundary_filter, - spatial_density_filter, - gaussian_smoothed_filter, - composite_filter, - # Analysis functions - node_summary_dataframe, - find_hub_nodes, - find_bridge_nodes, - component_statistics, - compare_mapper_results, - extract_mapper_features, - cells_in_multiple_nodes, - uncovered_cells, + spatial_mapper, ) # Check for optional dependencies try: - import networkx as nx + import networkx as nx # noqa: F401 (optional dependency probe) HAS_NETWORKX = True except ImportError: HAS_NETWORKX = False try: - import sklearn + import sklearn # noqa: F401 (optional dependency probe) HAS_SKLEARN = True except ImportError: HAS_SKLEARN = False @@ -66,42 +50,42 @@ class TestCover: """Tests for cover construction.""" - + def test_uniform_cover_basic(self): """Test basic uniform cover.""" cover = UniformCover(n_intervals=5, overlap_fraction=0.3) - + values = np.linspace(0, 100, 200) cover.fit(values) - + assert cover.n_intervals == 5 assert cover.overlap_fraction == 0.3 assert len(cover.elements) == 5 - + def test_uniform_cover_element_assignment(self): """Test assigning values to cover elements.""" cover = UniformCover(n_intervals=3, overlap_fraction=0.2) values = np.array([0, 25, 50, 75, 100]) cover.fit(values) - + members = cover.get_element_members(values) - + assert len(members) == 3 assert all(len(m) > 0 for m in members) - + def test_adaptive_cover_basic(self): """Test adaptive (quantile) cover.""" cover = AdaptiveCover(n_intervals=4, overlap_fraction=0.3) - + values = np.concatenate([ np.random.uniform(0, 10, 80), np.random.uniform(90, 100, 20) ]) cover.fit(values) - + assert cover.n_intervals == 4 assert len(cover.elements) == 4 - + def test_create_cover_factory(self): """Test cover factory.""" assert isinstance(create_cover('uniform'), UniformCover) @@ -114,44 +98,44 @@ def test_create_cover_factory(self): class TestFilterFunctions: """Tests for filter functions.""" - + def test_density_filter_basic(self, small_tissue): """Test density filter.""" filter_fn = density_filter() coords = small_tissue.coordinates features = np.random.rand(len(coords), 5) - + values = filter_fn(coords, features, small_tissue) - + assert len(values) == len(coords) assert np.all(np.isfinite(values)) - + @pytest.mark.skipif(not HAS_SKLEARN, reason="sklearn required for PCA") def test_pca_filter_basic(self, small_tissue): """Test PCA filter.""" filter_fn = pca_filter(n_components=1) coords = small_tissue.coordinates features = np.random.rand(len(coords), 5) - + values = filter_fn(coords, features, small_tissue) - + assert len(values) == len(coords) - + def test_spatial_coordinate_filter(self, small_tissue): """Test coordinate filter.""" filter_fn = spatial_coordinate_filter(axis='x', normalize=False) coords = small_tissue.coordinates values = filter_fn(coords, None, small_tissue) - + np.testing.assert_array_almost_equal(values, coords[:, 0]) - + def test_radial_filter(self, small_tissue): """Test radial filter.""" center = np.array([250, 250]) filter_fn = radial_filter(center=center, normalize=False) coords = small_tissue.coordinates values = filter_fn(coords, None, small_tissue) - + expected = np.linalg.norm(coords - center, axis=1) np.testing.assert_array_almost_equal(values, expected) @@ -162,7 +146,7 @@ def test_radial_filter(self, small_tissue): class TestSpatialMapper: """Tests for SpatialMapper class and results.""" - + @pytest.mark.skipif(not HAS_SKLEARN, reason="sklearn required for clustering") def test_mapper_fit_basic(self, small_tissue): """Test basic Mapper fitting.""" @@ -172,34 +156,34 @@ def test_mapper_fit_basic(self, small_tissue): overlap=0.3, min_cluster_size=2 ) - + result = mapper.fit(small_tissue, neighborhood_radius=100) - + assert isinstance(result, MapperResult) assert result.n_nodes >= 0 assert len(result.filter_values) == small_tissue.n_cells - + @pytest.mark.skipif(not HAS_SKLEARN, reason="sklearn required") def test_spatial_mapper_convenience(self, small_tissue): """Test spatial_mapper convenience function.""" result = spatial_mapper(small_tissue, n_intervals=5) assert isinstance(result, MapperResult) - + @pytest.mark.skipif(not (HAS_SKLEARN and HAS_NETWORKX), reason="sklearn and networkx required") def test_mapper_analysis_functions(self, small_tissue): """Test analysis functions on Mapper results.""" result = spatial_mapper(small_tissue, n_intervals=5, min_cluster_size=2) - + if result.n_nodes > 0: # Node summary df = node_summary_dataframe(result) assert isinstance(df, pd.DataFrame) assert len(df) == result.n_nodes - + # Hub nodes hubs = find_hub_nodes(result, n_hubs=2) assert len(hubs) <= 2 - + # Features features = extract_mapper_features(result) assert isinstance(features, dict) @@ -212,12 +196,12 @@ def test_mapper_analysis_functions(self, small_tissue): class TestTopologyEdgeCases: """Tests for topology edge cases.""" - + def test_invalid_filter(self): """Test error with invalid filter name.""" with pytest.raises(ValueError): SpatialMapper(filter_fn='invalid_filter_name') - + def test_invalid_cover(self): """Test error with invalid cover type.""" with pytest.raises(ValueError): diff --git a/tests/test_viz.py b/tests/test_viz.py index 1b71240..853d526 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -7,8 +7,6 @@ """ import pytest -import numpy as np -import pandas as pd # Check if matplotlib is available try: @@ -19,22 +17,20 @@ except ImportError: HAS_MATPLOTLIB = False -from spatialtissuepy import SpatialTissueData if HAS_MATPLOTLIB: from spatialtissuepy.viz import ( - # Config - set_publication_style, - set_default_style, - get_cell_type_colors, - get_categorical_palette, - save_figure, PlotConfig, - # Spatial plots - plot_spatial_scatter, + get_categorical_palette, + get_cell_type_colors, plot_cell_types, - plot_marker_expression, plot_density_map, + plot_marker_expression, + # Spatial plots + plot_spatial_scatter, + set_default_style, + # Config + set_publication_style, ) @@ -50,27 +46,27 @@ class TestVizConfiguration: """Tests for visualization configuration.""" - + def test_set_publication_style(self): """Test setting publication style.""" config = set_publication_style() assert isinstance(config, PlotConfig) assert config.dpi == 300 - + def test_set_default_style(self): """Test setting default style.""" config = set_default_style() assert isinstance(config, PlotConfig) - + def test_get_cell_type_colors(self): """Test getting cell type color mapping.""" cell_types = ['Tumor', 'T_cell'] colors = get_cell_type_colors(cell_types) - + assert isinstance(colors, dict) assert 'Tumor' in colors assert 'T_cell' in colors - + def test_get_categorical_palette(self): """Test getting categorical color palette.""" palette = get_categorical_palette(n_colors=5) @@ -83,13 +79,13 @@ def test_get_categorical_palette(self): class TestSpatialPlots: """Tests for spatial plotting functions.""" - + def test_plot_spatial_scatter_basic(self, small_tissue): """Test basic spatial scatter plot.""" ax = plot_spatial_scatter(small_tissue) assert isinstance(ax, matplotlib.axes.Axes) plt.close(ax.figure) - + def test_plot_spatial_scatter_color_by_marker(self, tissue_with_markers): """Test coloring by marker.""" marker = tissue_with_markers.marker_names[0] @@ -103,14 +99,14 @@ def test_plot_cell_types(self, small_tissue): fig = plot_cell_types(small_tissue, ncols=2) assert isinstance(fig, matplotlib.figure.Figure) plt.close(fig) - + def test_plot_marker_expression(self, tissue_with_markers): """Test plotting marker expression (faceted).""" markers = tissue_with_markers.marker_names[:2] fig = plot_marker_expression(tissue_with_markers, markers=markers) assert isinstance(fig, matplotlib.figure.Figure) plt.close(fig) - + def test_plot_density_map(self, small_tissue): """Test plotting density map.""" ax = plot_density_map(small_tissue) @@ -124,12 +120,12 @@ def test_plot_density_map(self, small_tissue): class TestVizEdgeCases: """Tests for edge cases and error handling.""" - + def test_plot_with_invalid_marker(self, small_tissue): """Test error with nonexistent marker.""" with pytest.raises(ValueError): plot_spatial_scatter(small_tissue, marker='nonexistent') - + def test_plot_with_wrong_data_type(self): """Test plotting with wrong data type.""" with pytest.raises(AttributeError): From 23738a5fa3f1d72ec704b6a1b771c79ebe86a38e Mon Sep 17 00:00:00 2001 From: Eric Cramer <13970720+emcramer@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:24:00 -0700 Subject: [PATCH 2/2] build: raise Python floor to 3.10 With the lint gate passing, the test matrix ran for the first time and every job failed at install: ERROR: Could not find a version that satisfies the requirement fastmcp<3,>=2.0; extra == "all" The package declared `requires-python = ">=3.8"`, but the `mcp` extra pulls in fastmcp, which requires 3.10 or newer. The two have never been consistent; nothing surfaced it because no CI job had reached the install step. Python 3.8 reached end of life in October 2024 and 3.9 in October 2025. The current releases of the core scientific stack have already moved past both: numpy and scipy now require 3.12, and pandas, scikit-learn and matplotlib require 3.11. Raise requires-python to >=3.10, update the classifiers and the black, ruff and mypy target versions to match, and change the CI matrix to 3.10-3.13. Also ignore UP035 alongside the other annotation-style rules. Raising ruff's target surfaced it, and it cannot be applied independently: dropping the typing imports while the annotations still say `Dict[...]` would not import. --- .github/workflows/tests.yml | 2 +- pyproject.toml | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d6e113..dd410f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 43582cf..8d316c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.2.0" description = "Spatial analysis tools for tissue biology and multiplexed imaging" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.8" +requires-python = ">=3.10" authors = [ {name = "spatialtissuepy developers"} ] @@ -32,11 +32,10 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Image Processing", ] @@ -107,12 +106,12 @@ addopts = "-v --tb=short" [tool.black] line-length = 88 -target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py310', 'py311', 'py312', 'py313'] include = '\.pyi?$' [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" # Tutorial notebooks are prose-first and rely on cross-cell state that ruff # reads as undefined names. They are exercised by nbsphinx at docs build time. extend-exclude = ["docs/tutorials/*.ipynb", "*/.ipynb_checkpoints/*"] @@ -127,12 +126,17 @@ select = [ ] ignore = [ "E501", # line too long (handled by black) - # Annotation-style modernisations. The package still supports Python 3.8, - # and rewriting ~950 annotations to PEP 585/604 form is churn with no - # runtime effect under `from __future__ import annotations`. Revisit when - # the 3.8 floor is dropped. + # Annotation-style modernisations. These are now available on the supported + # interpreters, but the codebase uses typing.Dict/List/Optional/Union + # consistently across ~1,000 sites. Rewriting them all has no runtime effect + # under `from __future__ import annotations` and would conflict with every + # in-flight branch. Worth doing as its own dedicated pass, not as a side + # effect of raising the Python floor. These four rules must move together: + # dropping the typing imports (UP035) while still writing `Dict[...]` + # (UP006) would not even import. "UP006", # non-pep585-annotation (Dict -> dict) "UP007", # non-pep604-annotation-union (Union[x, y] -> x | y) + "UP035", # deprecated-import (from typing import Dict) "UP045", # non-pep604-annotation-optional (Optional[x] -> x | None) ] @@ -141,7 +145,7 @@ ignore = [ "__init__.py" = ["F401"] [tool.mypy] -python_version = "3.8" +python_version = "3.10" warn_return_any = true warn_unused_configs = true ignore_missing_imports = true