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/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..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,17 @@ 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/*"] + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -122,10 +126,26 @@ select = [ ] ignore = [ "E501", # line too long (handled by black) + # 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) ] +[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" +python_version = "3.10" warn_return_any = true warn_unused_configs = true ignore_missing_imports = 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):