From f540d55b780d095e28c9c0754e50a4273303def8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 09:53:15 +1000 Subject: [PATCH 001/123] Update source with black code formatter. --- stlearn/adds/add_deconvolution.py | 1 - stlearn/adds/add_image.py | 1 - stlearn/adds/add_loupe_clusters.py | 1 - stlearn/adds/add_mask.py | 4 ++-- stlearn/adds/add_positions.py | 1 - stlearn/adds/parsing.py | 1 - stlearn/app/source/forms/form_validators.py | 4 ++-- stlearn/app/source/forms/forms.py | 6 +++--- stlearn/app/source/forms/view_helpers.py | 3 +-- stlearn/embedding/fa.py | 1 - stlearn/embedding/ica.py | 1 - stlearn/logging.py | 4 ++-- stlearn/plotting/cci_plot_helpers.py | 5 ++--- stlearn/plotting/classes_bokeh.py | 8 ++++---- stlearn/plotting/cluster_plot.py | 1 - stlearn/plotting/deconvolution_plot.py | 1 - stlearn/plotting/feat_plot.py | 1 + stlearn/plotting/non_spatial_plot.py | 1 - stlearn/plotting/stack_3d_plot.py | 1 - .../plotting/trajectory/DE_transition_plot.py | 17 ++++++++--------- stlearn/plotting/trajectory/local_plot.py | 1 - stlearn/plotting/trajectory/pseudotime_plot.py | 1 - .../trajectory/transition_markers_plot.py | 8 ++++---- stlearn/spatials/clustering/localization.py | 1 - stlearn/spatials/trajectory/local_level.py | 1 - stlearn/spatials/trajectory/pseudotime.py | 1 - stlearn/spatials/trajectory/pseudotimespace.py | 2 -- stlearn/spatials/trajectory/set_root.py | 1 - stlearn/spatials/trajectory/utils.py | 6 ++---- stlearn/tools/clustering/kmeans.py | 1 - stlearn/tools/microenv/cci/analysis.py | 5 +++-- stlearn/tools/microenv/cci/base_grouping.py | 4 ++-- stlearn/tools/microenv/cci/go.py | 3 +-- stlearn/wrapper/read.py | 3 +-- tests/utils.py | 6 +++--- 35 files changed, 41 insertions(+), 66 deletions(-) diff --git a/stlearn/adds/add_deconvolution.py b/stlearn/adds/add_deconvolution.py index 3b5445be..6f571395 100644 --- a/stlearn/adds/add_deconvolution.py +++ b/stlearn/adds/add_deconvolution.py @@ -10,7 +10,6 @@ def add_deconvolution( annotation_path: Union[Path, str], copy: bool = False, ) -> Optional[AnnData]: - """\ Adding label transfered from Seurat diff --git a/stlearn/adds/add_image.py b/stlearn/adds/add_image.py index 83c92d6b..39f895fd 100644 --- a/stlearn/adds/add_image.py +++ b/stlearn/adds/add_image.py @@ -18,7 +18,6 @@ def image( spot_diameter_fullres: float = 50, copy: bool = False, ) -> Optional[AnnData]: - """\ Adding image data to the Anndata object diff --git a/stlearn/adds/add_loupe_clusters.py b/stlearn/adds/add_loupe_clusters.py index af614bd8..116d8eec 100644 --- a/stlearn/adds/add_loupe_clusters.py +++ b/stlearn/adds/add_loupe_clusters.py @@ -13,7 +13,6 @@ def add_loupe_clusters( key_add: str = "multiplex", copy: bool = False, ) -> Optional[AnnData]: - """\ Adding label transfered from Seurat diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 6608e00f..515fa1e9 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -168,8 +168,8 @@ def apply_mask( """ ) mask_image_2d = mask_image.mean(axis=2) - apply_spot_mask = ( - lambda x: [i, mask] + apply_spot_mask = lambda x: ( + [i, mask] if mask_image_2d[int(x["imagerow"]), int(x["imagecol"])] == 1 else [x[key + "_code"], x[key]] ) diff --git a/stlearn/adds/add_positions.py b/stlearn/adds/add_positions.py index 52872384..3435c32d 100644 --- a/stlearn/adds/add_positions.py +++ b/stlearn/adds/add_positions.py @@ -12,7 +12,6 @@ def positions( quality: str = "low", copy: bool = False, ) -> Optional[AnnData]: - """\ Adding spatial information into the Anndata object diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index d92932cc..db241dcd 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -12,7 +12,6 @@ def parsing( coordinates_file: Union[Path, str], copy: bool = True, ) -> Optional[AnnData]: - """\ Parsing the old spaital transcriptomics data diff --git a/stlearn/app/source/forms/form_validators.py b/stlearn/app/source/forms/form_validators.py index 3a82f887..5afc6d3c 100644 --- a/stlearn/app/source/forms/form_validators.py +++ b/stlearn/app/source/forms/form_validators.py @@ -1,5 +1,5 @@ -""" Contains different kinds of form validators. -""" +"""Contains different kinds of form validators.""" + from wtforms.validators import ValidationError diff --git a/stlearn/app/source/forms/forms.py b/stlearn/app/source/forms/forms.py index 0eef6b1d..53ae1908 100644 --- a/stlearn/app/source/forms/forms.py +++ b/stlearn/app/source/forms/forms.py @@ -1,7 +1,7 @@ """Purpose of this script is to create general forms that are programmable with - particular input. Will impliment forms for subsetting the data and - visualisation options in a general way so can be used with any - SingleCellAnalysis dataset. +particular input. Will impliment forms for subsetting the data and +visualisation options in a general way so can be used with any +SingleCellAnalysis dataset. """ import sys diff --git a/stlearn/app/source/forms/view_helpers.py b/stlearn/app/source/forms/view_helpers.py index 499edd7e..ac9c4ea4 100644 --- a/stlearn/app/source/forms/view_helpers.py +++ b/stlearn/app/source/forms/view_helpers.py @@ -1,5 +1,4 @@ -""" Helper functions for views.py. -""" +"""Helper functions for views.py.""" import numpy diff --git a/stlearn/embedding/fa.py b/stlearn/embedding/fa.py index 9c463aee..715de4d5 100644 --- a/stlearn/embedding/fa.py +++ b/stlearn/embedding/fa.py @@ -18,7 +18,6 @@ def run_fa( use_data: str = None, copy: bool = False, ) -> Optional[AnnData]: - """\ Factor Analysis (FA) A simple linear generative model with Gaussian latent variables. diff --git a/stlearn/embedding/ica.py b/stlearn/embedding/ica.py index ed2f1d21..a42cda5a 100644 --- a/stlearn/embedding/ica.py +++ b/stlearn/embedding/ica.py @@ -14,7 +14,6 @@ def run_ica( use_data: str = None, copy: bool = False, ) -> Optional[AnnData]: - """\ FastICA: a fast algorithm for Independent Component Analysis. diff --git a/stlearn/logging.py b/stlearn/logging.py index 674e7f77..63c0f6eb 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -1,5 +1,5 @@ -"""Logging and Profiling -""" +"""Logging and Profiling""" + import logging from functools import update_wrapper, partial from logging import CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 045612e0..6753e44b 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,5 +1,4 @@ -""" Helper functions for cci_plot.py. -""" +"""Helper functions for cci_plot.py.""" import sys import math @@ -159,7 +158,7 @@ def rank_scatter( y, alpha=alpha, c=color, - s=None if type(point_sizes) == type(None) else point_sizes ** point_size_exp, + s=None if type(point_sizes) == type(None) else point_sizes**point_size_exp, edgecolors="none", ) y_min, y_max = ax.get_ylim() diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 7a75b2df..484d495b 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -548,7 +548,7 @@ def make_fig(self): title="Cluster plot", x_range=(0, self.dim - 150), y_range=(self.dim, 0), - output_backend=self.output_backend.value + output_backend=self.output_backend.value, # Specifying xdim/ydim isn't quire right :-( # width=xdim, height=ydim, ) @@ -1410,9 +1410,9 @@ def change_click(): empty_array[:] = np.NaN empty_array = empty_array.astype(object) for i in range(0, len(self.adata[0].uns["annotation"])): - empty_array[ - [np.array(self.adata[0].uns["annotation"]["spot"][i])] - ] = str(self.adata[0].uns["annotation"]["label"][i]) + empty_array[[np.array(self.adata[0].uns["annotation"]["spot"][i])]] = ( + str(self.adata[0].uns["annotation"]["label"][i]) + ) empty_array = pd.Series(empty_array).fillna("other") self.adata[0].obs["annotation"] = pd.Categorical(empty_array) diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index 0cd6645a..a212a317 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -61,7 +61,6 @@ def cluster_plot( trajectory_edge_color: Optional[str] = "#f4efd3", trajectory_arrowsize: Optional[int] = 17, ) -> Optional[AnnData]: - """\ Allows the visualization of a cluster results as the discretes values of dot points in the Spatial transcriptomics array. We also support to diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/plotting/deconvolution_plot.py index e69c2b13..96f83caa 100644 --- a/stlearn/plotting/deconvolution_plot.py +++ b/stlearn/plotting/deconvolution_plot.py @@ -33,7 +33,6 @@ def deconvolution_plot( figsize: tuple = (6.4, 4.8), show=True, ) -> Optional[AnnData]: - """\ Clustering plot for sptial transcriptomics data. Also it has a function to display trajectory inference. diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index 7df51516..3f3b272a 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -23,6 +23,7 @@ from bokeh.io import push_notebook, output_notebook from bokeh.plotting import show + # @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def feat_plot( adata: AnnData, diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/plotting/non_spatial_plot.py index 4e6447d0..e7992d63 100644 --- a/stlearn/plotting/non_spatial_plot.py +++ b/stlearn/plotting/non_spatial_plot.py @@ -17,7 +17,6 @@ def non_spatial_plot( adata: AnnData, use_label: str = "louvain", ) -> Optional[AnnData]: - """\ A wrap function to plot all the non-spatial plot from scanpy. diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index 4128c97f..f229672a 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -11,7 +11,6 @@ def stack_3d_plot( use_label=None, gene_symbol=None, ) -> Optional[AnnData]: - """\ Clustering plot for sptial transcriptomics data. Also it has a function to display trajectory inference. diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/plotting/trajectory/DE_transition_plot.py index 2fc82ebd..d988099c 100644 --- a/stlearn/plotting/trajectory/DE_transition_plot.py +++ b/stlearn/plotting/trajectory/DE_transition_plot.py @@ -12,7 +12,6 @@ def DE_transition_plot( dpi: int = 150, output: str = None, ) -> Optional[AnnData]: - """\ Differential expression between transition markers. @@ -136,7 +135,7 @@ def DE_transition_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=font_size + size=font_size, ) axes[0][1].text( rect.get_x() + 0.01, @@ -144,7 +143,7 @@ def DE_transition_plot( p_value, color="w", **alignment, - size=font_size + size=font_size, ) rects = axes[0][0].patches @@ -161,7 +160,7 @@ def DE_transition_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=font_size + size=font_size, ) axes[0][0].text( rect.get_x() - 0.01, @@ -169,7 +168,7 @@ def DE_transition_plot( p_value, color="w", **alignment, - size=font_size + size=font_size, ) rects = axes[1][1].patches @@ -186,7 +185,7 @@ def DE_transition_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=font_size + size=font_size, ) axes[1][1].text( rect.get_x() + 0.01, @@ -194,7 +193,7 @@ def DE_transition_plot( p_value, color="w", **alignment, - size=font_size + size=font_size, ) rects = axes[1][0].patches @@ -211,7 +210,7 @@ def DE_transition_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=font_size + size=font_size, ) axes[1][0].text( rect.get_x() - 0.01, @@ -219,7 +218,7 @@ def DE_transition_plot( p_value, color="w", **alignment, - size=font_size + size=font_size, ) plt.figtext( diff --git a/stlearn/plotting/trajectory/local_plot.py b/stlearn/plotting/trajectory/local_plot.py index 878d1666..a520f8a9 100644 --- a/stlearn/plotting/trajectory/local_plot.py +++ b/stlearn/plotting/trajectory/local_plot.py @@ -32,7 +32,6 @@ def local_plot( output: str = None, copy: bool = False, ) -> Optional[AnnData]: - """\ Local spatial trajectory inference plot. diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 54359c5a..14c67030 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -39,7 +39,6 @@ def pseudotime_plot( copy: bool = False, ax=None, ) -> Optional[AnnData]: - """\ Global trajectory inference plot (Only DPT). diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/plotting/trajectory/transition_markers_plot.py index 9f81d100..b68ec7fe 100644 --- a/stlearn/plotting/trajectory/transition_markers_plot.py +++ b/stlearn/plotting/trajectory/transition_markers_plot.py @@ -100,7 +100,7 @@ def transition_markers_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=6 + size=6, ) axes[1].text( rect.get_x() + 0.01, @@ -108,7 +108,7 @@ def transition_markers_plot( p_value, color="w", **alignment, - size=6 + size=6, ) rects = axes[0].patches @@ -125,7 +125,7 @@ def transition_markers_plot( rect.get_y() + rect.get_height() / 2.0, gene_name, **alignment, - size=6 + size=6, ) axes[0].text( rect.get_x() - 0.01, @@ -133,7 +133,7 @@ def transition_markers_plot( p_value, color="w", **alignment, - size=6 + size=6, ) plt.figtext(0.5, 0.9, trajectory, ha="center", va="center") diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index c91dd9c7..58f83f8f 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -13,7 +13,6 @@ def localization( min_samples: int = 0, copy: bool = False, ) -> Optional[AnnData]: - """\ Perform local cluster by using DBSCAN. diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index c68888f9..79c0b6e4 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -15,7 +15,6 @@ def local_level( verbose: bool = True, copy: bool = False, ) -> Optional[AnnData]: - """\ Perform local sptial trajectory inference (required run pseudotime first). diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 0c9df496..3710ee61 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -24,7 +24,6 @@ def pseudotime( run_knn: bool = False, copy: bool = False, ) -> Optional[AnnData]: - """\ Perform pseudotime analysis. diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index 230d0ff0..d238a428 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -15,7 +15,6 @@ def pseudotimespace_global( step=0.01, k=10, ) -> Optional[AnnData]: - """\ Perform pseudo-time-space analysis with global level. @@ -68,7 +67,6 @@ def pseudotimespace_local( cluster: list = [], w: float = None, ) -> Optional[AnnData]: - """\ Perform pseudo-time-space analysis with local level. diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index b26c7909..0805c0c6 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -5,7 +5,6 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False): - """\ Automatically set the root index. diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index 54ea41be..e7ab2909 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -581,9 +581,7 @@ def _mat_mat_corr_sparse( n = X.shape[1] X_bar = np.reshape(np.array(X.mean(axis=1)), (-1, 1)) - X_std = np.reshape( - np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar**2)), (-1, 1) - ) + X_std = np.reshape(np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar**2)), (-1, 1)) y_bar = np.reshape(np.mean(Y, axis=0), (1, -1)) y_std = np.reshape(np.std(Y, axis=0), (1, -1)) @@ -629,7 +627,7 @@ def _correlation_test_helper( """ def perm_test_extractor( - res: Sequence[Tuple[np.ndarray, np.ndarray]] + res: Sequence[Tuple[np.ndarray, np.ndarray]], ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: pvals, corr_bs = zip(*res) pvals = np.sum(pvals, axis=0) / float(n_perms) diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tools/clustering/kmeans.py index 0b451cb1..43d4d6da 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tools/clustering/kmeans.py @@ -20,7 +20,6 @@ def kmeans( key_added: str = "kmeans", copy: bool = False, ) -> Optional[AnnData]: - """\ Perform kmeans cluster for spatial transcriptomics data diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index adbb2d19..d6421a64 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -1,5 +1,5 @@ -""" Wrapper function for performing CCI analysis, varrying the analysis based on - the inputted data / state of the anndata object. +"""Wrapper function for performing CCI analysis, varrying the analysis based on +the inputted data / state of the anndata object. """ import os @@ -24,6 +24,7 @@ ) from statsmodels.stats.multitest import multipletests + ################################################################################ # Functions related to Ligand-Receptor interactions # ################################################################################ diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index a24b21e8..882bb011 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -1,5 +1,5 @@ -""" Performs LR analysis by grouping LR pairs which having hotspots across - similar tissues. +"""Performs LR analysis by grouping LR pairs which having hotspots across +similar tissues. """ from stlearn.pl import het_plot diff --git a/stlearn/tools/microenv/cci/go.py b/stlearn/tools/microenv/cci/go.py index 617a7fe5..eff77d09 100644 --- a/stlearn/tools/microenv/cci/go.py +++ b/stlearn/tools/microenv/cci/go.py @@ -1,5 +1,4 @@ -""" Wrapper for performing the LR GO analysis. -""" +"""Wrapper for performing the LR GO analysis.""" import os import stlearn.tools.microenv.cci.r_helpers as rhs diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index a66bf512..42f2d2eb 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -1,5 +1,4 @@ -"""Reading and Writing -""" +"""Reading and Writing""" from pathlib import Path, PurePath from typing import Optional, Union diff --git a/tests/utils.py b/tests/utils.py index a10b5d21..6a5cc78f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,7 +10,7 @@ def read_test_data(): path = os.path.dirname(os.path.realpath(__file__)) adata = sc.read_h5ad(f"{path}/test_data/test_data.h5") im = Image.open(f"{path}/test_data/test_image.jpg") - adata.uns["spatial"]["V1_Breast_Cancer_Block_A_Section_1"]["images"][ - "hires" - ] = np.array(im) + adata.uns["spatial"]["V1_Breast_Cancer_Block_A_Section_1"]["images"]["hires"] = ( + np.array(im) + ) return adata From 975261091b752e25fae03ab8f26550c40115c03c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 10:42:50 +1000 Subject: [PATCH 002/123] Fix some quality issues. --- stlearn/__init__.py | 5 ++--- stlearn/utils.py | 13 ++----------- stlearn/wrapper/convert_scanpy.py | 5 +---- stlearn/wrapper/read.py | 16 ++++++++-------- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 1fc79b20..8b2712ef 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -1,9 +1,8 @@ """Top-level package for stLearn.""" __author__ = """Genomics and Machine Learning lab""" -__email__ = "duy.pham@uq.edu.au" -__version__ = "0.4.11" - +__email__ = "andrew.newman@uq.edu.au" +__version__ = "0.4.2" from . import add from . import pp diff --git a/stlearn/utils.py b/stlearn/utils.py index 0ea54262..ba9e1280 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -1,18 +1,15 @@ import numpy as np -import pandas as pd -import io -from PIL import Image -import matplotlib from anndata import AnnData import networkx as nx from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs from typing import Tuple # Classes from textwrap import dedent from enum import Enum +from matplotlib import axes +from matplotlib.axes import Axes class Empty(Enum): @@ -21,10 +18,6 @@ class Empty(Enum): _empty = Empty.token -from matplotlib import rcParams, ticker, gridspec, axes -from matplotlib.axes import Axes -from abc import ABC - class _AxesSubplot(Axes, axes.SubplotBase): """Intersection between Axes and SubplotBase: Has methods of both""" @@ -110,7 +103,6 @@ def _check_img( def _check_coords( obsm: Optional[Mapping], scale_factor: Optional[float] ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: - image_coor = obsm["spatial"] * scale_factor imagecol = image_coor[:, 0] imagerow = image_coor[:, 1] @@ -119,7 +111,6 @@ def _check_coords( def _read_graph(adata: AnnData, graph_type: Optional[str]): - if graph_type == "PTS_graph": graph = nx.from_scipy_sparse_array( adata.uns[graph_type]["graph"], create_using=nx.DiGraph diff --git a/stlearn/wrapper/convert_scanpy.py b/stlearn/wrapper/convert_scanpy.py index 4cf7e288..e384f0a0 100644 --- a/stlearn/wrapper/convert_scanpy.py +++ b/stlearn/wrapper/convert_scanpy.py @@ -1,8 +1,5 @@ -from typing import Optional, Union +from typing import Optional from anndata import AnnData -from matplotlib import pyplot as plt -from pathlib import Path -import os def convert_scanpy( diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 42f2d2eb..d400d816 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -1,6 +1,6 @@ """Reading and Writing""" -from pathlib import Path, PurePath +from pathlib import Path from typing import Optional, Union from anndata import AnnData import numpy as np @@ -9,10 +9,10 @@ import stlearn from .._compat import Literal import scanpy -import scipy import matplotlib.pyplot as plt from matplotlib.image import imread import json +import logging as logg _QUALITY = Literal["fulres", "hires", "lowres"] _background = ["black", "white"] @@ -185,7 +185,7 @@ def Read10X( else: scale = adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] + ] image_coor = adata.obsm["spatial"] * scale adata.obs["imagecol"] = image_coor[:, 0] @@ -318,7 +318,7 @@ def ReadSlideSeq( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" @@ -401,7 +401,7 @@ def ReadMERFISH( adata_merfish.uns["spatial"][library_id]["scalefactors"] = {} adata_merfish.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata_merfish.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -487,7 +487,7 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -578,7 +578,7 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -658,7 +658,7 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres From 2df855967ed262a9105478ddca4aaf93898aeb77 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 10:43:11 +1000 Subject: [PATCH 003/123] Update project configuration. --- CONTRIBUTING.rst | 26 +++++++++++++------- pyproject.toml | 55 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 22 +++++++++-------- setup.cfg | 21 ----------------- setup.py | 13 +++++----- tests/test_CCI.py | 25 +++++++++----------- tests/test_install.py | 16 ------------- 7 files changed, 103 insertions(+), 75 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 tests/test_install.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index aa232892..fbc922f4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -64,11 +64,19 @@ Ready to contribute? Here's how to set up `stlearn` for local development. $ git clone git@github.com:your_name_here/stlearn.git -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: +3. Install your local copy into a virtualenv. This is how you set up your fork for local development:: - $ mkvirtualenv stlearn + $ conda create -n stlearn-dev python=3.10 + $ conda activate stlearn-dev $ cd stlearn/ - $ python setup.py develop + $ pip install -e .[dev,test] + + Or if you prefer pip/virtualenv:: + + $ python -m venv stlearn-env + $ source stlearn-env/bin/activate # On Windows: stlearn-env\Scripts\activate + $ cd stlearn/ + $ pip install -e .[dev,test] 4. Create a branch for local development:: @@ -76,14 +84,16 @@ Ready to contribute? Here's how to set up `stlearn` for local development. Now you can make your changes locally. -5. When you're done making changes, check that your changes pass flake8 and the - tests, including testing other Python versions with tox:: +5. When you're done making changes, check that your changes pass linters and tests:: + $ black stlearn tests $ flake8 stlearn tests - $ python setup.py test or pytest - $ tox + $ mypy stlearn + $ pytest + +Or run everything with tox:: - To get flake8 and tox, just pip install them into your virtualenv. + $ tox 6. Commit your changes and push your branch to GitHub:: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5c67efd9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "stlearn" +version = "0.4.2" +authors = [ + {name = "Genomics and Machine Learning lab", email = "andrew.newman@uq.edu.au"}, +] +description = "A downstream analysis toolkit for Spatial Transcriptomic data" +readme = {file = "README.md", content-type = "text/markdown"} +license = {text = "BSD license"} +requires-python = ">=3.10" +keywords = ["stlearn"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = ["dependencies"] + +[project.optional-dependencies] +dev = [ + "black", + "flake8", + "mypy", + "pytest", + "tox", +] +test = [ + "pytest", + "pytest-cov", +] + +[project.urls] +Homepage = "https://github.com/BiomedicalMachineLearning/stLearn" +Repository = "https://github.com/BiomedicalMachineLearning/stLearn" + +[project.scripts] +stlearn = "stlearn.app.cli:main" + +[tool.setuptools.packages.find] +include = ["stlearn", "stlearn.*"] + +[tool.setuptools.package-data] +"*" = ["*"] + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c5452f88..616bcd91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ -bokeh>= 2.4.2 -click>=8.0.4 -leidenalg -louvain -numba<=0.57.1 -numpy>=1.18,<1.22 -Pillow>=9.0.1 -scanpy>=1.8.2 -scikit-image>=0.19.2 -tensorflow +bokeh==3.7.3 +click==8.2.1 +leidenalg==0.10.2 +louvain==0.8.2 +numba==0.55.2 +numpy==1.22.4 +pillow==11.2.1 +scanpy==1.9.8 +scikit-image==0.22.0 +tensorflow==2.13.1 +imageio==2.37.0 +scipy==1.11.4 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f877626d..00000000 --- a/setup.cfg +++ /dev/null @@ -1,21 +0,0 @@ -[bumpversion] -current_version = 0.4.11 -commit = True -tag = True - -[bumpversion:file:setup.py] -search = version='{current_version}' -replace = version='{new_version}' - -[bumpversion:file:stlearn/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - -[bdist_wheel] -universal = 1 - -[flake8] -exclude = docs - -[aliases] -# Define setup.py command aliases here diff --git a/setup.py b/setup.py index e728fba4..292288be 100644 --- a/setup.py +++ b/setup.py @@ -20,16 +20,17 @@ setup( author="Genomics and Machine Learning lab", - author_email="duy.pham@uq.edu.au", - python_requires=">=3.7", + author_email="andrew.newman@uq.edu.au", + python_requires=">=3.10", classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Natural Language :: English", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], description="A downstream analysis toolkit for Spatial Transcriptomic data", entry_points={ @@ -49,6 +50,6 @@ test_suite="tests", tests_require=test_requirements, url="https://github.com/BiomedicalMachineLearning/stLearn", - version="0.4.11", + version="0.4.2", zip_safe=False, ) diff --git a/tests/test_CCI.py b/tests/test_CCI.py index b17af940..e31e887f 100644 --- a/tests/test_CCI.py +++ b/tests/test_CCI.py @@ -2,15 +2,12 @@ """Tests for `stlearn` package.""" -import os - import unittest import numpy as np from numba.typed import List import stlearn as st -import scanpy as sc from tests.utils import read_test_data import stlearn.tools.microenv.cci.het_helpers as het_hs @@ -26,7 +23,7 @@ class TestCCI(unittest.TestCase): def setUp(self) -> None: """Setup some basic test-cases as sanity checks.""" - ##### Unit neighbourhood, containing just 1 spot and 6 neighbours ###### + # Unit neighbourhood, containing just 1 spot and 6 neighbours """ * A is the middle spot, B/C/D/E/F/G are the neighbouring spots clock- wise starting at the top-left. @@ -55,7 +52,7 @@ def setUp(self) -> None: self.neighbourhood_indices = neighbourhood_indices self.neigh_dict = neigh_dict - ##### Basic tests ####### + # Basic tests def test_load_lrs(self): """Testing loading lr database.""" sizes = [2293, 4071] # lit lr db size, putative lr db size. @@ -71,7 +68,7 @@ def test_load_lrs(self): lrs = st.tl.cci.load_lrs() self.assertEqual(len(lrs), sizes[0]) - ### Testing loading mouse as species #### + # Testing loading mouse as species lrs = st.tl.cci.load_lrs(species="mouse") genes1 = [lr_.split("_")[0] for lr_ in lrs] genes2 = [lr_.split("_")[1] for lr_ in lrs] @@ -80,9 +77,9 @@ def test_load_lrs(self): self.assertTrue(np.all([gene[0].isupper() for gene in genes2])) self.assertTrue(np.all([gene[1:] == gene[1:].lower() for gene in genes2])) - ####### Important, granular tests related to LR scoring ######### + # Important, granular tests related to LR scoring - ###### Important, granular tests related to CCI counting ####### + # Important, granular tests related to CCI counting def test_edge_retrieval_basic(self): """ Basic test of functionality to retrieve edges via \ get_between_spot_edge_array. @@ -93,7 +90,7 @@ def test_edge_retrieval_basic(self): # Initialising the edge list # edge_list = het_hs.init_edge_list(neighbourhood_bcs) - ############# Basic case, should populate with all edges ############### + # Basic case, should populate with all edges neigh_bool = np.array([True] * len(neighbourhood_bcs)) cell_data = np.array([1] * len(neighbourhood_bcs), dtype=np.float64) het_hs.get_between_spot_edge_array( @@ -115,7 +112,7 @@ def test_edge_retrieval_basic(self): np.all([edge in all_edges or edge[::-1] in all_edges for edge in edge_list]) ) - ########### Some neighbours not valid but no effect on edge list ####### + # Some neighbours not valid but no effect on edge list # No effect since though not a valid neighbour, still a valid spot # edge_list = het_hs.init_edge_list(neighbourhood_bcs) invalid_neighs = ["B", "E"] @@ -130,7 +127,7 @@ def test_edge_retrieval_basic(self): np.all([edge in all_edges or edge[::-1] in all_edges for edge in edge_list]) ) - ########### Some neighbours not valid, effects the edge list ########### + # Some neighbours not valid, effects the edge list # Two neighbouring spots no longer valid neighbours # edge_list = het_hs.init_edge_list(neighbourhood_bcs) invalid_neighs = ["B", "C"] @@ -152,7 +149,7 @@ def test_edge_retrieval_basic(self): ) ) - ######### Middle spot not neighbour, cell type, or spot of interest #### + # Middle spot not neighbour, cell type, or spot of interest # Removing the centre-spot as being the cell type of interest # neigh_bool = np.array([True] * len(neighbourhood_bcs)) neigh_bool[0] = False @@ -177,7 +174,7 @@ def test_edge_retrieval_basic(self): ) ) - ### Corner spot valid neighbour, not cell type, not spot of interest ### + # Corner spot valid neighbour, not cell type, not spot of interest neigh_bool = np.array([True] * len(neighbourhood_bcs)) cell_data = np.array([1] * len(neighbourhood_bcs), dtype=np.float64) cell_data[1] = 0 @@ -209,7 +206,7 @@ def test_get_interactions(self): and spots of another cell type expressing the receptor. """ - ####### Case 1 ###### + # Case 1 """ Middle spot only spot of interest. Cell type 1, 2, or 3. Middle spot expresses ligand. diff --git a/tests/test_install.py b/tests/test_install.py deleted file mode 100644 index 5ff4160e..00000000 --- a/tests/test_install.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Tests that everything is installed correctly. -""" - -import unittest - - -class TestCCI(unittest.TestCase): - """Tests for `stlearn` importability, i.e. correct installation.""" - - def test_SME(self): - import stlearn.spatials.SME.normalize as sme_normalise - - def test_cci(self): - """Tests CCI can be imported.""" - import stlearn.tools.microenv.cci.analysis as an From 34af76bbb7da685634ebab874a5dacff5c31a6bc Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 10:48:18 +1000 Subject: [PATCH 004/123] Improve list of files to ignore. --- .gitignore | 63 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index c5ab06d4..b5495d15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,68 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class *.pyc -.ipynb_checkpoints -*/.ipynb_checkpoints/* + +# C extensions +*.so + +# Distribution / packaging +.Python build/ +data/samples +develop-eggs/ dist/ -*.egg-info +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit tests / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Jupyter Notebook +.ipynb_checkpoints +*/.ipynb_checkpoints/* /*.ipynb + +# Data files /*.csv -output/ + +# MacOS caching .DS_Store */.DS_Store + +# PyCharm etc .idea/ + +# Sphinx documentation docs/_build + +# Distribution/package/temporary files data/ tiling/ -.pytest_cache figures/ *.h5ad -inferCNV/ -stlearn/tools/microenv/cci/junk_code.py -stlearn/tools/microenv/cci/.Rhistory From 7b762c4f28049b76754661e39f31beae62ed9af6 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 10:48:29 +1000 Subject: [PATCH 005/123] Small reformat. --- stlearn/wrapper/read.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index d400d816..3f6a1f52 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -185,7 +185,7 @@ def Read10X( else: scale = adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] + ] image_coor = adata.obsm["spatial"] * scale adata.obs["imagecol"] = image_coor[:, 0] @@ -318,7 +318,7 @@ def ReadSlideSeq( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" @@ -401,7 +401,7 @@ def ReadMERFISH( adata_merfish.uns["spatial"][library_id]["scalefactors"] = {} adata_merfish.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata_merfish.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -487,7 +487,7 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -578,7 +578,7 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -658,7 +658,7 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres From c301830d8fadeefa04ae0feb8223566b5afe1b4a Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 12:00:32 +1000 Subject: [PATCH 006/123] More style issues. --- .readthedocs.yml | 2 +- stlearn/__init__.py | 19 +++++++++++++++++++ stlearn/wrapper/read.py | 30 ++++++++++++++++++------------ tox.ini | 36 ++++++++++++++++++++++++++---------- 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 6a8f1a14..3ee3a06a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,4 +1,4 @@ build: image: latest python: - version: 3.8 + version: 3.10 diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 8b2712ef..d3a734d5 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -27,3 +27,22 @@ from .wrapper.concatenate_spatial_adata import concatenate_spatial_adata # from . import cli +__all__ = [ + "add", + "pp", + "em", + "tl", + "pl", + "spatial", + "datasets", + "ReadSlideSeq", + "Read10X", + "ReadOldST", + "ReadMERFISH", + "ReadSeqFish", + "ReadXenium", + "create_stlearn", + "settings", + "convert_scanpy", + "concatenate_spatial_adata", +] \ No newline at end of file diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 3f6a1f52..9db3dc25 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -372,7 +372,7 @@ def ReadMERFISH( adata_merfish = counts[coordinates.index, :] adata_merfish.obsm["spatial"] = coordinates.to_numpy() - if scale == None: + if scale is None: max_coor = np.max(adata_merfish.obsm["spatial"]) scale = 2000 / max_coor @@ -429,11 +429,13 @@ def ReadSeqFish( spatial_file Path to spatial location file. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the visium library. Can be modified when concatenating multiple + adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] field Set field of view for SeqFish data spot_diameter_fullres @@ -457,7 +459,7 @@ def ReadSeqFish( adata = AnnData(count) - if scale == None: + if scale is None: max_coor = np.max(spatial[["X", "Y"]]) scale = 2000 / max_coor @@ -517,11 +519,13 @@ def ReadXenium( image_path Path to image. Only need when loading full resolution image. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the visium library. Can be modified when concatenating multiple + adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] spot_diameter_fullres Diameter of spot in full resolution background_color @@ -540,7 +544,7 @@ def ReadXenium( adata.obsm["spatial"] = spatial.values - if scale == None: + if scale is None: max_coor = np.max(adata.obsm["spatial"]) scale = 2000 / max_coor @@ -550,7 +554,7 @@ def ReadXenium( adata.obs["imagecol"] = spatial["imagecol"].values * scale adata.obs["imagerow"] = spatial["imagerow"].values * scale - if image_path != None: + if image_path is not None: stlearn.add.image( adata, library_id=library_id, @@ -606,11 +610,13 @@ def create_stlearn( spatial Pandas Dataframe of spatial location of cells/spots. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the visium library. Can be modified when concatenating multiple + adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] spot_diameter_fullres Diameter of spot in full resolution background_color @@ -623,14 +629,14 @@ def create_stlearn( adata.obsm["spatial"] = spatial.values - if scale == None: + if scale is None: max_coor = np.max(adata.obsm["spatial"]) scale = 2000 / max_coor adata.obs["imagecol"] = spatial["imagecol"].values * scale adata.obs["imagerow"] = spatial["imagerow"].values * scale - if image_path != None: + if image_path is not None: stlearn.add.image( adata, library_id=library_id, diff --git a/tox.ini b/tox.ini index 9aae612f..473ed981 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,36 @@ [tox] -envlist = py35, py36, py37, py38, flake8 +requires = + tox>=4 +env_list = lint, type, 3.1{3,2,1,0}, flake8 -[travis] -python = - 3.8: py38 - 3.7: py37 - 3.6: py36 - 3.5: py35 +[testenv:lint] +description = run linters +skip_install = true +deps = + black +commands = black {posargs:.} + +[testenv:type] +description = run type checks +deps = + mypy +commands = + mypy {posargs:stlearn tests} [testenv:flake8] -basepython = python +description = run flake8 linting +skip_install = true deps = flake8 -commands = flake8 stlearn +commands = flake8 stlearn tests [testenv] setenv = PYTHONPATH = {toxinidir} +deps = + pytest +commands = pytest {posargs} -commands = python setup.py test +[flake8] +max-line-length = 88 +extend-ignore = E203, W503 +exclude = .git,__pycache__,build,dist \ No newline at end of file From b4cfc6b000fea4de0cd6ba60a4d8370b693cd7bc Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 3 Jun 2025 16:39:55 +1000 Subject: [PATCH 007/123] Fix style issues. --- TODO.md | 7 + pyproject.toml | 17 +- stlearn/__init__.py | 34 +- stlearn/__main__.py | 1 - stlearn/_compat.py | 2 +- stlearn/_datasets/_datasets.py | 7 +- stlearn/_settings.py | 117 ++-- stlearn/add.py | 10 - stlearn/adds/add_deconvolution.py | 11 +- stlearn/adds/add_image.py | 16 +- stlearn/adds/add_labels.py | 44 +- stlearn/adds/add_loupe_clusters.py | 13 +- stlearn/adds/add_lr.py | 34 +- stlearn/adds/add_mask.py | 34 +- stlearn/adds/add_positions.py | 13 +- stlearn/adds/annotation.py | 9 +- stlearn/adds/parsing.py | 15 +- stlearn/app/app.py | 55 +- stlearn/app/cli.py | 6 +- stlearn/app/source/forms/form_validators.py | 2 +- stlearn/app/source/forms/forms.py | 161 ++--- stlearn/app/source/forms/helper_functions.py | 5 +- stlearn/app/source/forms/utils.py | 2 - stlearn/app/source/forms/view_helpers.py | 1 - stlearn/app/source/forms/views.py | 36 +- stlearn/classes.py | 29 +- stlearn/datasets.py | 1 - stlearn/em.py | 5 - stlearn/embedding/diffmap.py | 9 +- stlearn/embedding/fa.py | 7 +- stlearn/embedding/ica.py | 11 +- stlearn/embedding/pca.py | 24 +- stlearn/embedding/umap.py | 33 +- .../image_preprocessing/feature_extractor.py | 16 +- stlearn/image_preprocessing/image_tiling.py | 19 +- stlearn/image_preprocessing/model_zoo.py | 10 +- stlearn/image_preprocessing/segmentation.py | 11 +- stlearn/logging.py | 14 +- stlearn/pl.py | 24 - stlearn/plotting/QC_plot.py | 6 +- stlearn/plotting/_docs.py | 27 +- stlearn/plotting/cci_plot.py | 576 +++++++++--------- stlearn/plotting/cci_plot_helpers.py | 237 ++++--- stlearn/plotting/classes.py | 574 +++++++++-------- stlearn/plotting/classes_bokeh.py | 133 ++-- stlearn/plotting/cluster_plot.py | 105 ++-- stlearn/plotting/deconvolution_plot.py | 68 +-- stlearn/plotting/feat_plot.py | 78 +-- stlearn/plotting/gene_plot.py | 83 ++- stlearn/plotting/mask_plot.py | 38 +- stlearn/plotting/non_spatial_plot.py | 17 +- stlearn/plotting/stack_3d_plot.py | 47 +- stlearn/plotting/subcluster_plot.py | 71 +-- .../plotting/trajectory/DE_transition_plot.py | 18 +- stlearn/plotting/trajectory/__init__.py | 20 +- .../plotting/trajectory/check_trajectory.py | 38 +- stlearn/plotting/trajectory/local_plot.py | 55 +- .../plotting/trajectory/pseudotime_plot.py | 88 +-- .../trajectory/transition_markers_plot.py | 20 +- stlearn/plotting/trajectory/tree_plot.py | 89 ++- .../plotting/trajectory/tree_plot_simple.py | 89 ++- stlearn/plotting/trajectory/utils.py | 1 - stlearn/plotting/utils.py | 42 +- stlearn/pp.py | 19 +- stlearn/preprocessing/filter_genes.py | 19 +- stlearn/preprocessing/graph.py | 31 +- stlearn/preprocessing/log_scale.py | 30 +- stlearn/preprocessing/normalize.py | 25 +- stlearn/spatial.py | 14 +- stlearn/spatials/SME/__init__.py | 8 +- stlearn/spatials/SME/_weighting_matrix.py | 20 +- stlearn/spatials/SME/impute.py | 93 +-- stlearn/spatials/SME/normalize.py | 44 +- stlearn/spatials/clustering/__init__.py | 4 + stlearn/spatials/clustering/localization.py | 18 +- stlearn/spatials/morphology/__init__.py | 4 + stlearn/spatials/morphology/adjust.py | 9 +- stlearn/spatials/smooth/__init__.py | 4 + stlearn/spatials/smooth/disk.py | 23 +- stlearn/spatials/trajectory/__init__.py | 32 +- .../trajectory/detect_transition_markers.py | 37 +- stlearn/spatials/trajectory/global_level.py | 81 +-- stlearn/spatials/trajectory/local_level.py | 26 +- stlearn/spatials/trajectory/pseudotime.py | 77 ++- .../spatials/trajectory/pseudotimespace.py | 73 ++- stlearn/spatials/trajectory/set_root.py | 10 +- .../trajectory/shortest_path_spatial_PAGA.py | 1 + stlearn/spatials/trajectory/utils.py | 138 ++--- .../trajectory/weight_optimization.py | 45 +- stlearn/tl.py | 8 +- stlearn/tools/clustering/__init__.py | 8 +- stlearn/tools/clustering/annotate.py | 3 +- stlearn/tools/clustering/kmeans.py | 34 +- stlearn/tools/clustering/louvain.py | 33 +- stlearn/tools/label/label.py | 83 +-- stlearn/tools/microenv/cci/__init__.py | 11 +- stlearn/tools/microenv/cci/analysis.py | 116 ++-- stlearn/tools/microenv/cci/base.py | 282 +++++---- stlearn/tools/microenv/cci/base_grouping.py | 116 ++-- stlearn/tools/microenv/cci/go.py | 3 +- stlearn/tools/microenv/cci/het.py | 53 +- stlearn/tools/microenv/cci/het_helpers.py | 71 +-- stlearn/tools/microenv/cci/merge.py | 12 +- stlearn/tools/microenv/cci/perm_utils.py | 46 +- stlearn/tools/microenv/cci/permutation.py | 256 ++++---- stlearn/utils.py | 39 +- stlearn/wrapper/concatenate_spatial_adata.py | 2 +- stlearn/wrapper/convert_scanpy.py | 4 +- stlearn/wrapper/read.py | 103 ++-- tests/test_CCI.py | 5 +- tests/test_PSTS.py | 6 +- tests/test_SME.py | 4 +- tests/utils.py | 3 +- tox.ini | 17 +- 114 files changed, 2752 insertions(+), 2836 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..8da59d28 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +# TODO + +[] > Python 3.8 +[] Fix quality issues +[] Replace tensorflow with Pytorch +[] Upgrade dependencies + [] Numba \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5c67efd9..fda624e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dynamic = ["dependencies"] [project.optional-dependencies] dev = [ "black", - "flake8", + "ruff", "mypy", "pytest", "tox", @@ -52,4 +52,17 @@ include = ["stlearn", "stlearn.*"] "*" = ["*"] [tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} \ No newline at end of file +dependencies = {file = ["requirements.txt"]} + +[tool.ruff] +line-length=88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP"] +ignore = ["E722", "F811", "N802", "N803", "N806", "N818", "N999", "UP031"] +exclude = [".git", "__pycache__", "build", "dist"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" \ No newline at end of file diff --git a/stlearn/__init__.py b/stlearn/__init__.py index d3a734d5..9736f217 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -4,27 +4,21 @@ __email__ = "andrew.newman@uq.edu.au" __version__ = "0.4.2" -from . import add -from . import pp -from . import em -from . import tl -from . import pl -from . import spatial -from . import datasets - -# Wrapper - -from .wrapper.read import ReadSlideSeq -from .wrapper.read import Read10X -from .wrapper.read import ReadOldST -from .wrapper.read import ReadMERFISH -from .wrapper.read import ReadSeqFish -from .wrapper.read import ReadXenium -from .wrapper.read import create_stlearn - +from . import add, datasets, em, pl, pp, spatial, tl from ._settings import settings -from .wrapper.convert_scanpy import convert_scanpy from .wrapper.concatenate_spatial_adata import concatenate_spatial_adata +from .wrapper.convert_scanpy import convert_scanpy + +# Wrapper +from .wrapper.read import ( + Read10X, + ReadMERFISH, + ReadOldST, + ReadSeqFish, + ReadSlideSeq, + ReadXenium, + create_stlearn, +) # from . import cli __all__ = [ @@ -45,4 +39,4 @@ "settings", "convert_scanpy", "concatenate_spatial_adata", -] \ No newline at end of file +] diff --git a/stlearn/__main__.py b/stlearn/__main__.py index 981709a2..4687bf58 100644 --- a/stlearn/__main__.py +++ b/stlearn/__main__.py @@ -5,6 +5,5 @@ from stlearn.app import main - if __name__ == "__main__": # pragma: no cover main() diff --git a/stlearn/_compat.py b/stlearn/_compat.py index 0ef291a2..ba28b435 100644 --- a/stlearn/_compat.py +++ b/stlearn/_compat.py @@ -2,7 +2,7 @@ from typing import Literal except ImportError: try: - from typing_extensions import Literal + from typing import Literal except ImportError: class LiteralMeta(type): diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 19ffb6d5..a637aed3 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -1,13 +1,14 @@ import scanpy as sc -from .._settings import settings -from pathlib import Path from anndata import AnnData +from .._settings import settings + def example_bcba() -> AnnData: """\ Download processed BCBA data (10X genomics published data). - Reference: https://support.10xgenomics.com/spatial-gene-expression/datasets/1.1.0/V1_Breast_Cancer_Block_A_Section_1 + Reference: + https://support.10xgenomics.com/spatial-gene-expression/datasets/1.1.0/V1_Breast_Cancer_Block_A_Section_1 """ settings.datasetdir.mkdir(exist_ok=True) filename = settings.datasetdir / "example_bcba.h5" diff --git a/stlearn/_settings.py b/stlearn/_settings.py index 30eb017a..91d8b617 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -1,16 +1,16 @@ import inspect import sys -from contextlib import contextmanager +from collections.abc import Iterable +from contextlib import AbstractContextManager, contextmanager from enum import IntEnum +from logging import getLevelName from pathlib import Path from time import time -from logging import getLevelName -from typing import Any, Union, Optional, Iterable, TextIO -from typing import Tuple, List, ContextManager +from typing import Any, TextIO from . import logging -from .logging import _set_log_level, _set_log_file, _RootLogger from ._compat import Literal +from .logging import _RootLogger, _set_log_file, _set_log_level # All the code here migrated from scanpy # It help to work with scanpy package @@ -40,7 +40,7 @@ def level(self) -> int: return getLevelName(_VERBOSITY_TO_LOGLEVEL[self]) @contextmanager - def override(self, verbosity: "Verbosity") -> ContextManager["Verbosity"]: + def override(self, verbosity: "Verbosity") -> AbstractContextManager["Verbosity"]: """\ Temporarily override verbosity """ @@ -49,7 +49,7 @@ def override(self, verbosity: "Verbosity") -> ContextManager["Verbosity"]: settings.verbosity = self -def _type_check(var: Any, varname: str, types: Union[type, Tuple[type, ...]]): +def _type_check(var: Any, varname: str, types: type | tuple[type, ...]): if isinstance(var, types): return if isinstance(types, type): @@ -62,32 +62,32 @@ def _type_check(var: Any, varname: str, types: Union[type, Tuple[type, ...]]): raise TypeError(f"{varname} must be of type {possible_types_str}") -class stLearnConfig: +class stLearnConfig: # noqa N801 """\ Config manager for scanpy. """ def __init__( - self, - *, - verbosity: str = "warning", - plot_suffix: str = "", - file_format_data: str = "h5ad", - file_format_figs: str = "pdf", - autosave: bool = False, - autoshow: bool = True, - writedir: Union[str, Path] = "./write/", - cachedir: Union[str, Path] = "./cache/", - datasetdir: Union[str, Path] = "./data/", - figdir: Union[str, Path] = "./figures/", - cache_compression: Union[str, None] = "lzf", - max_memory=15, - n_jobs=1, - logfile: Union[str, Path, None] = None, - categories_to_ignore: Iterable[str] = ("N/A", "dontknow", "no_gate", "?"), - _frameon: bool = True, - _vector_friendly: bool = False, - _low_resolution_warning: bool = True, + self, + *, + verbosity: str = "warning", + plot_suffix: str = "", + file_format_data: str = "h5ad", + file_format_figs: str = "pdf", + autosave: bool = False, + autoshow: bool = True, + writedir: str | Path = "./write/", + cachedir: str | Path = "./cache/", + datasetdir: str | Path = "./data/", + figdir: str | Path = "./figures/", + cache_compression: str | None = "lzf", + max_memory=15, + n_jobs=1, + logfile: str | Path | None = None, + categories_to_ignore: Iterable[str] = ("N/A", "dontknow", "no_gate", "?"), + _frameon: bool = True, + _vector_friendly: bool = False, + _low_resolution_warning: bool = True, ): # logging self._root_logger = _RootLogger(logging.INFO) # level will be replaced @@ -139,7 +139,7 @@ def verbosity(self) -> Verbosity: return self._verbosity @verbosity.setter - def verbosity(self, verbosity: Union[Verbosity, int, str]): + def verbosity(self, verbosity: Verbosity | int | str): verbosity_str_options = [ v for v in _VERBOSITY_TO_LOGLEVEL if isinstance(v, str) ] @@ -207,7 +207,8 @@ def file_format_figs(self, figure_format: str): @property def autosave(self) -> bool: """\ - Automatically save figures in :attr:`~stlearn._settings.stLearnConfig.figdir` (default `False`). + Automatically save figures in :attr:`~stlearn._settings.stLearnConfig.figdir` + (default `False`). Do not show plots/figures interactively. """ @@ -240,7 +241,7 @@ def writedir(self) -> Path: return self._writedir @writedir.setter - def writedir(self, writedir: Union[str, Path]): + def writedir(self, writedir: str | Path): _type_check(writedir, "writedir", (str, Path)) self._writedir = Path(writedir) @@ -252,7 +253,7 @@ def cachedir(self) -> Path: return self._cachedir @cachedir.setter - def cachedir(self, cachedir: Union[str, Path]): + def cachedir(self, cachedir: str | Path): _type_check(cachedir, "cachedir", (str, Path)) self._cachedir = Path(cachedir) @@ -264,7 +265,7 @@ def datasetdir(self) -> Path: return self._datasetdir @datasetdir.setter - def datasetdir(self, datasetdir: Union[str, Path]): + def datasetdir(self, datasetdir: str | Path): _type_check(datasetdir, "datasetdir", (str, Path)) self._datasetdir = Path(datasetdir).resolve() @@ -276,12 +277,12 @@ def figdir(self) -> Path: return self._figdir @figdir.setter - def figdir(self, figdir: Union[str, Path]): + def figdir(self, figdir: str | Path): _type_check(figdir, "figdir", (str, Path)) self._figdir = Path(figdir) @property - def cache_compression(self) -> Optional[str]: + def cache_compression(self) -> str | None: """\ Compression for `sc.read(..., cache=True)` (default `'lzf'`). @@ -290,7 +291,7 @@ def cache_compression(self) -> Optional[str]: return self._cache_compression @cache_compression.setter - def cache_compression(self, cache_compression: Optional[str]): + def cache_compression(self, cache_compression: str | None): if cache_compression not in {"lzf", "gzip", None}: raise ValueError( f"`cache_compression` ({cache_compression}) " @@ -299,7 +300,7 @@ def cache_compression(self, cache_compression: Optional[str]): self._cache_compression = cache_compression @property - def max_memory(self) -> Union[int, float]: + def max_memory(self) -> int | float: """\ Maximal memory usage in Gigabyte. @@ -308,7 +309,7 @@ def max_memory(self) -> Union[int, float]: return self._max_memory @max_memory.setter - def max_memory(self, max_memory: Union[int, float]): + def max_memory(self, max_memory: int | float): _type_check(max_memory, "max_memory", (int, float)) self._max_memory = max_memory @@ -325,14 +326,14 @@ def n_jobs(self, n_jobs: int): self._n_jobs = n_jobs @property - def logpath(self) -> Optional[Path]: + def logpath(self) -> Path | None: """\ The file path `logfile` was set to. """ return self._logpath @logpath.setter - def logpath(self, logpath: Union[str, Path, None]): + def logpath(self, logpath: str | Path | None): _type_check(logpath, "logfile", (str, Path)) # set via “file object” branch of logfile.setter self.logfile = Path(logpath).open("a") @@ -347,12 +348,13 @@ def logfile(self) -> TextIO: The default `None` corresponds to :obj:`sys.stdout` in jupyter notebooks and to :obj:`sys.stderr` otherwise. - For backwards compatibility, setting it to `''` behaves like setting it to `None`. + For backwards compatibility, setting it to `''` behaves like setting it + to `None`. """ return self._logfile @logfile.setter - def logfile(self, logfile: Union[str, Path, TextIO, None]): + def logfile(self, logfile: str | Path | TextIO | None): if not hasattr(logfile, "write") and logfile: self.logpath = logfile else: # file object @@ -363,7 +365,7 @@ def logfile(self, logfile: Union[str, Path, TextIO, None]): _set_log_file(self) @property - def categories_to_ignore(self) -> List[str]: + def categories_to_ignore(self) -> list[str]: """\ Categories that are omitted in plotting etc. """ @@ -397,16 +399,16 @@ def categories_to_ignore(self, categories_to_ignore: Iterable[str]): ] def set_figure_params( - self, - dpi: int = 80, - dpi_save: int = 150, - frameon: bool = True, - vector_friendly: bool = True, - fontsize: int = 14, - color_map: Optional[str] = None, - format: _Format = "pdf", - transparent: bool = False, - ipython_format: str = "png2x", + self, + dpi: int = 80, + dpi_save: int = 150, + frameon: bool = True, + vector_friendly: bool = True, + fontsize: int = 14, + color_map: str | None = None, + format: _Format = "pdf", + transparent: bool = False, + ipython_format: str = "png2x", ): """\ Set resolution/size, styling and format of figures. @@ -414,18 +416,21 @@ def set_figure_params( Parameters ---------- dpi - Resolution of rendered figures – this influences the size of figures in notebooks. + Resolution of rendered figures – this influences the size of figures + in notebooks. dpi_save Resolution of saved figures. This should typically be higher to achieve publication quality. frameon Add frames and axes labels to scatter plots. vector_friendly - Plot scatter plots using `png` backend even when exporting as `pdf` or `svg`. + Plot scatter plots using `png` backend even when exporting as + `pdf` or `svg`. fontsize Set the fontsize for several `rcParams` entries. Ignored if `scanpy=False`. color_map - Convenience method for setting the default color map. Ignored if `scanpy=False`. + Convenience method for setting the default color map. Ignored if + `scanpy=False`. format This sets the default format for saving figures: `file_format_figs`. transparent diff --git a/stlearn/add.py b/stlearn/add.py index fde7173d..e69de29b 100644 --- a/stlearn/add.py +++ b/stlearn/add.py @@ -1,10 +0,0 @@ -from .adds.add_image import image -from .adds.add_positions import positions -from .adds.parsing import parsing -from .adds.add_lr import lr -from .adds.annotation import annotation -from .adds.add_labels import labels -from .adds.add_deconvolution import add_deconvolution -from .adds.add_mask import add_mask -from .adds.add_mask import apply_mask -from .adds.add_loupe_clusters import add_loupe_clusters diff --git a/stlearn/adds/add_deconvolution.py b/stlearn/adds/add_deconvolution.py index 6f571395..d169b8ed 100644 --- a/stlearn/adds/add_deconvolution.py +++ b/stlearn/adds/add_deconvolution.py @@ -1,15 +1,14 @@ -from typing import Optional, Union -from anndata import AnnData -import pandas as pd -import numpy as np from pathlib import Path +import pandas as pd +from anndata import AnnData + def add_deconvolution( adata: AnnData, - annotation_path: Union[Path, str], + annotation_path: Path | str, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding label transfered from Seurat diff --git a/stlearn/adds/add_image.py b/stlearn/adds/add_image.py index 39f895fd..15a4953b 100644 --- a/stlearn/adds/add_image.py +++ b/stlearn/adds/add_image.py @@ -1,8 +1,8 @@ -from typing import Optional, Union +import os +from pathlib import Path + from anndata import AnnData from matplotlib import pyplot as plt -from pathlib import Path -import os from PIL import Image Image.MAX_IMAGE_PIXELS = None @@ -10,14 +10,14 @@ def image( adata: AnnData, - imgpath: Union[Path, str], + imgpath: Path | str, library_id: str, quality: str = "hires", scale: float = 1.0, visium: bool = False, spot_diameter_fullres: float = 50, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding image data to the Anndata object @@ -28,11 +28,13 @@ def image( imgpath Image path. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the visium library. Can be modified when concatenating + multiple adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow']. + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow']. visium Is this anndata read from Visium platform or not. copy diff --git a/stlearn/adds/add_labels.py b/stlearn/adds/add_labels.py index d4a05451..cb7210bf 100644 --- a/stlearn/adds/add_labels.py +++ b/stlearn/adds/add_labels.py @@ -1,41 +1,45 @@ -from typing import Optional, Union -from anndata import AnnData -from pathlib import Path -import os -import pandas as pd import numpy as np +import pandas as pd +from anndata import AnnData from natsort import natsorted def labels( - adata: AnnData, - label_filepath: str = None, - index_col: int = 0, - use_label: str = None, - sep: str = "\t", - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + label_filepath: str = None, + index_col: int = 0, + use_label: str = None, + sep: str = "\t", + copy: bool = False, +) -> AnnData | None: """\ Add label transfer results into AnnData object Parameters ---------- - adata: AnnData The data object to add L-R info into - label_filepath: str The path to the label transfer results file - use_label: str Where to store the label_transfer results, defaults to 'predictions' in adata.obs & 'label_transfer' in adata.uns. - sep: str Separator of the csv file - copy: bool Copy flag indicating copy or direct edit + adata: AnnData + The data object to add L-R info into + label_filepath: str + The path to the label transfer results file + use_label: str + Where to store the label_transfer results, defaults to 'predictions' + in adata.obs & 'label_transfer' in adata.uns. + sep: str + Separator of the csv file + copy: bool + Copy flag indicating copy or direct edit Returns ------- - adata: AnnData The data object that L-R added into + adata: AnnData + The data object that L-R added into """ labels = pd.read_csv(label_filepath, index_col=index_col, sep=sep) - uns_key = "label_transfer" if type(use_label) == type(None) else use_label + uns_key = "label_transfer" if use_label is None else use_label adata.uns[uns_key] = labels.drop(["predicted.id", "prediction.score.max"], axis=1) - key_add = "predictions" if type(use_label) == type(None) else use_label + key_add = "predictions" if use_label is None else use_label key_source = "predicted.id" adata.obs[key_add] = pd.Categorical( values=np.array(labels[key_source]).astype("U"), diff --git a/stlearn/adds/add_loupe_clusters.py b/stlearn/adds/add_loupe_clusters.py index 116d8eec..4d1baef2 100644 --- a/stlearn/adds/add_loupe_clusters.py +++ b/stlearn/adds/add_loupe_clusters.py @@ -1,18 +1,17 @@ -from typing import Optional, Union -from anndata import AnnData -import pandas as pd -import numpy as np -import stlearn from pathlib import Path + +import numpy as np +import pandas as pd +from anndata import AnnData from natsort import natsorted def add_loupe_clusters( adata: AnnData, - loupe_path: Union[Path, str], + loupe_path: Path | str, key_add: str = "multiplex", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding label transfered from Seurat diff --git a/stlearn/adds/add_lr.py b/stlearn/adds/add_lr.py index 6ed99cde..bafc45ea 100644 --- a/stlearn/adds/add_lr.py +++ b/stlearn/adds/add_lr.py @@ -1,26 +1,28 @@ -from typing import Optional, Union -from anndata import AnnData -from pathlib import Path -import os import pandas as pd +from anndata import AnnData def lr( - adata: AnnData, - db_filepath: str = None, - sep: str = "\t", - source: str = "connectomedb", - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + db_filepath: str = None, + sep: str = "\t", + source: str = "connectomedb", + copy: bool = False, +) -> AnnData | None: """Add significant Ligand-Receptor pairs into AnnData object Parameters ---------- - adata: AnnData The data object to add L-R info into - db_filepath: str The path to the CPDB results file - sep: str Separator of the CPDB results file - source: str Source of LR database (default: connectomedb, can also support 'cellphonedb') - copy: bool Copy flag indicating copy or direct edit + adata: AnnData + The data object to add L-R info into + db_filepath: str + The path to the CPDB results file + sep: str + Separator of the CPDB results file + source: str + Source of LR database (default: connectomedb, can also support 'cellphonedb') + copy: bool + Copy flag indicating copy or direct edit Returns ------- @@ -42,7 +44,7 @@ def lr( elif source == "connectomedb": ctdb = pd.read_csv(db_filepath, sep=sep, quotechar='"', encoding="latin1") adata.uns["lr"] = ( - ctdb["Ligand gene symbol"] + "_" + ctdb["Receptor gene symbol"] + ctdb["Ligand gene symbol"] + "_" + ctdb["Receptor gene symbol"] ).values.tolist() print("connectomedb results added to adata.uns['ctdb']") print("Added ligand receptor pairs to adata.uns['lr'].") diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 515fa1e9..ffc58f51 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -1,19 +1,18 @@ +import os from pathlib import Path + import matplotlib -from matplotlib import pyplot as plt import numpy as np -from typing import Optional, Union from anndata import AnnData -import os -from stlearn._compat import Literal +from matplotlib import pyplot as plt def add_mask( adata: AnnData, - imgpath: Union[Path, str], + imgpath: Path | str, key: str = "mask", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding binary mask image to the Anndata object @@ -38,7 +37,7 @@ def add_mask( quality = adata.uns["spatial"][library_id]["use_quality"] except: raise KeyError( - f"""\ + """\ Please read ST data first and try again """ ) @@ -78,11 +77,11 @@ def add_mask( def apply_mask( adata: AnnData, - masks: Optional[list] = "all", + masks: list | None = "all", select: str = "black", cmap: str = "default", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Parsing the old spaital transcriptomics data @@ -106,6 +105,7 @@ def apply_mask( Array format of image, saving by Pillow package. """ from scanpy.plotting import palettes + from stlearn.plotting import palettes_st if cmap == "vega_10_scanpy": @@ -134,7 +134,7 @@ def apply_mask( quality = adata.uns["spatial"][library_id]["use_quality"] except: raise KeyError( - f"""\ + """\ Please read ST data first and try again """ ) @@ -163,16 +163,18 @@ def apply_mask( mask_image = np.where(mask_image > 155, 0, 1) else: raise ValueError( - f"""\ + """\ Only support black and white mask yet. """ ) mask_image_2d = mask_image.mean(axis=2) - apply_spot_mask = lambda x: ( - [i, mask] - if mask_image_2d[int(x["imagerow"]), int(x["imagecol"])] == 1 - else [x[key + "_code"], x[key]] - ) + + def apply_spot_mask(x): + if mask_image_2d[int(x["imagerow"]), int(x["imagecol"])] == 1: + return [i, mask] + else: + return [x[key + "_code"], x[key]] + spot_mask_df = adata.obs.apply(apply_spot_mask, axis=1, result_type="expand") adata.obs[key + "_code"] = spot_mask_df[0] adata.obs[key] = spot_mask_df[1] diff --git a/stlearn/adds/add_positions.py b/stlearn/adds/add_positions.py index 3435c32d..b993a9d3 100644 --- a/stlearn/adds/add_positions.py +++ b/stlearn/adds/add_positions.py @@ -1,17 +1,16 @@ -from typing import Optional, Union -from anndata import AnnData -import pandas as pd from pathlib import Path -import os + +import pandas as pd +from anndata import AnnData def positions( adata: AnnData, - position_filepath: Union[Path, str] = None, - scale_filepath: Union[Path, str] = None, + position_filepath: Path | str = None, + scale_filepath: Path | str = None, quality: str = "low", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding spatial information into the Anndata object diff --git a/stlearn/adds/annotation.py b/stlearn/adds/annotation.py index a8bc1ac9..fcd6fe52 100644 --- a/stlearn/adds/annotation.py +++ b/stlearn/adds/annotation.py @@ -1,16 +1,13 @@ -from typing import Optional, Union, List + from anndata import AnnData -from matplotlib import pyplot as plt -from pathlib import Path -import os def annotation( adata: AnnData, - label_list: List[str], + label_list: list[str], use_label: str = "louvain", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Adding annotation for cluster diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index db241dcd..e0b2daa5 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -1,17 +1,14 @@ -from typing import Optional, Union -from anndata import AnnData -from matplotlib import pyplot as plt from pathlib import Path -import os -import sys + import numpy as np +from anndata import AnnData def parsing( adata: AnnData, - coordinates_file: Union[Path, str], + coordinates_file: Path | str, copy: bool = True, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Parsing the old spaital transcriptomics data @@ -32,7 +29,7 @@ def parsing( # Get a map of the new coordinates new_coordinates = dict() - with open(coordinates_file, "r") as filehandler: + with open(coordinates_file) as filehandler: for line in filehandler.readlines(): tokens = line.split() assert len(tokens) >= 6 or len(tokens) == 4 @@ -65,7 +62,7 @@ def parsing( imgcol.append(new_x) imgrow.append(new_y) - new_index_values.append("{0}x{1}".format(new_x, new_y)) + new_index_values.append(f"{new_x}x{new_y}") except KeyError: counts_table.drop(index, inplace=True) diff --git a/stlearn/app/app.py b/stlearn/app/app.py index 6eb6a5dc..8f6ccd74 100644 --- a/stlearn/app/app.py +++ b/stlearn/app/app.py @@ -1,53 +1,44 @@ -import os, sys, subprocess +import os +import subprocess +import sys +from threading import Thread sys.path.append(os.path.dirname(__file__)) try: - import flask + import flask # noqa: F401 except ImportError: subprocess.call( "pip install -r " + os.path.dirname(__file__) + "//requirements.txt", shell=True ) -from flask import ( - Flask, - render_template, - request, - flash, - url_for, - redirect, - session, - send_file, -) -from bokeh.embed import components -from bokeh.plotting import figure -from bokeh.resources import INLINE -from werkzeug.utils import secure_filename -import tempfile -import traceback - +import asyncio import tempfile -import shutil -import stlearn -import scanpy import numpy import numpy as np - -import asyncio -from bokeh.server.server import BaseServer -from bokeh.server.tornado import BokehTornado -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop +import scanpy from bokeh.application import Application from bokeh.application.handlers import FunctionHandler -from bokeh.server.server import Server from bokeh.embed import server_document - -from bokeh.layouts import column, row +from bokeh.layouts import row +from bokeh.server.server import Server +from flask import ( + Flask, + flash, + redirect, + render_template, + request, + send_file, + url_for, +) # Functions related to processing the forms. from source.forms import views # for changing data in response to input +from tornado.ioloop import IOLoop +from werkzeug.utils import secure_filename + +import stlearn # Global variables. @@ -497,6 +488,4 @@ def bk_worker(): server.io_loop.start() -from threading import Thread - Thread(target=bk_worker).start() diff --git a/stlearn/app/cli.py b/stlearn/app/cli.py index 20154df4..ff45c24a 100644 --- a/stlearn/app/cli.py +++ b/stlearn/app/cli.py @@ -1,7 +1,9 @@ +import errno +import os + import click -from .. import __version__ -import os +from .. import __version__ @click.group( diff --git a/stlearn/app/source/forms/form_validators.py b/stlearn/app/source/forms/form_validators.py index 5afc6d3c..1d6f2672 100644 --- a/stlearn/app/source/forms/form_validators.py +++ b/stlearn/app/source/forms/form_validators.py @@ -3,7 +3,7 @@ from wtforms.validators import ValidationError -class CheckNumberRange(object): +class CheckNumberRange: def __init__(self, lower, upper, hint=""): self.lower = lower self.upper = upper diff --git a/stlearn/app/source/forms/forms.py b/stlearn/app/source/forms/forms.py index 53ae1908..91aff56e 100644 --- a/stlearn/app/source/forms/forms.py +++ b/stlearn/app/source/forms/forms.py @@ -4,61 +4,60 @@ SingleCellAnalysis dataset. """ -import sys +import wtforms from flask_wtf import FlaskForm # from flask_wtf.file import FileField -from wtforms import SelectMultipleField, SelectField -import wtforms +from wtforms import SelectField, SelectMultipleField def createSuperForm(elements, element_fields, element_values, validators=None): """ Creates a general form; goal is to create a fully programmable form \ - that essentially governs all the options the user will select. + that essentially governs all the options the user will select. - Args: - elements (list): Element names to be rendered on the page, in \ - order of how they will appear on the page. + Args: + elements (list): Element names to be rendered on the page, in \ + order of how they will appear on the page. - element_fields (list): The names of the fields to be rendered. \ - Each field is in same order as 'elements'. \ - Currently supported are: \ - 'Title', 'SelectMultipleField', 'SelectField', \ - 'StringField', 'Text', 'List'. + element_fields (list): The names of the fields to be rendered. \ + Each field is in same order as 'elements'. \ + Currently supported are: \ + 'Title', 'SelectMultipleField', 'SelectField', \ + 'StringField', 'Text', 'List'. - element_values (list): The information which will be put into \ - the field. Changes depending on field: \ + element_values (list): The information which will be put into \ + the field. Changes depending on field: \ - 'Title' and 'Text': 'object' is a string - containing the title which will be added as \ - a heading when rendered on the page. + 'Title' and 'Text': 'object' is a string + containing the title which will be added as \ + a heading when rendered on the page. - 'SelectMultipleField' and 'SelectField': - 'object' is list of options to select from. + 'SelectMultipleField' and 'SelectField': + 'object' is list of options to select from. - 'StringField': - The example values to display within the \ - fields text area. The 'placeholder' option. + 'StringField': + The example values to display within the \ + fields text area. The 'placeholder' option. - 'List': - A list of objects which will be attached \ - to the form. + 'List': + A list of objects which will be attached \ + to the form. - validators (list): A list of functions which take the \ - form as input, used to construct the form validator. \ - Form validator constructed by calling these \ - sequentially with form 'self' as input. + validators (list): A list of functions which take the \ + form as input, used to construct the form validator. \ + Form validator constructed by calling these \ + sequentially with form 'self' as input. - Args: - form (list): A WTForm which has attached as variable all the \ - fields mentioned, so then when rendered as input to - 'SuperDataDisplay.html' shows the form. - """ + Args: + form (list): A WTForm which has attached as variable all the \ + fields mentioned, so then when rendered as input to + 'SuperDataDisplay.html' shows the form. + """ class SuperForm(FlaskForm): """A base form on which all of the fields will be added.""" - if type(validators) == type(None): + if validators is None: validators = [None] * len(elements) # Add the information # @@ -82,7 +81,7 @@ class SuperForm(FlaskForm): # left. setattr(SuperForm, element + "_number", int(multiSelectLeft)) # inverts, so if left, goes right for the next multiSelectField - multiSelectLeft = multiSelectLeft == False + multiSelectLeft = not multiSelectLeft else: multiSelectLeft = True # Reset the MultiSelectField position @@ -100,9 +99,9 @@ class SuperForm(FlaskForm): ) # elif fieldName == 'FileField': - # setattr(SuperForm, element, FileField(validators=validators[i])) - # setattr(SuperForm, element + '_placeholder', # Setting default - # element_values[i]) + # setattr(SuperForm, element, FileField(validators=validators[i])) + # setattr(SuperForm, element + '_placeholder', # Setting default + # element_values[i]) elif fieldName in [ "StringField", @@ -198,10 +197,13 @@ def getCCIForm(adata): related to CCI analysis. """ elements = [ - "Cell information (only discrete labels available, unless mixture already in anndata.uns)", + "Cell information (only discrete labels available, unless mixture already in " + + "anndata.uns)", "Minimum spots for LR to be considered", - "Spot mixture (only if the 'Cell Information' label selected available in anndata.uns)", - "Cell proportion cutoff (value above which cell is considered in spot if 'Spot mixture' selected)", + "Spot mixture (only if the 'Cell Information' label selected available in " + + "anndata.uns)", + "Cell proportion cutoff (value above which cell is considered in spot " + + "if 'Spot mixture' selected)", "Permutations (recommend atleast 1000)", ] element_fields = [ @@ -211,12 +213,12 @@ def getCCIForm(adata): "FloatField", "IntegerField", ] - if type(adata) == type(None): + if adata is None: fields = [] mix = False else: fields = [ - key for key in adata.obs.keys() if type(adata.obs[key].values[0]) == str + key for key in adata.obs.keys() if adata.obs[key].values[0] is str ] mix = fields[0] in adata.uns.keys() element_values = [fields, 20, mix, 0.2, 100] @@ -279,7 +281,7 @@ def getPSTSForm(trajectory, clusts, options): Args: cluster_set (numpy.array): The clusters which can be selected as - the root for psts analysis. + the root for psts analysis. Returns: FlaskForm: With attributes that allow input related to psts. @@ -308,7 +310,7 @@ def getDEAForm(list_labels, methods): Args: cluster_set (numpy.array): The clusters which can be selected as - the root for psts analysis. + the root for psts analysis. Returns: FlaskForm: With attributes that allow input related to psts. @@ -322,43 +324,42 @@ def getDEAForm(list_labels, methods): element_values = [list_labels, methods] return createSuperForm(elements, element_fields, element_values) - ######################## Junk Code ############################################# # def getCCIForm(step_log): -# """ Gets the CCI form generated from the superform above. +# """ Gets the CCI form generated from the superform above. # -# Returns: -# FlaskForm: With attributes that allow for inputs that are related to -# CCI analysis. -# """ -# elements, element_fields, element_values = [], [], [] -# if type(step_log['cci_het']) == type(None): -# # Analysis type form version # -# analysis_elements = ['Cell Heterogeneity Information', # Title -# 'cci_het', -# 'Permutation Testing', # Title -# 'cci_perm'] -# analysis_fields = ['Title', 'SelectField', 'Title', 'SelectField'] -# label_transfer_options = ['Upload Cell Label Transfer', -# 'No Cell Label Transfer'] -# permutation_options = ['With permutation testing', -# 'Without permutation testing'] -# analysis_values = ['', label_transfer_options, '', permutation_options] -# elements += analysis_elements -# element_fields += analysis_fields -# element_values += analysis_values +# Returns: +# FlaskForm: With attributes that allow for inputs that are related to +# CCI analysis. +# """ +# elements, element_fields, element_values = [], [], [] +# if type(step_log['cci_het']) == type(None): +# # Analysis type form version # +# analysis_elements = ['Cell Heterogeneity Information', # Title +# 'cci_het', +# 'Permutation Testing', # Title +# 'cci_perm'] +# analysis_fields = ['Title', 'SelectField', 'Title', 'SelectField'] +# label_transfer_options = ['Upload Cell Label Transfer', +# 'No Cell Label Transfer'] +# permutation_options = ['With permutation testing', +# 'Without permutation testing'] +# analysis_values = ['', label_transfer_options, '', permutation_options] +# elements += analysis_elements +# element_fields += analysis_fields +# element_values += analysis_values # -# else: -# # Core elements regardless of CCI mode # -# elements += ['Neighbourhood distance', -# 'L-R pair input (e.g. L1_R1, L2_R2, ...)'] -# element_fields += ['IntegerField', 'StringField'] -# element_values += [5, ''] +# else: +# # Core elements regardless of CCI mode # +# elements += ['Neighbourhood distance', +# 'L-R pair input (e.g. L1_R1, L2_R2, ...)'] +# element_fields += ['IntegerField', 'StringField'] +# element_values += [5, ''] # -# if step_log['cci_perm']: -# # Including cell heterogeneity information # -# elements += ['Permutations'] -# element_fields += ['IntegerField'] -# element_values += [200] +# if step_log['cci_perm']: +# # Including cell heterogeneity information # +# elements += ['Permutations'] +# element_fields += ['IntegerField'] +# element_values += [200] # -# return createSuperForm(elements, element_fields, element_values, None) +# return createSuperForm(elements, element_fields, element_values, None) diff --git a/stlearn/app/source/forms/helper_functions.py b/stlearn/app/source/forms/helper_functions.py index e9a64e40..692c98a9 100644 --- a/stlearn/app/source/forms/helper_functions.py +++ b/stlearn/app/source/forms/helper_functions.py @@ -1,6 +1,5 @@ # Purpose of this script is to write the functions that help facilitate # subsetting of the data depending on the users input -import numpy def printOut(text, fileName="stdout.txt", close=True, file=None): @@ -8,7 +7,7 @@ def printOut(text, fileName="stdout.txt", close=True, file=None): If close is Fale, returns open file. """ - if type(file) == type(None): + if file is None: file = open(fileName, "w") print(text, file=file) @@ -21,7 +20,7 @@ def printOut(text, fileName="stdout.txt", close=True, file=None): def filterOptions(metaDataSets, options): """Returns options that overlap with keys in metaDataSets dictionary""" - if type(options) == type(None): + if options is None: options = list(metaDataSets.keys()) else: options = [option for option in options if option in metaDataSets.keys()] diff --git a/stlearn/app/source/forms/utils.py b/stlearn/app/source/forms/utils.py index 3782c74f..43284e68 100644 --- a/stlearn/app/source/forms/utils.py +++ b/stlearn/app/source/forms/utils.py @@ -1,7 +1,5 @@ -# -*- coding: utf-8 -*- """Helper utilities and decorators.""" from flask import flash -import matplotlib.pyplot as plt def flash_errors(form, category="warning"): diff --git a/stlearn/app/source/forms/view_helpers.py b/stlearn/app/source/forms/view_helpers.py index ac9c4ea4..a5613b61 100644 --- a/stlearn/app/source/forms/view_helpers.py +++ b/stlearn/app/source/forms/view_helpers.py @@ -1,6 +1,5 @@ """Helper functions for views.py.""" -import numpy def getVal(form, element): diff --git a/stlearn/app/source/forms/views.py b/stlearn/app/source/forms/views.py index 551c737e..09dc3888 100644 --- a/stlearn/app/source/forms/views.py +++ b/stlearn/app/source/forms/views.py @@ -1,25 +1,21 @@ """ This is more a general views focussed on defining functions which are \ - called by other views for specify pages. This way different pages can be \ - used to display different data, but in a consistent way. + called by other views for specify pages. This way different pages can be \ + used to display different data, but in a consistent way. """ import sys +import traceback + import numpy import numpy as np -from flask import flash +import scanpy as sc +import source.forms.view_helpers as vhs +from flask import flash, render_template from source.forms import forms - from source.forms.utils import flash_errors -import source.forms.view_helpers as vhs -import traceback - -from flask import render_template -import scanpy as sc import stlearn as st -from scipy.spatial.distance import cosine - # Creating the forms using a class generator # PreprocessForm = forms.getPreprocessForm() # CCIForm = forms.getCCIForm() #OLD @@ -35,7 +31,7 @@ def run_preprocessing(request, adata, step_log): if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: @@ -87,13 +83,12 @@ def run_lr(request, adata, step_log): if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: step_log["lr_params"] = vhs.getData(form) print(step_log["lr_params"], file=sys.stdout) - elements = numpy.array(list(step_log["lr_params"].keys())) # order: Species, Spot neighbourhood, min_spots, n_pairs, CPUs element_values = list(step_log["lr_params"].values()) dist = element_values[1] @@ -134,13 +129,12 @@ def run_cci(request, adata, step_log): if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: step_log["cci_params"] = vhs.getData(form) print(step_log["cci_params"], file=sys.stdout) - elements = numpy.array(list(step_log["cci_params"].keys())) # order: cell_type, min_spots, spot_mixtures, cell_prop_cutoff, sig_spots # n_perms element_values = list(step_log["cci_params"].values()) @@ -188,14 +182,13 @@ def run_clustering(request, adata, step_log): step_log["cluster_params"] = vhs.getData(form) print(step_log["cluster_params"], file=sys.stdout) - elements = list(step_log["cluster_params"].keys()) # order: pca_comps, SME bool, method, method_param element_values = list(step_log["cluster_params"].values()) if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: @@ -275,7 +268,6 @@ def run_psts(request, adata, step_log): step_log["psts_params"] = vhs.getData(form) print(step_log["psts_params"], file=sys.stdout) - elements = list(step_log["psts_params"].keys()) # order: pca_comps, SME bool, method, method_param element_values = list(step_log["psts_params"].values()) @@ -289,7 +281,7 @@ def run_psts(request, adata, step_log): if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: @@ -349,7 +341,6 @@ def run_psts(request, adata, step_log): def run_dea(request, adata, step_log): - list_labels = [] for col in adata.obs.columns: @@ -366,13 +357,12 @@ def run_dea(request, adata, step_log): step_log["dea_params"] = vhs.getData(form) print(step_log["dea_params"], file=sys.stdout) - elements = list(step_log["dea_params"].keys()) element_values = list(step_log["dea_params"].values()) if not form.validate_on_submit(): flash_errors(form) - elif type(adata) == type(None): + elif adata is None: flash("Need to load data first!") else: diff --git a/stlearn/classes.py b/stlearn/classes.py index afa4b997..f9ef77c2 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -4,37 +4,34 @@ Date: 20 Feb 2021 """ -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes import numpy as np from anndata import AnnData from .utils import ( Empty, - _empty, - _check_spatial_data, + _check_coords, _check_img, - _check_spot_size, _check_scale_factor, - _check_coords, + _check_spatial_data, + _check_spot_size, + _empty, ) -class Spatial(object): +class Spatial: def __init__( self, adata: AnnData, basis: str = "spatial", - img: Union[np.ndarray, None] = None, - img_key: Union[str, None, Empty] = _empty, - library_id: Union[str, None] = _empty, - crop_coord: Optional[bool] = True, - bw: Optional[bool] = False, - scale_factor: Optional[float] = None, - spot_size: Optional[float] = None, - use_raw: Optional[bool] = False, + img: np.ndarray | None = None, + img_key: str | None | Empty = _empty, + library_id: str | None = _empty, + crop_coord: bool | None = True, + bw: bool | None = False, + scale_factor: float | None = None, + spot_size: float | None = None, + use_raw: bool | None = False, **kwargs, ): diff --git a/stlearn/datasets.py b/stlearn/datasets.py index 068c89d0..e69de29b 100644 --- a/stlearn/datasets.py +++ b/stlearn/datasets.py @@ -1 +0,0 @@ -from ._datasets._datasets import example_bcba diff --git a/stlearn/em.py b/stlearn/em.py index 193ade80..6768d1c6 100644 --- a/stlearn/em.py +++ b/stlearn/em.py @@ -1,7 +1,2 @@ -from .embedding.pca import run_pca -from .embedding.umap import run_umap -from .embedding.ica import run_ica # from .embedding.scvi import run_ldvae -from .embedding.fa import run_fa -from .embedding.diffmap import run_diffmap diff --git a/stlearn/embedding/diffmap.py b/stlearn/embedding/diffmap.py index 97f916e8..93a5c480 100644 --- a/stlearn/embedding/diffmap.py +++ b/stlearn/embedding/diffmap.py @@ -1,9 +1,5 @@ -from typing import Optional, Union -import numpy as np -from anndata import AnnData -from numpy.random.mtrand import RandomState -from scipy.sparse import issparse import scanpy +from anndata import AnnData def run_diffmap(adata: AnnData, n_comps: int = 15, copy: bool = False): @@ -41,7 +37,8 @@ def run_diffmap(adata: AnnData, n_comps: int = 15, copy: bool = False): scanpy.tl.diffmap(adata, n_comps=n_comps, copy=copy) print( - "Diffusion Map is done! Generated in adata.obsm['X_diffmap'] nad adata.uns['diffmap_evals']" + "Diffusion Map is done! Generated in adata.obsm['X_diffmap'] and " + + "adata.uns['diffmap_evals']" ) return adata if copy else None diff --git a/stlearn/embedding/fa.py b/stlearn/embedding/fa.py index 715de4d5..a707efb3 100644 --- a/stlearn/embedding/fa.py +++ b/stlearn/embedding/fa.py @@ -1,10 +1,7 @@ -import numpy as np -import pandas as pd -from typing import Optional from anndata import AnnData -from sklearn.decomposition import FactorAnalysis from scipy.sparse import issparse +from sklearn.decomposition import FactorAnalysis def run_fa( @@ -17,7 +14,7 @@ def run_fa( random_state: int = 2108, use_data: str = None, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Factor Analysis (FA) A simple linear generative model with Gaussian latent variables. diff --git a/stlearn/embedding/ica.py b/stlearn/embedding/ica.py index a42cda5a..e99e77ca 100644 --- a/stlearn/embedding/ica.py +++ b/stlearn/embedding/ica.py @@ -1,9 +1,7 @@ -import numpy as np -import pandas as pd -from typing import Optional + from anndata import AnnData -from sklearn.decomposition import FastICA from scipy.sparse import issparse +from sklearn.decomposition import FastICA def run_ica( @@ -13,7 +11,7 @@ def run_ica( tol: float = 0.0001, use_data: str = None, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ FastICA: a fast algorithm for Independent Component Analysis. @@ -64,7 +62,8 @@ def my_g(x): adata.uns["ica"] = {"params": {"n_factors": n_factors, "fun": fun, "tol": tol}} print( - "ICA is done! Generated in adata.obsm['X_ica'] and parameters in adata.uns['ica']" + "ICA is done! Generated in adata.obsm['X_ica'] and parameters in " + + "adata.uns['ica']" ) return adata if copy else None diff --git a/stlearn/embedding/pca.py b/stlearn/embedding/pca.py index 040a3b6f..d4b66f15 100644 --- a/stlearn/embedding/pca.py +++ b/stlearn/embedding/pca.py @@ -1,25 +1,24 @@ -import logging as logg -from typing import Union, Optional, Tuple, Collection, Sequence, Iterable -from anndata import AnnData + import numpy as np -from scipy.sparse import issparse, isspmatrix_csr, csr_matrix, spmatrix -from numpy.random.mtrand import RandomState import scanpy +from anndata import AnnData +from numpy.random.mtrand import RandomState +from scipy.sparse import spmatrix def run_pca( - data: Union[AnnData, np.ndarray, spmatrix], + data: AnnData | np.ndarray | spmatrix, n_comps: int = 50, - zero_center: Optional[bool] = True, + zero_center: bool | None = True, svd_solver: str = "auto", - random_state: Optional[Union[int, RandomState]] = 0, + random_state: int | RandomState | None = 0, return_info: bool = False, - use_highly_variable: Optional[bool] = None, + use_highly_variable: bool | None = None, dtype: str = "float32", copy: bool = False, chunked: bool = False, - chunk_size: Optional[int] = None, -) -> Union[AnnData, np.ndarray, spmatrix]: + chunk_size: int | None = None, +) -> AnnData | np.ndarray | spmatrix: """\ Wrap function scanpy.pp.pca Principal component analysis [Pedregosa11]_. @@ -100,5 +99,6 @@ def run_pca( ) print( - "PCA is done! Generated in adata.obsm['X_pca'], adata.uns['pca'] and adata.varm['PCs']" + "PCA is done! Generated in adata.obsm['X_pca'], adata.uns['pca'] and " + + "adata.varm['PCs']" ) diff --git a/stlearn/embedding/umap.py b/stlearn/embedding/umap.py index 912aaa00..85f6e8b1 100644 --- a/stlearn/embedding/umap.py +++ b/stlearn/embedding/umap.py @@ -1,31 +1,30 @@ -from typing import Optional, Union import numpy as np +import scanpy from anndata import AnnData from numpy.random.mtrand import RandomState from .._compat import Literal -import scanpy _InitPos = Literal["paga", "spectral", "random"] def run_umap( - adata: AnnData, - min_dist: float = 0.5, - spread: float = 1.0, - n_components: int = 2, - maxiter: Optional[int] = None, - alpha: float = 1.0, - gamma: float = 1.0, - negative_sample_rate: int = 5, - init_pos: Union[_InitPos, np.ndarray, None] = "spectral", - random_state: Optional[Union[int, RandomState]] = 0, - a: Optional[float] = None, - b: Optional[float] = None, - copy: bool = False, - method: Literal["umap", "rapids"] = "umap", -) -> Optional[AnnData]: + adata: AnnData, + min_dist: float = 0.5, + spread: float = 1.0, + n_components: int = 2, + maxiter: int | None = None, + alpha: float = 1.0, + gamma: float = 1.0, + negative_sample_rate: int = 5, + init_pos: _InitPos | np.ndarray | None = "spectral", + random_state: int | RandomState | None = 0, + a: float | None = None, + b: float | None = None, + copy: bool = False, + method: Literal["umap", "rapids"] = "umap", # noqa: F821 +) -> AnnData | None: """\ Wrap function scanpy.pp.umap Embed the neighborhood graph using UMAP [McInnes18]_. diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index b2946ee8..0d451041 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -1,15 +1,15 @@ -from .model_zoo import encode, Model -from typing import Optional, Union -from anndata import AnnData + import numpy as np -from .._compat import Literal -from PIL import Image import pandas as pd -from pathlib import Path +from anndata import AnnData +from PIL import Image # Test progress bar from tqdm import tqdm +from .._compat import Literal +from .model_zoo import Model, encode + _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] @@ -20,7 +20,7 @@ def extract_feature( verbose: bool = False, copy: bool = False, seeds: int = 1, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Extract latent morphological features from H&E images using pre-trained convolutional neural network base @@ -63,7 +63,7 @@ def extract_feature( tile = tile.astype(np.float32) tile = np.stack([tile]) if verbose: - print("extract feature for spot: {}".format(str(spot))) + print(f"extract feature for spot: {str(spot)}") features = encode(tile, model) feature_dfs.append(pd.DataFrame(features, columns=[spot])) pbar.update(1) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index bdb88a60..4daee2a8 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -1,25 +1,24 @@ -from typing import Optional, Union +import os +from pathlib import Path + +import numpy as np from anndata import AnnData -from .._compat import Literal from PIL import Image -from pathlib import Path # Test progress bar from tqdm import tqdm -import numpy as np -import os def tiling( adata: AnnData, - out_path: Union[Path, str] = "./tiling", - library_id: Union[str, None] = None, + out_path: Path | str = "./tiling", + library_id: str | None = None, crop_size: int = 40, target_size: int = 299, img_fmt: str = "JPEG", verbose: bool = False, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Tiling H&E images to small tiles based on spot spatial location @@ -93,9 +92,7 @@ def tiling( if verbose: print( - "generate tile at location ({}, {})".format( - str(imagecol), str(imagerow) - ) + f"generate tile at location ({str(imagecol)}, {str(imagerow)})" ) pbar.update(1) diff --git a/stlearn/image_preprocessing/model_zoo.py b/stlearn/image_preprocessing/model_zoo.py index a028f75f..7faf2673 100644 --- a/stlearn/image_preprocessing/model_zoo.py +++ b/stlearn/image_preprocessing/model_zoo.py @@ -8,12 +8,12 @@ class Model: __name__ = "CNN base model" def __init__(self, base, batch_size=1): - from tensorflow.keras import backend as K + from tensorflow.keras import backend as keras self.base = base self.model, self.preprocess = self.load_model() self.batch_size = batch_size - self.data_format = K.image_data_format() + self.data_format = keras.image_data_format() def load_model(self): if self.base == "resnet50": @@ -48,13 +48,13 @@ def load_model(self): include_top=False, weights="imagenet", pooling="avg" ) else: - raise ValueError("{} is not a valid model".format(self.base)) + raise ValueError(f"{self.base} is not a valid model") return cnn_base_model, preprocess_input def predict(self, x): - from tensorflow.keras import backend as K + from tensorflow.keras import backend as keras if self.data_format == "channels_first": x = x.transpose(0, 3, 1, 2) - x = self.preprocess(x.astype(K.floatx())) + x = self.preprocess(x.astype(keras.floatx())) return self.model.predict(x, batch_size=self.batch_size, verbose=False) diff --git a/stlearn/image_preprocessing/segmentation.py b/stlearn/image_preprocessing/segmentation.py index 76023058..8c7f4dfe 100644 --- a/stlearn/image_preprocessing/segmentation.py +++ b/stlearn/image_preprocessing/segmentation.py @@ -1,4 +1,4 @@ -from typing import Optional + import histomicstk as htk import numpy as np import scipy as sp @@ -17,7 +17,7 @@ def morph_watershed( library_id: str = None, verbose: bool = False, copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: """\ Watershed method to segment nuclei and calculate morphological statistics @@ -163,13 +163,6 @@ def _calculate_morph_stats(tile_path): # compute nuclei properties objProps = skimage.measure.regionprops(im_nuclei_seg_mask) - # # Display results - # plt.figure(figsize=(20, 10)) - # plt.imshow(skimage.color.label2rgb(im_nuclei_seg_mask, im_nuclei_stain, bg_label=0), - # origin='upper') - # plt.title('Nuclei segmentation mask overlay') - # plt.savefig("./Nuclei_segmentation_tiles_bc_wh/{}.png".format(tile_path.split("/")[-1].split(".")[0]), dpi=300) - n_nuclei = len(objProps) nuclei_total_area = sum(map(lambda x: x.area, objProps)) diff --git a/stlearn/logging.py b/stlearn/logging.py index 63c0f6eb..e23e2786 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -1,14 +1,12 @@ """Logging and Profiling""" import logging -from functools import update_wrapper, partial -from logging import CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET from datetime import datetime, timedelta, timezone -from typing import Optional +from functools import partial, update_wrapper +from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING import anndata.logging - HINT = (INFO + DEBUG) // 2 logging.addLevelName(HINT, "HINT") @@ -24,9 +22,9 @@ def log( level: int, msg: str, *, - extra: Optional[dict] = None, + extra: dict | None = None, time: datetime = None, - deep: Optional[str] = None, + deep: str | None = None, ) -> datetime: from . import settings @@ -180,8 +178,8 @@ def error( msg: str, *, time: datetime = None, - deep: Optional[str] = None, - extra: Optional[dict] = None, + deep: str | None = None, + extra: dict | None = None, ) -> datetime: """\ Log message with specific level and return current time. diff --git a/stlearn/pl.py b/stlearn/pl.py index 7f7577d4..54be1ef2 100644 --- a/stlearn/pl.py +++ b/stlearn/pl.py @@ -1,26 +1,2 @@ -from .plotting.gene_plot import gene_plot -from .plotting.gene_plot import gene_plot_interactive -from .plotting.feat_plot import feat_plot -from .plotting.cluster_plot import cluster_plot -from .plotting.cluster_plot import cluster_plot_interactive -from .plotting.subcluster_plot import subcluster_plot -from .plotting.non_spatial_plot import non_spatial_plot -from .plotting.deconvolution_plot import deconvolution_plot -from .plotting.stack_3d_plot import stack_3d_plot -from .plotting import trajectory -from .plotting.QC_plot import QC_plot -from .plotting.cci_plot import het_plot # from .plotting.cci_plot import het_plot_interactive -from .plotting.cci_plot import lr_plot_interactive, spatialcci_plot_interactive -from .plotting.cci_plot import grid_plot -from .plotting.cci_plot import lr_diagnostics, lr_n_spots, lr_summary, lr_go -from .plotting.cci_plot import lr_plot, lr_result_plot -from .plotting.cci_plot import ( - ccinet_plot, - cci_map, - lr_cci_map, - lr_chord_plot, - cci_check, -) -from .plotting.mask_plot import plot_mask diff --git a/stlearn/plotting/QC_plot.py b/stlearn/plotting/QC_plot.py index 186d542b..9b4af383 100644 --- a/stlearn/plotting/QC_plot.py +++ b/stlearn/plotting/QC_plot.py @@ -1,7 +1,7 @@ -from matplotlib import pyplot as plt + import numpy as np -from typing import Optional, Union from anndata import AnnData +from matplotlib import pyplot as plt def QC_plot( @@ -19,7 +19,7 @@ def QC_plot( margin: int = 100, dpi: int = 150, output: str = None, -) -> Optional[AnnData]: +) -> AnnData | None: """\ QC plot for sptial transcriptomics data. diff --git a/stlearn/plotting/_docs.py b/stlearn/plotting/_docs.py index f9a66165..dbf36984 100644 --- a/stlearn/plotting/_docs.py +++ b/stlearn/plotting/_docs.py @@ -6,7 +6,8 @@ figsize Figure size with the format (width,height). cmap - Color map to use for continous variables or discretes variables (e.g. viridis, Set1,...). + Color map to use for continous variables or discretes variables (e.g. viridis, + Set1,...). use_label Key for the label use in `adata.obs` (e.g. `leiden`, `louvain`,...). list_clusters @@ -39,7 +40,8 @@ doc_gene_plot = """\ gene_symbols - Single gene (str) or multiple genes (list) that user wants to display. It should be available in `adata.var_names`. + Single gene (str) or multiple genes (list) that user wants to display. It should + be available in `adata.var_names`. threshold Threshold to display genes in the figure. method @@ -83,23 +85,28 @@ sig_spots Whether to filter to significant spots or not. use_label - Label to use for the inner points, can be in adata.obs or in the lr stats of adata.uns['per_lr_results'][lr].columns + Label to use for the inner points, can be in adata.obs or in the lr stats of + adata.uns['per_lr_results'][lr].columns use_mix - The deconvolution/label_transfer results to use for visualising pie charts in the inner point, not currently implimented. + The deconvolution/label_transfer results to use for visualising pie charts in + the inner point, not currently implimented. outer_mode - Either 'binary', 'continuous', or None; controls how ligand-receptor expression shown (or not shown). + Either 'binary', 'continuous', or None; controls how ligand-receptor expression + shown (or not shown). l_cmap matplotlib cmap controlling ligand continous expression. r_cmap matplotlib cmap controlling receptor continuous expression. lr_cmap - matplotlib cmap controlling the ligand receptor binary expression, but have atleast 4 colours. + matplotlib cmap controlling the ligand receptor binary expression, but have + at least 4 colours. inner_cmap matplotlib cmap controlling the inner point colours. inner_size_prop multiplier which controls size of inner points. middle_size_prop - Multiplier which controls size of middle point (only relevant when outer_mode='continuous') + Multiplier which controls size of middle point (only relevant when + outer_mode='continuous') outer_size_prop Multiplier which controls size of the outter point. pt_scale @@ -109,12 +116,14 @@ show_image Whether to show the background H&E or not. kwargs - Extra arguments parsed to the other plotting functions such as gene_plot, cluster_plot, &/or het_plot. + Extra arguments parsed to the other plotting functions such as gene_plot, + cluster_plot, &/or het_plot. """ doc_het_plot = """\ use_het - Single gene (str) or multiple genes (list) that user wants to display. It should be available in `adata.var_names`. + Single gene (str) or multiple genes (list) that user wants to display. It should + be available in `adata.var_names`. contour Option to show the contour plot. step_size diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 06d997f8..a2816a37 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -1,75 +1,69 @@ -from matplotlib import pyplot as plt -from matplotlib.axes import Axes -from matplotlib.figure import Figure -import matplotlib -import pandas as pd -import numpy as np -import networkx as nx +import importlib import math -import matplotlib.patches as patches -from numba.typed import List -import seaborn as sns import sys -from anndata import AnnData -from typing import Optional, Union - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes +from typing import ( + Optional, # Special + ) -import warnings +import matplotlib +import matplotlib.patches as patches +import networkx as nx +import numpy as np +import pandas as pd +from anndata import AnnData +from bokeh.io import output_notebook +from bokeh.plotting import show +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from scipy.stats import gaussian_kde -from .classes import CciPlot, LrResultPlot -from .classes_bokeh import BokehSpatialCciPlot, BokehLRPlot -from ._docs import doc_spatial_base_plot, doc_het_plot, doc_lr_plot -from ..utils import Empty, _empty, _AxesSubplot, _docs_params -from .utils import get_cmap, check_cmap, get_colors -from .cluster_plot import cluster_plot -from .deconvolution_plot import deconvolution_plot -from .gene_plot import gene_plot -from stlearn.plotting.utils import get_colors import stlearn.plotting.cci_plot_helpers as cci_hs +from stlearn.plotting.utils import get_colors + +from ..utils import _docs_params +from ._docs import doc_het_plot, doc_spatial_base_plot from .cci_plot_helpers import ( - get_int_df, - add_arrows, - create_flat_df, _box_map, chordDiagram, + create_flat_df, + get_int_df, ) -from scipy.stats import gaussian_kde - -import importlib +from .classes import CciPlot, LrResultPlot +from .classes_bokeh import BokehLRPlot, BokehSpatialCciPlot +from .cluster_plot import cluster_plot +from .gene_plot import gene_plot +from .utils import check_cmap, get_cmap importlib.reload(cci_hs) -from bokeh.io import push_notebook, output_notebook -from bokeh.plotting import show #### Functions for visualising the overall LR results and diagnostics. def lr_diagnostics( - adata, - highlight_lrs: list = None, - n_top: int = None, - color0: str = "turquoise", - color1: str = "plum", - figsize: tuple = (10, 4), - lr_text_fp: dict = None, - show: bool = True, + adata, + highlight_lrs: list = None, + n_top: int = None, + color0: str = "turquoise", + color1: str = "plum", + figsize: tuple = (10, 4), + lr_text_fp: dict = None, + show: bool = True, ): - """Diagnostic plot looking at relationship between technical features of lrs and lr rank. - Two plots generated: left is the average of the median for nonzero - expressing spots for both the ligand and the receptor on the y-axis, & - LR-rank by no. of significant spots on the x-axis. Right is the average - of the proportion of zeros for the ligand and receptor gene on teh y-axis. + """Diagnostic plot looking at relationship between technical features of lrs and + lr rank. Two plots generated: left is the average of the median for nonzero + expressing spots for both the ligand and the receptor on the y-axis, & + LR-rank by no. of significant spots on the x-axis. Right is the average + of the proportion of zeros for the ligand and receptor gene on teh y-axis. Parameters ---------- adata: AnnData The data object on which st.tl.cci.run has been applied. highlight_lrs: list - List of LRs to highlight, will add text and change point color for these LR pairs. + List of LRs to highlight, will add text and change point color for these + LR pairs. n_top: int The number of LRs to display. If None shows all. color0: str @@ -83,7 +77,7 @@ def lr_diagnostics( Figure, Axes Figure and axes of the plot, if show=False. """ - if type(n_top) == type(None): + if n_top is None: n_top = adata.uns["lr_summary"].shape[0] fig, axes = plt.subplots(ncols=2, figsize=figsize) cci_hs.lr_scatter( @@ -113,17 +107,17 @@ def lr_diagnostics( def lr_summary( - adata, - n_top: int = 50, - highlight_lrs: list = None, - y: str = "n_spots_sig", - color: str = "gold", - figsize: tuple = None, - highlight_color: str = "red", - max_text: int = 50, - lr_text_fp: dict = None, - ax: Axes = None, - show: bool = True, + adata, + n_top: int = 50, + highlight_lrs: list = None, + y: str = "n_spots_sig", + color: str = "gold", + figsize: tuple = None, + highlight_color: str = "red", + max_text: int = 50, + lr_text_fp: dict = None, + ax: Axes = None, + show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -181,17 +175,17 @@ def lr_summary( def lr_n_spots( - adata, - n_top: int = 100, - font_dict: dict = None, - xtick_dict: dict = None, - bar_width: float = 1, - max_text: int = 50, - non_sig_color: str = "dodgerblue", - sig_color: str = "springgreen", - figsize: tuple = (6, 4), - show_title: bool = True, - show: bool = True, + adata, + n_top: int = 100, + font_dict: dict = None, + xtick_dict: dict = None, + bar_width: float = 1, + max_text: int = 50, + non_sig_color: str = "dodgerblue", + sig_color: str = "springgreen", + figsize: tuple = (6, 4), + show_title: bool = True, + show: bool = True, ): """Bar plot showing for each LR no. of sig versus non-sig spots. @@ -227,9 +221,9 @@ def lr_n_spots( Fig, Axes Figure & axes with the plot draw on; only if show=False. Else None. """ - if type(font_dict) == type(None): + if font_dict is None: font_dict = {"weight": "bold", "size": 12} - if type(xtick_dict) == type(None): + if xtick_dict is None: xtick_dict = {"fontweight": "bold", "rotation": 90, "size": 6} lrs = adata.uns["lr_summary"].index.values[0:n_top] @@ -263,15 +257,15 @@ def lr_n_spots( def lr_go( - adata, - n_top: int = 20, - highlight_go: list = None, - figsize=(6, 4), - rot: float = 50, - lr_text_fp: dict = None, - highlight_color: str = "yellow", - max_text: int = 50, - show: bool = True, + adata, + n_top: int = 20, + highlight_go: list = None, + figsize=(6, 4), + rot: float = 50, + lr_text_fp: dict = None, + highlight_color: str = "yellow", + max_text: int = 50, + show: bool = True, ): """Plots the results from the LR GO analysis. @@ -327,15 +321,16 @@ def lr_go( def cci_check( - adata: AnnData, - use_label: str, - figsize=(16, 10), - cell_label_size=20, - axis_text_size=18, - tick_size=14, - show=True, + adata: AnnData, + use_label: str, + figsize=(16, 10), + cell_label_size=20, + axis_text_size=18, + tick_size=14, + show=True, ): - """Checks relationship between no. of significant CCI-LR interactions and cell type frequency. + """Checks relationship between no. of significant CCI-LR interactions and cell + type frequency. Parameters ---------- @@ -427,32 +422,32 @@ def cci_check( # Functions for visualisation the LR results per spot. def lr_result_plot( - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[float] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: float | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, ): """Plots the per spot statistics for given LR. @@ -543,36 +538,36 @@ def lr_result_plot( # @_docs_params(het_plot=doc_lr_plot) def lr_plot( - adata: AnnData, - lr: str, - min_expr: float = 0, - sig_spots=True, - use_label: str = None, - outer_mode: str = "continuous", - l_cmap=None, - r_cmap=None, - lr_cmap=None, - inner_cmap=None, - inner_size_prop: float = 0.25, - middle_size_prop: float = 0.5, - outer_size_prop: float = 1, - pt_scale: int = 100, - title="", - show_image: bool = True, - show_arrows: bool = False, - fig: Figure = None, - ax: Axes = None, - arrow_head_width: float = 4, - arrow_width: float = 0.001, - arrow_cmap: str = None, - arrow_vmax: float = None, - sig_cci: bool = False, - lr_colors: dict = None, - figsize: tuple = (6.4, 4.8), - use_mix: bool = None, - # plotting params - **kwargs, -) -> Optional[AnnData]: + adata: AnnData, + lr: str, + min_expr: float = 0, + sig_spots=True, + use_label: str = None, + outer_mode: str = "continuous", + l_cmap=None, + r_cmap=None, + lr_cmap=None, + inner_cmap=None, + inner_size_prop: float = 0.25, + middle_size_prop: float = 0.5, + outer_size_prop: float = 1, + pt_scale: int = 100, + title="", + show_image: bool = True, + show_arrows: bool = False, + fig: Figure = None, + ax: Axes = None, + arrow_head_width: float = 4, + arrow_width: float = 0.001, + arrow_cmap: str = None, + arrow_vmax: float = None, + sig_cci: bool = False, + lr_colors: dict = None, + figsize: tuple = (6.4, 4.8), + use_mix: bool = None, + # plotting params + **kwargs, +) -> AnnData | None: """Creates different kinds of spatial visualisations for the LR analysis results. To see combinations of parameters refer to stLearn CCI tutorial. @@ -643,7 +638,7 @@ def lr_plot( interactions; particularly relevant when plotting the arrows. lr_colors: dict Specifies the colors of the LRs when plotting with outer_mode='binary'; - structures is {'l': color, 'r': color, 'lr': color, '': color}; + structures is {'ligand': color, 'receptor': color, 'lr': color, '': color}; the last key-value indicates colour for spots not expressing the ligand or receptor. figsize: tuple @@ -653,7 +648,7 @@ def lr_plot( """ # Input checking # - l, r = lr.split("_") + ligand, receptor = lr.split("_") ran_lr = "lr_summary" in adata.uns ran_sig = False if not ran_lr else "n_spots_sig" in adata.uns["lr_summary"].columns if ran_lr and lr in adata.uns["lr_summary"].index: @@ -677,10 +672,10 @@ def lr_plot( # Making sure have run_cci first with respective labelling # if ( - show_arrows - and sig_cci - and use_label - and f"per_lr_cci_{use_label}" not in adata.uns + show_arrows + and sig_cci + and use_label + and f"per_lr_cci_{use_label}" not in adata.uns ): raise Exception( "Cannot subset arrow interactions to significant ccis " @@ -700,25 +695,25 @@ def lr_plot( "lr_sig_scores", ] - if type(use_mix) != type(None) and use_mix not in adata.uns: + if use_mix is not None and use_mix not in adata.uns: raise Exception( - f"Specified use_mix, but no deconvolution results added " + "Specified use_mix, but no deconvolution results added " "to adata.uns matching the use_mix ({use_mix}) key." ) elif ( - type(use_label) != type(None) - and use_label in lr_use_labels - and ran_sig - and not lr_sig + use_label is not None + and use_label in lr_use_labels + and ran_sig + and not lr_sig ): raise Exception( - f"Since use_label refers to lr stats & ran permutation testing, " - f"LR needs to be significant to view stats." + "Since use_label refers to lr stats & ran permutation testing, " + "LR needs to be significant to view stats." ) elif ( - type(use_label) != type(None) - and use_label not in adata.obs.keys() - and use_label not in lr_use_labels + use_label is not None + and use_label not in adata.obs.keys() + and use_label not in lr_use_labels ): raise Exception( f"use_label must be in adata.obs or " f"one of lr stats: {lr_use_labels}." @@ -728,7 +723,7 @@ def lr_plot( if outer_mode not in out_options: raise Exception(f"{outer_mode} should be one of {out_options}") - if l not in adata.var_names or r not in adata.var_names: + if ligand not in adata.var_names or receptor not in adata.var_names: raise Exception("L or R not found in adata.var_names.") # Whether to show just the significant spots or all spots @@ -741,21 +736,21 @@ def lr_plot( adata_full = adata # Dealing with the axis # - if type(fig) == type(None) or type(ax) == type(None): + if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) expr = adata.to_df() - l_expr = expr.loc[:, l].values - r_expr = expr.loc[:, r].values + l_expr = expr.loc[:, ligand].values + r_expr = expr.loc[:, receptor].values # Adding binary points of the ligand/receptor pair # if outer_mode == "binary": l_bool, r_bool = l_expr > min_expr, r_expr > min_expr lr_binary_labels = [] for i in range(len(l_bool)): if l_bool[i] and not r_bool[i]: - lr_binary_labels.append(l) + lr_binary_labels.append(ligand) elif not l_bool[i] and r_bool[i]: - lr_binary_labels.append(r) + lr_binary_labels.append(receptor) elif l_bool[i] and r_bool[i]: lr_binary_labels.append(lr) elif not l_bool[i] and not r_bool[i]: @@ -765,12 +760,12 @@ def lr_plot( ).astype("category") adata.obs[f"{lr}_binary_labels"] = lr_binary_labels - if type(lr_cmap) == type(None): + if lr_cmap is None: lr_cmap = "default" # This gets ignored due to setting colours below - if type(lr_colors) == type(None): + if lr_colors is None: lr_colors = { - l: matplotlib.colors.to_hex("r"), - r: matplotlib.colors.to_hex("limegreen"), + ligand: matplotlib.colors.to_hex("receptor"), + receptor: matplotlib.colors.to_hex("limegreen"), lr: matplotlib.colors.to_hex("b"), "": "#836BC6", # Neutral color in H&E images. } @@ -797,13 +792,13 @@ def lr_plot( # Showing continuous gene expression of the LR pair # elif outer_mode == "continuous": - if type(l_cmap) == type(None): + if l_cmap is None: l_cmap = matplotlib.colors.LinearSegmentedColormap.from_list( "lcmap", [(0, 0, 0), (0.5, 0, 0), (0.75, 0, 0), (1, 0, 0)] ) else: l_cmap = check_cmap(l_cmap) - if type(r_cmap) == type(None): + if r_cmap is None: r_cmap = matplotlib.colors.LinearSegmentedColormap.from_list( "rcmap", [(0, 0, 0), (0, 0.5, 0), (0, 0.75, 0), (0, 1, 0)] ) @@ -812,10 +807,10 @@ def lr_plot( gene_plot( adata, - gene_symbols=l, + gene_symbols=ligand, size=outer_size_prop * pt_scale, cmap=l_cmap, - color_bar_label=l, + color_bar_label=ligand, ax=ax, fig=fig, crop=False, @@ -824,10 +819,10 @@ def lr_plot( ) gene_plot( adata, - gene_symbols=r, + gene_symbols=receptor, size=middle_size_prop * pt_scale, cmap=r_cmap, - color_bar_label=r, + color_bar_label=receptor, ax=ax, fig=fig, crop=False, @@ -836,11 +831,9 @@ def lr_plot( ) # Adding the cell type labels # - if type(use_label) != type(None): + if use_label is not None: if use_label in lr_use_labels: - inner_cmap = inner_cmap if type(inner_cmap) != type(None) else "copper" - # adata.obsm[f'{lr}_{use_label}'] = adata.uns['per_lr_results'][ - # lr].loc[adata.obs_names,use_label].values + inner_cmap = inner_cmap if inner_cmap is not None else "copper" lr_result_plot( adata, use_lr=lr, @@ -853,7 +846,7 @@ def lr_plot( **kwargs, ) else: - inner_cmap = inner_cmap if type(inner_cmap) != type(None) else "default" + inner_cmap = inner_cmap if inner_cmap is not None else "default" cluster_plot( adata, use_label=use_label, @@ -870,8 +863,8 @@ def lr_plot( # Adding in labels which show the interactions between signicant spots & # neighbours if show_arrows: - l_expr = adata_full[:, l].X.toarray()[:, 0] - r_expr = adata_full[:, r].X.toarray()[:, 0] + l_expr = adata_full[:, ligand].X.toarray()[:, 0] + r_expr = adata_full[:, receptor].X.toarray()[:, 0] if sig_cci: int_df = adata.uns[f"per_lr_cci_{use_label}"][lr] @@ -912,35 +905,35 @@ def lr_plot( #### from old data structure when only test individual LRs. @_docs_params(spatial_base_plot=doc_spatial_base_plot, het_plot=doc_het_plot) def het_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # cci_rank param - use_het: Optional[str] = "het", - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, -) -> Optional[AnnData]: + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + use_het: str | None = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, +) -> AnnData | None: """\ Allows the visualization of significant cell-cell interaction as the values of dot points or contour in the Spatial @@ -996,22 +989,22 @@ def het_plot( def ccinet_plot( - adata: AnnData, - use_label: str, - lr: str = None, - pos: dict = None, - return_pos: bool = False, - cmap: str = "default", - font_size: int = 12, - node_size_exp: int = 1, - node_size_scaler: int = 1, - min_counts: int = 0, - sig_interactions: bool = True, - fig: matplotlib.figure.Figure = None, - ax: matplotlib.axes.Axes = None, - pad=0.25, - title: str = None, - figsize: tuple = (10, 10), + adata: AnnData, + use_label: str, + lr: str = None, + pos: dict = None, + return_pos: bool = False, + cmap: str = "default", + font_size: int = 12, + node_size_exp: int = 1, + node_size_scaler: int = 1, + min_counts: int = 0, + sig_interactions: bool = True, + fig: matplotlib.figure.Figure = None, + ax: matplotlib.axes.Axes = None, + pad=0.25, + title: str = None, + figsize: tuple = (10, 10), ): """Circular celltype-celltype interaction network based on LR-CCI analysis. The size of the nodes drawn for each cell type indicates the total no. of @@ -1052,7 +1045,8 @@ def ccinet_plot( Returns ------- pos: dict - Dictionary of positions where the nodes are draw if return_pos is True, useful for consistent layouts. + Dictionary of positions where the nodes are draw if return_pos is True, + useful for consistent layouts. """ cmap, cmap_n = get_cmap(cmap) # Making sure adata in correct state that this function should run # @@ -1061,7 +1055,7 @@ def ccinet_plot( "Need to first call st.tl.run_cci with the equivalnt " "use_label to visualise cell-cell interactions." ) - elif type(lr) != type(None) and lr not in adata.uns[f"per_lr_cci_{use_label}"]: + elif lr is not None and lr not in adata.uns[f"per_lr_cci_{use_label}"]: raise Exception( f"{lr} not found in {f'per_lr_cci_{use_label}'}, " "suggesting no significant interactions." @@ -1084,7 +1078,7 @@ def ccinet_plot( graph.add_edge(cell_A, cell_B, weight=count) # Determining graph layout, node sizes, & edge colours # - if type(pos) == type(None): + if pos is None: pos = nx.circular_layout(graph) # position the nodes using the layout total = sum(sum(int_matrix)) node_names = list(graph.nodes.keys()) @@ -1092,9 +1086,10 @@ def ccinet_plot( node_sizes = np.array( [ ( - ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[i, i]) / total) - * 10000 - * node_size_scaler + ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[ + i, i]) / total) + * 10000 + * node_size_scaler ) ** (node_size_exp) for i in node_indices @@ -1108,8 +1103,8 @@ def ccinet_plot( trans_i = np.where(all_set == edge[0][0])[0][0] receive_i = np.where(all_set == edge[0][1])[0][0] e_total = ( - sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) - - int_matrix[trans_i, receive_i] + sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) + - int_matrix[trans_i, receive_i] ) # so don't double count e_totals.append(e_total) edge_weights = [edge[1]["weight"] / e_totals[i] for i, edge in enumerate(edges)] @@ -1122,7 +1117,7 @@ def ccinet_plot( node_colors = np.array(node_colors)[nodes_indices] #### Drawing the graph ##### - if type(fig) == type(None) or type(ax) == type(None): + if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize, facecolor=[0.7, 0.7, 0.7, 0.4]) # Adding in the self-loops # @@ -1176,15 +1171,15 @@ def ccinet_plot( def cci_map( - adata: AnnData, - use_label: str, - lr: str = None, - ax: matplotlib.figure.Axes = None, - show: bool = False, - figsize: tuple = None, - cmap: str = "Spectral_r", - sig_interactions: bool = True, - title=None, + adata: AnnData, + use_label: str, + lr: str = None, + ax: matplotlib.figure.Axes = None, + show: bool = False, + figsize: tuple = None, + cmap: str = "Spectral_r", + sig_interactions: bool = True, + title=None, ): """Heatmap visualising sender->receivers of cell type interactions. @@ -1222,7 +1217,7 @@ def cci_map( # Either plotting overall interactions, or just for a particular LR # int_df, title = get_int_df(adata, lr, use_label, sig_interactions, title) - if type(figsize) == type(None): # Adjust size depending on no. cell types + if figsize is None: # Adjust size depending on no. cell types add = np.array([int_df.shape[0] * 0.1, int_df.shape[0] * 0.05]) figsize = tuple(np.array([6.4, 4.8]) + add) @@ -1255,18 +1250,18 @@ def cci_map( def lr_cci_map( - adata: AnnData, - use_label: str, - lrs: list or np.array = None, - n_top_lrs: int = 5, - n_top_ccis: int = 15, - min_total: int = 0, - ax: matplotlib.figure.Axes = None, - figsize: tuple = (6.48, 4.8), - show: bool = False, - cmap: str = "Spectral_r", - square_scaler: int = 700, - sig_interactions: bool = True, + adata: AnnData, + use_label: str, + lrs: list or np.array = None, + n_top_lrs: int = 5, + n_top_ccis: int = 15, + min_total: int = 0, + ax: matplotlib.figure.Axes = None, + figsize: tuple = (6.48, 4.8), + show: bool = False, + cmap: str = "Spectral_r", + square_scaler: int = 700, + sig_interactions: bool = True, ): """Heatmap of interaction counts. Rows are lrs and columns are celltype->celltype interactions. @@ -1310,7 +1305,7 @@ def lr_cci_map( else: lr_int_dfs = adata.uns[f"per_lr_cci_raw_{use_label}"] - if type(lrs) == type(None): + if lrs is None: lrs = np.array(list(lr_int_dfs.keys())) else: lrs = np.array(lrs) @@ -1373,18 +1368,18 @@ def lr_cci_map( def lr_chord_plot( - adata: AnnData, - use_label: str, - lr: str = None, - min_ints: int = 2, - n_top_ccis: int = 10, - cmap: str = "default", - sig_interactions: bool = True, - label_size: int = 10, - label_rotation: float = 0, - title: str = None, - figsize: tuple = (8, 8), - show: bool = True, + adata: AnnData, + use_label: str, + lr: str = None, + min_ints: int = 2, + n_top_ccis: int = 10, + cmap: str = "default", + sig_interactions: bool = True, + label_size: int = 10, + label_rotation: float = 0, + title: str = None, + figsize: tuple = (8, 8), + show: bool = True, ): """Chord diagram of interactions between cell types. Note that interaction is measured as the total no. of edges connecting @@ -1396,8 +1391,8 @@ def lr_chord_plot( Each cell type has a labelled edge taking up a proportion of the outter circle. Chords connecting cell type edges are coloured by the dominant sending cell. Each chord linking cell types has an assymetric shape. - For two cell types, A and B, the side of the chord attached to edge A is sized by - the total interactions from B->A, where B is expressing the ligand & A + For two cell types, A and B, the side of the chord attached to edge A is + sized by the total interactions from B->A, where B is expressing the ligand & A is expressing the receptor. Hence, the proportion of a cell type's edge in the chordplot circle represents the total input signals to that cell type; while the @@ -1419,7 +1414,8 @@ def lr_chord_plot( n_top_ccis: int Maximum no. of CCIs to show, will take the top number of these to display. cmap: str - Cmap to use to get colors if colors not already in adata.uns[f'{use_label}_colors'] + Cmap to use to get colors if colors not already in + adata.uns[f'{use_label}_colors'] sig_interactions: bool Whether to show only significant CCIs or all interaction counts. label_size: str @@ -1455,7 +1451,7 @@ def lr_chord_plot( all_zero = np.array( [np.all(np.logical_and(flux[i, keep] == 0, flux[keep, i] == 0)) for i in keep] ) - keep = keep[all_zero == False] + keep = keep[not all_zero] if len(keep) == 0: # If we don't keep anything, warn the user print( f"Warning: for {lr} at the current min_ints ({min_ints}), there " @@ -1491,7 +1487,7 @@ def lr_chord_plot( rotation = nodePos[i][2] # Prevent text going upside down at certain rotations if (rotation < 90 and rotation > 18 and label_rotation != 0) or ( - rotation < 120 and rotation > 90 + rotation < 120 and rotation > 90 ): label_rotation_ = -label_rotation else: @@ -1507,15 +1503,16 @@ def lr_chord_plot( def grid_plot( - adata, - use_label: str = None, - n_row: int = 10, - n_col: int = 10, - size: int = 1, - figsize=(4.5, 4.5), - show: bool = False, + adata, + use_label: str = None, + n_row: int = 10, + n_col: int = 10, + size: int = 1, + figsize=(4.5, 4.5), + show: bool = False, ): - """Plots grid over the top of spatial data to show how cells will be grouped if gridded. + """Plots grid over the top of spatial data to show how cells will be grouped if + gridded. Parameters ---------- @@ -1544,7 +1541,7 @@ def grid_plot( fig, ax = plt.subplots(figsize=figsize) # Plotting the points # - if type(use_label) != type(None): + if use_label is not None: if f"{use_label}_colors" in adata.uns: color_map = {} for i, ct in enumerate(adata.obs[use_label].cat.categories): @@ -1590,7 +1587,6 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) - # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 6753e44b..1b7b456c 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,46 +1,42 @@ """Helper functions for cci_plot.py.""" -import sys -import math -import numpy as np -import pandas as pd import matplotlib +import matplotlib.cm as cm +import matplotlib.colors as plt_colors +import matplotlib.patches as patches import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from anndata import AnnData from matplotlib.axes import Axes from matplotlib.patches import Arc, Wedge -from mpl_toolkits.axes_grid1 import make_axes_locatable - from matplotlib.path import Path -import matplotlib.patches as patches -import matplotlib.colors as plt_colors -import matplotlib.cm as cm +from mpl_toolkits.axes_grid1 import make_axes_locatable from ..tools.microenv.cci.het import get_edges -from anndata import AnnData - # Helper functions for overview plots of the LRs. def lr_scatter( - data, - feature, - highlight_lrs=None, - show_text=True, - n_top=50, - color="gold", - alpha=0.5, - lr_text_fp=None, - axis_text_fp=None, - ax=None, - show=True, - max_text=100, - highlight_color="red", - figsize: tuple = None, - show_all: bool = False, + data, + feature, + highlight_lrs=None, + show_text=True, + n_top=50, + color="gold", + alpha=0.5, + lr_text_fp=None, + axis_text_fp=None, + ax=None, + show=True, + max_text=100, + highlight_color="red", + figsize: tuple = None, + show_all: bool = False, ): """General plotting of the LR features.""" - highlight = type(highlight_lrs) != type(None) + highlight = highlight_lrs is not None if not highlight: show_text = show_text if n_top <= max_text else False else: @@ -112,40 +108,40 @@ def lr_scatter( def rank_scatter( - items, - y, - y_label: str = "", - x_label: str = "", - highlight_items=None, - show_text=True, - color="gold", - alpha=0.5, - lr_text_fp=None, - axis_text_fp=None, - ax=None, - show=True, - highlight_color="red", - rot: float = 90, - point_sizes: np.array = None, - pad=0.2, - figsize=None, - width_ratio=7.5 / 50, - height=4, - point_size_name="Sizes", - point_size_exp=2, - show_all: bool = False, + items, + y, + y_label: str = "", + x_label: str = "", + highlight_items=None, + show_text=True, + color="gold", + alpha=0.5, + lr_text_fp=None, + axis_text_fp=None, + ax=None, + show=True, + highlight_color="red", + rot: float = 90, + point_sizes: np.array = None, + pad=0.2, + figsize=None, + width_ratio=7.5 / 50, + height=4, + point_size_name="Sizes", + point_size_exp=2, + show_all: bool = False, ): """General plotting function for showing ranked list of items.""" ranks = np.array(list(range(len(items)))) - highlight = type(highlight_items) != type(None) - if type(lr_text_fp) == type(None): + highlight = highlight_items is not None + if lr_text_fp is None: lr_text_fp = {"weight": "bold", "size": 8} - if type(axis_text_fp) == type(None): + if axis_text_fp is None: axis_text_fp = {"weight": "bold", "size": 12} - if type(ax) == type(None): - if type(figsize) == type(None): + if ax is None: + if figsize is None: width = width_ratio * len(ranks) if show_text and not highlight else 7.5 if width > 20: width = 20 @@ -158,28 +154,28 @@ def rank_scatter( y, alpha=alpha, c=color, - s=None if type(point_sizes) == type(None) else point_sizes**point_size_exp, + s=None if point_sizes is None else point_sizes ** point_size_exp, edgecolors="none", ) y_min, y_max = ax.get_ylim() y_max = y_max + y_max * pad ax.set_ylim(y_min, y_max) - if type(point_sizes) != type(None): + if point_sizes is not None: # produce a legend with a cross section of sizes from the scatter handles, labels = scatter.legend_elements(prop="sizes", alpha=0.6, num=4) [handle.set_markeredgecolor("none") for handle in handles] starts = [label.find("{") for label in labels] ends = [label.find("}") + 1 for label in labels] sizes = [ - float(label[(starts[i] + 1) : (ends[i] - 1)]) + float(label[(starts[i] + 1): (ends[i] - 1)]) for i, label in enumerate(labels) ] counts = [int(size ** (1 / point_size_exp)) for size in sizes] labels2 = [ - label.replace(label[(starts[i]) : (ends[i])], "{" + str(counts[i]) + "}") + label.replace(label[(starts[i]): (ends[i])], "{" + str(counts[i]) + "}") for i, label in enumerate(labels) ] - legend2 = ax.legend( + ax.legend( handles, labels2, frameon=False, @@ -199,7 +195,7 @@ def rank_scatter( c=highlight_color, s=( None - if type(point_sizes) == type(None) + if point_sizes is None else (point_sizes[ranks_] ** point_size_exp) ), edgecolors=color, @@ -224,19 +220,19 @@ def rank_scatter( def add_arrows( - adata: AnnData, - l_expr: np.array, - r_expr: np.array, - min_expr: float, - sig_bool: np.array, - fig, - ax: Axes, - use_label: str, - int_df: pd.DataFrame, - head_width=4, - width=0.001, - arrow_cmap=None, - arrow_vmax=None, + adata: AnnData, + l_expr: np.array, + r_expr: np.array, + min_expr: float, + sig_bool: np.array, + fig, + ax: Axes, + use_label: str, + int_df: pd.DataFrame, + head_width=4, + width=0.001, + arrow_cmap=None, + arrow_vmax=None, ): """ Adds arrows to the current plot for significant spots to neighbours \ which is interacting with. @@ -270,7 +266,7 @@ def add_arrows( forward_edges, reverse_edges = get_edges(adata, L_bool, R_bool, sig_bool) # If int_df specified, means need to subset to CCIs which are significant # - if type(int_df) != type(None): + if int_df is not None: spot_bcs = adata.obs_names.values.astype(str) spot_labels = adata.obs[use_label].values.astype(str) label_set = int_df.index.values.astype(str) @@ -302,7 +298,7 @@ def add_arrows( forward_edges, reverse_edges = edges_sub # If cmap specified, colour arrows by average LR expression on edge # - if type(arrow_cmap) != type(None): + if arrow_cmap is not None: edges_means = [[], []] all_means = [] for i, edges in enumerate([forward_edges, reverse_edges]): @@ -318,7 +314,7 @@ def add_arrows( all_means.append(mean_expr) # Determining the color maps # - arrow_vmax = np.max(all_means) if type(arrow_vmax) == type(None) else arrow_vmax + arrow_vmax = np.max(all_means) if arrow_vmax is None else arrow_vmax cmap = plt.get_cmap(arrow_cmap) c_norm = plt_colors.Normalize(vmin=0, vmax=arrow_vmax) scalar_map = cm.ScalarMappable(norm=c_norm, cmap=cmap) @@ -365,22 +361,22 @@ def add_arrows( edge_colors=edges_colors[1], ) # Adding the color map # - if type(arrow_cmap) != type(None): - cb1 = matplotlib.colorbar.ColorbarBase( + if arrow_cmap is not None: + matplotlib.colorbar.ColorbarBase( axc, cmap=cmap, norm=c_norm, orientation="horizontal" ) def add_arrows_by_edges( - ax, - adata, - edges, - scale_factor, - head_width, - width, - forward=True, - edge_colors=None, - axc=None, + ax, + adata, + edges, + scale_factor, + head_width, + width, + forward=True, + edge_colors=None, + axc=None, ): """Adds the arrows using an edge list.""" for i, edge in enumerate(edges): @@ -398,7 +394,7 @@ def add_arrows_by_edges( x1, y1 = adata.obsm["spatial"][edge0_index, :] * scale_factor x2, y2 = adata.obsm["spatial"][edge1_index, :] * scale_factor dx, dy = (x2 - x1) * 0.75, (y2 - y1) * 0.75 - arrow_color = "k" if type(edge_colors) == type(None) else edge_colors[i] + arrow_color = "k" if edge_colors is None else edge_colors[i] ax.arrow( x1, @@ -417,9 +413,9 @@ def add_arrows_by_edges( def get_int_df(adata, lr, use_label, sig_interactions, title): """Retrieves the relevant interaction count matrix.""" - no_title = type(title) == type(None) + no_title = title is None labels_ordered = adata.obs[use_label].cat.categories - if type(lr) == type(None): # No LR inputted, so just use all + if lr is None: # No LR inputted, so just use all int_df = ( adata.uns[f"lr_cci_{use_label}"] if sig_interactions @@ -455,10 +451,9 @@ def create_flat_df(int_df): def _box_map(x, y, size, ax=None, figsize=(6.48, 4.8), cmap=None, square_scaler=700): """Main underlying helper function for generating the heatmaps.""" - if type(cmap) == type(None): + if cmap is None: cmap = "Spectral_r" - - if type(ax) == type(None): + if ax is None: fig, ax = plt.subplots(figsize=figsize) # Mapping from column names to integer coordinates @@ -504,11 +499,11 @@ def polar2xy(r, theta): def hex2rgb(c): - return tuple(int(c[i : i + 2], 16) / 256.0 for i in (1, 3, 5)) + return tuple(int(c[i: i + 2], 16) / 256.0 for i in (1, 3, 5)) def IdeogramArc( - start=0, end=60, radius=1.0, width=0.2, ax=None, color=(1, 0, 0), curve_steps=1 + start=0, end=60, radius=1.0, width=0.2, ax=None, color=(1, 0, 0), curve_steps=1 ): # start, end should be in [0, 360) if start > end: @@ -548,25 +543,25 @@ def IdeogramArc( for i in range(1, curve_steps + 1) ] verts_inner = ( - verts_inner_start - + verts_inner_curve - + [polar2xy(inner, start), polar2xy(radius, start)] + verts_inner_start + + verts_inner_curve + + [polar2xy(inner, start), polar2xy(radius, start)] ) verts = verts_upper + verts_inner codes = ( - [Path.MOVETO] - + [Path.CURVE4] * curve_steps * 2 - + [Path.CURVE4, Path.LINETO] - + [Path.CURVE4] * curve_steps * 2 - + [ - Path.CURVE4, - Path.CLOSEPOLY, - ] + [Path.MOVETO] + + [Path.CURVE4] * curve_steps * 2 + + [Path.CURVE4, Path.LINETO] + + [Path.CURVE4] * curve_steps * 2 + + [ + Path.CURVE4, + Path.CLOSEPOLY, + ] ) - if ax == None: + if ax is None: return verts, codes else: path = Path(verts, codes) @@ -577,14 +572,14 @@ def IdeogramArc( def ChordArc( - start1=0, - end1=60, - start2=180, - end2=240, - radius=1.0, - chordwidth=0.7, - ax=None, - color=(1, 0, 0), + start1=0, + end1=60, + start2=180, + end2=240, + radius=1.0, + chordwidth=0.7, + ax=None, + color=(1, 0, 0), ): # start, end should be in [0, 360) if start1 > end1: @@ -630,7 +625,7 @@ def ChordArc( Path.CURVE4, ] - if ax == None: + if ax is None: return verts, codes else: path = Path(verts, codes) @@ -668,7 +663,7 @@ def selfChordArc(start=0, end=60, radius=1.0, chordwidth=0.7, ax=None, color=(1, Path.CURVE4, ] - if ax == None: + if ax is None: return verts, codes else: path = Path(verts, codes) @@ -687,13 +682,15 @@ def chordDiagram(X, ax, colors=None, width=0.1, pad=2, chordwidth=0.7, lim=1.1): ax : matplotlib `axes` to show the plot colors : optional - user defined colors in rgb format. Use function hex2rgb() to convert hex color to rgb color. Default: d3.js category10 + user defined colors in rgb format. Use function hex2rgb() to convert hex + color to rgb color. Default: d3.js category10 width : optional width/thickness of the ideogram arc pad : optional gap pad between two neighboring ideogram arcs, unit: degree, default: 2 degree chordwidth : optional - position of the control points for the chords, controlling the shape of the chords + position of the control points for the chords, controlling the shape + of the chords """ # X[i, j]: i -> j x = X.sum(axis=1) # sum over rows @@ -717,7 +714,7 @@ def chordDiagram(X, ax, colors=None, width=0.1, pad=2, chordwidth=0.7, lim=1.1): ] if len(x) > 10: print("x is too large! Use x smaller than 10") - if type(colors[0]) == str: + if colors[0] is str: colors = [hex2rgb(colors[i]) for i in range(len(x))] # find position for each start and end diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index e60c7a0e..a435cbf3 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -4,60 +4,52 @@ Date: 20 Feb 2021 """ -from lib2to3.pgen2.token import OP -from typing import Optional, Union, Mapping, List # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes - import numbers +import warnings +from typing import ( # Special + Optional, # Classes + ) + +import matplotlib +import matplotlib.pyplot as plt +import networkx as nx import numpy as np import pandas as pd from anndata import AnnData - -from matplotlib import rcParams, ticker, gridspec, axes -import matplotlib.pyplot as plt -import matplotlib from scipy.interpolate import griddata -import networkx as nx from ..classes import Spatial -from ..utils import _AxesSubplot, Axes, _read_graph -from .utils import centroidpython, get_cluster, get_node, check_sublist, get_cmap - -################################################################ -# # -# Spatial base plot class # -# # -################################################################ +from ..utils import Axes, _AxesSubplot, _read_graph +from .utils import centroidpython, check_sublist, get_cluster, get_cmap, get_node class SpatialBasePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - color_bar_label: Optional[str] = "", - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 0.7, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - **kwds, + self, + # plotting param + adata: AnnData, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + **kwds, ): super().__init__( adata, @@ -75,13 +67,13 @@ def __init__( if use_raw: self.query_adata = self.adata[0].raw.to_adata().copy() - if self.list_clusters != None: - assert use_label != None, "Please specify `use_label` parameter!" + if self.list_clusters is not None: + assert use_label is not None, "Please specify `use_label` parameter!" - if use_label != None: + if use_label is not None: assert ( - use_label in self.adata[0].obs.columns + use_label in self.adata[0].obs.columns ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label @@ -91,7 +83,7 @@ def __init__( self.adata[0].obs[use_label].cat.categories ) else: - if type(self.list_clusters) != list: + if self.list_clusters is not list: self.list_clusters = [self.list_clusters] clusters_indexes = [ @@ -111,21 +103,21 @@ def __init__( stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) - if type(cmap) == str: + if cmap is str: assert cmap in cmap_available, error_msg - elif type(cmap) != matplotlib.colors.LinearSegmentedColormap: + elif cmap is not matplotlib.colors.LinearSegmentedColormap: raise Exception(error_msg) self.cmap = cmap - if type(fig) == type(None) and type(ax) == type(None): + if fig is None and ax is None: self.fig, self.ax = self._generate_frame() else: self.fig, self.ax = fig, ax - if show_axis == False: + if not show_axis: self._remove_axis(self.ax) if show_image: @@ -147,7 +139,7 @@ def create_query(list_cl, use_label): if self.list_clusters is not None: # IF not all clusters specified, subset, otherwise just copy. if len(self.list_clusters) != len( - self.adata[0].obs[self.use_label].cat.categories + self.adata[0].obs[self.use_label].cat.categories ): self.query_adata = self.query_adata[ self.query_adata.obs.query( @@ -191,7 +183,7 @@ def _crop_image(self, main_ax: _AxesSubplot, margin: float): main_ax.set_ylim(main_ax.get_ylim()[::-1]) - def _zoom_image(self, main_ax: _AxesSubplot, zoom_coord: Optional[float]): + def _zoom_image(self, main_ax: _AxesSubplot, zoom_coord: float | None): main_ax.set_xlim(zoom_coord[0], zoom_coord[1]) main_ax.set_ylim(zoom_coord[3], zoom_coord[2]) @@ -231,44 +223,41 @@ def _save_output(self): # # ################################################################ -import warnings - - class GenePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - color_bar_label: Optional[str] = "", - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # gene plot param - gene_symbols: Union[str, list] = None, - threshold: Optional[float] = None, - method: str = "CumSum", - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # gene plot param + gene_symbols: str | list = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -302,9 +291,8 @@ def __init__( self.step_size = step_size - if self.title == None: - if type(gene_symbols) == str: - + if self.title is None: + if gene_symbols is str: self.title = str(gene_symbols) gene_symbols = [gene_symbols] else: @@ -328,7 +316,7 @@ def __init__( if show_color_bar: self._add_color_bar(plot, color_bar_label=color_bar_label) - if fname != None: + if fname is not None: self._save_output() def _get_gene_expression(self): @@ -380,7 +368,7 @@ def _get_gene_expression(self): def _plot_genes(self, gene_values: pd.Series): - if type(self.vmin) == type(None) and type(self.vmax) == type(None): + if self.vmin is None and self.vmax is None: vmin = min(gene_values) vmax = max(gene_values) else: @@ -398,7 +386,7 @@ def _plot_genes(self, gene_values: pd.Series): marker="o", vmin=vmin, vmax=vmax, - cmap=plt.get_cmap(self.cmap) if type(self.cmap) == str else self.cmap, + cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, c=gene_values, ) return plot @@ -417,7 +405,7 @@ def _plot_contour(self, gene_values: pd.Series): yi = np.linspace(y.min(), y.max(), 100) zi = griddata((x, y), z, (xi[None, :], yi[:, None]), method="linear") - if self.step_size == None: + if self.step_size is None: self.step_size = int(np.max(z) / 50) if self.step_size < 1: self.step_size = 1 @@ -428,13 +416,13 @@ def _plot_contour(self, gene_values: pd.Series): yi, zi, range(0, int(np.nanmax(zi)) + self.step_size, self.step_size), - cmap=plt.get_cmap(self.cmap) if type(self.cmap) == str else self.cmap, + cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, alpha=self.cell_alpha, ) return cs def _add_threshold(self, gene_values, threshold): - if threshold == None: + if threshold is None: return np.repeat(True, len(gene_values)) else: return gene_values > threshold @@ -449,38 +437,38 @@ def _add_threshold(self, gene_values, threshold): class FeaturePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - color_bar_label: Optional[str] = "", - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # gene plot param - feature: str = None, - threshold: Optional[float] = None, - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # gene plot param + feature: str = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -527,7 +515,7 @@ def __init__( if show_color_bar: self._add_color_bar(plot, color_bar_label=color_bar_label) - if fname != None: + if fname is not None: self._save_output() def _get_feature_values(self): @@ -537,7 +525,7 @@ def _get_feature_values(self): self.feature + " is not in data.obs, please try another feature" ) elif not isinstance( - self.query_adata.obs[self.feature].values[0], numbers.Number + self.query_adata.obs[self.feature].values[0], numbers.Number ): raise ValueError( self.feature @@ -550,7 +538,7 @@ def _get_feature_values(self): def _plot_feature(self, feature_values: pd.Series): - if type(self.vmin) == type(None) and type(self.vmax) == type(None): + if self.vmin is None and self.vmax is None: vmin = min(feature_values) vmax = max(feature_values) else: @@ -568,7 +556,7 @@ def _plot_feature(self, feature_values: pd.Series): marker="o", vmin=vmin, vmax=vmax, - cmap=plt.get_cmap(self.cmap) if type(self.cmap) == str else self.cmap, + cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, c=feature_values, ) return plot @@ -587,7 +575,7 @@ def _plot_contour(self, feature_values: pd.Series): yi = np.linspace(y.min(), y.max(), 100) zi = griddata((x, y), z, (xi[None, :], yi[:, None]), method="linear") - if self.step_size == None: + if self.step_size is None: self.step_size = int(np.max(z) / 50) if self.step_size < 1: self.step_size = 1 @@ -598,65 +586,59 @@ def _plot_contour(self, feature_values: pd.Series): yi, zi, range(0, int(np.nanmax(zi)) + self.step_size, self.step_size), - cmap=plt.get_cmap(self.cmap) if type(self.cmap) == str else self.cmap, + cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, alpha=self.cell_alpha, ) return cs def _add_threshold(self, feature_values, threshold): - if threshold == None: + if threshold is None: return np.repeat(True, len(feature_values)) else: return feature_values > threshold -################################################################ -# # -# Cluster plot class # -# # -################################################################ - - +# Cluster plot class class ClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "default", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 5, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # cluster plot param - show_subcluster: Optional[bool] = False, - show_cluster_labels: Optional[bool] = False, - show_trajectories: Optional[bool] = False, - reverse: Optional[bool] = False, - show_node: Optional[bool] = False, - threshold_spots: Optional[int] = 5, - text_box_size: Optional[float] = 5, - color_bar_size: Optional[float] = 10, - bbox_to_anchor: Optional[Tuple[float, float]] = (1, 1), - # trajectory - trajectory_node_size: Optional[int] = 10, - trajectory_alpha: Optional[float] = 1.0, - trajectory_width: Optional[float] = 2.5, - trajectory_edge_color: Optional[str] = "#f4efd3", - trajectory_arrowsize: Optional[int] = 17, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # cluster plot param + show_subcluster: bool | None = False, + show_cluster_labels: bool | None = False, + show_trajectories: bool | None = False, + reverse: bool | None = False, + show_node: bool | None = False, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + color_bar_size: float | None = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int | None = 10, + trajectory_alpha: float | None = 1.0, + trajectory_width: float | None = 2.5, + trajectory_edge_color: str | None = "#f4efd3", + trajectory_arrowsize: int | None = 17, ): super().__init__( adata=adata, @@ -703,7 +685,6 @@ def __init__( self._add_sub_clusters() if show_trajectories: - self.trajectory_node_size = trajectory_node_size self.trajectory_alpha = trajectory_alpha self.trajectory_width = trajectory_width @@ -712,7 +693,7 @@ def __init__( self._add_trajectories() - if fname != None: + if fname is not None: self._save_output() def _add_cluster_colors(self): @@ -729,7 +710,6 @@ def _add_cluster_colors(self): def _plot_clusters(self): # Plot scatter plot based on pixel of spots - # for i, cluster in enumerate(self.query_adata.obs[self.use_label].cat.categories): for i, cluster in enumerate(self.query_adata.obs.groupby(self.use_label)): # Plot scatter plot based on pixel of spots @@ -783,7 +763,7 @@ def _add_cluster_labels(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -826,7 +806,7 @@ def _add_sub_clusters(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -836,18 +816,18 @@ def _add_sub_clusters(self): imgrow_new = subset_spatial[:, 1] * self.scale_factor if ( - len( - self.query_adata.obs[ - self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() - ) - < 2 + len( + self.query_adata.obs[ + self.query_adata.obs[self.use_label] == str(label) + ]["sub_cluster_labels"].unique() + ) + < 2 ): centroids = [centroidpython(imgcol_new, imgrow_new)] classes = np.array( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() + ]["sub_cluster_labels"].unique() ) else: @@ -858,7 +838,7 @@ def _add_sub_clusters(self): np.column_stack((imgcol_new, imgrow_new)), self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"], + ]["sub_cluster_labels"], ) centroids = clf.centroids_ @@ -866,12 +846,12 @@ def _add_sub_clusters(self): for j, label in enumerate(classes): if ( - len( - self.query_adata.obs[ - self.query_adata.obs["sub_cluster_labels"] == label - ] - ) - > self.threshold_spots + len( + self.query_adata.obs[ + self.query_adata.obs["sub_cluster_labels"] == label + ] + ) + > self.threshold_spots ): if centroids[j][0] < 1500: x = -100 @@ -973,34 +953,34 @@ def _add_trajectories(self): class SubClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "jet", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 5, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # subcluster plot param - cluster: Optional[int] = 0, - threshold_spots: Optional[int] = 5, - text_box_size: Optional[float] = 5, - bbox_to_anchor: Optional[Tuple[float, float]] = (1, 1), - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # subcluster plot param + cluster: int | None = 0, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + **kwargs, ): super().__init__( adata=adata, @@ -1032,7 +1012,7 @@ def __init__( self._add_subclusters_label(subset) - if fname != None: + if fname is not None: self._save_output() def _plot_subclusters(self, threshold_spots): @@ -1060,13 +1040,13 @@ def _plot_subclusters(self, threshold_spots): colors = colors.replace(self.mapping) - plot = self.ax.scatter( + self.ax.scatter( self.imgcol_new, self.imgrow_new, edgecolor="none", s=self.size, marker="o", - cmap=plt.get_cmap(self.cmap) if type(self.cmap) == str else self.cmap, + cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, c=colors, alpha=self.cell_alpha, ) @@ -1127,36 +1107,36 @@ def _add_subclusters_label(self, subset): class CciPlot(GenePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # cci_rank param - use_het: Optional[str] = "het", - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + use_het: str | None = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -1196,45 +1176,45 @@ def _get_gene_expression(self): class LrResultPlot(GenePlot): def __init__( - self, - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - crop: Optional[bool] = True, - zoom_coord: Optional[float] = None, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # cci_rank param - contour: bool = False, - step_size: Optional[int] = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): # Making sure cci_rank has been run first # if "lr_summary" not in adata.uns: raise Exception( - f"To visualise LR interaction results, must run" f"st.pl.cci.run first." + "To visualise LR interaction results, must run st.pl.cci.run first." ) # By default, using the LR with most significant spots # - if type(use_lr) == type(None): + if use_lr is None: use_lr = adata.uns["lr_summary"].index.values[0] elif use_lr not in adata.uns["lr_summary"].index: raise Exception( diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 484d495b..91f678a3 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -1,63 +1,58 @@ -from __future__ import division + +from collections import OrderedDict + import numpy as np import pandas as pd -from PIL import Image -from stlearn.tools.microenv.cci.het import get_edges - -from bokeh.plotting import ( - figure, - show, - ColumnDataSource, - curdoc, -) +import scanpy as sc +from anndata import AnnData +from bokeh.application import Application +from bokeh.application.handlers import FunctionHandler +from bokeh.layouts import column, row from bokeh.models import ( + Arrow, + AutocompleteInput, + BasicTicker, BoxSelectTool, - LassoSelectTool, + Button, + CheckboxGroup, + ColorBar, CustomJS, Div, - Paragraph, + HoverTool, + LassoSelectTool, LinearColorMapper, - Slider, + Paragraph, Select, - AutocompleteInput, - ColorBar, + Slider, TextInput, - BasicTicker, - HoverTool, - ZoomOutTool, - CheckboxGroup, - Arrow, VeeHead, - Button, - Dropdown, - Div, + ZoomOutTool, ) - -from bokeh.models.widgets import DataTable, DateFormatter, TableColumn -from anndata import AnnData +from bokeh.models.widgets import DataTable, TableColumn from bokeh.palettes import ( - Spectral11, - Viridis256, - Reds256, Blues256, - Magma256, Category20, + Magma256, + Reds256, + Spectral11, + Viridis256, ) -from bokeh.layouts import column, row, grid -from collections import OrderedDict -from bokeh.application import Application -from bokeh.application.handlers import FunctionHandler +from bokeh.plotting import ( + ColumnDataSource, + figure, +) +from PIL import Image + from stlearn.classes import Spatial -from typing import Optional +from stlearn.tools.microenv.cci.het import get_edges from stlearn.utils import _read_graph -import scanpy as sc class BokehGenePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -327,9 +322,9 @@ def create_violin(self, adata, gene_symbol, use_label): class BokehClusterPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__(adata) @@ -476,8 +471,8 @@ def __init__( if "rank_genes_groups" in self.adata[0].uns: if ( - self.use_label.value - == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] + self.use_label.value + == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] ): self.layout = column(row(self.inputs, self.make_fig()), self.add_dea()) else: @@ -513,13 +508,6 @@ def update_list(self, attrname, old, name): from stlearn.plotting.cluster_plot import cluster_plot cluster_plot(self.adata[0], use_label=self.use_label.value, show_plot=False) - - # self.list_cluster = CheckboxGroup( - # labels=list(self.adata[0].obs[self.use_label.value].cat.categories), - # active=list( - # np.array(range(0, len(self.adata[0].obs[self.use_label.value].unique()))) - # ), - # ) self.list_cluster.labels = list( self.adata[0].obs[self.use_label.value].cat.categories ) @@ -531,8 +519,8 @@ def update_data(self, attrname, old, new): if "rank_genes_groups" in self.adata[0].uns: if ( - self.use_label.value - == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] + self.use_label.value + == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] ): self.layout.children[0].children[1] = self.make_fig() self.layout.children[1] = self.add_dea() @@ -776,9 +764,9 @@ def create_dea(self, adata): class BokehLRPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -853,7 +841,6 @@ def __init__( # self.tab = Tabs(tabs = [Panel(child=self.layout, title="Gene plot")]) def modify_fig(doc): - doc.add_root(row(self.layout, width=800)) self.data_alpha.on_change("value", self.update_data) @@ -955,9 +942,9 @@ def _get_lr(self, lr): class BokehSpatialCciPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -1045,7 +1032,6 @@ def __init__( # self.tab = Tabs(tabs = [Panel(child=self.layout, title="Gene plot")]) def modify_fig(doc): - doc.add_root(row(self.layout, width=800)) self.data_alpha.on_change("value", self.update_data) @@ -1165,10 +1151,10 @@ def _get_cci_lr_edges(self): selected = self.annot_select.value # Extracting the data # - l, r = lr.split("_") + ligand, receptor = lr.split("_") lr_index = np.where(adata.uns["lr_summary"].index.values == lr)[0][0] - L_bool = adata[:, l].X.toarray()[:, 0] > 0 - R_bool = adata[:, r].X.toarray()[:, 0] > 0 + L_bool = adata[:, ligand].X.toarray()[:, 0] > 0 + R_bool = adata[:, receptor].X.toarray()[:, 0] > 0 sig_bool = adata.obsm["lr_sig_scores"][:, lr_index] > 0 int_df = adata.uns[f"per_lr_cci_{selected}"][lr] @@ -1225,19 +1211,10 @@ def _add_edges(fig, adata, edges, arrow_size, forward=True, scale_factor=1): ) def update_list(self, attrname, old, name): - # Initialize the color from stlearn.plotting.cluster_plot import cluster_plot - selected = self.annot_select.value.strip("raw_") cluster_plot(self.adata[0], use_label=selected, show_plot=False) - - # self.list_cluster = CheckboxGroup( - # labels=list(self.adata[0].obs[self.use_label.value].cat.categories), - # active=list( - # np.array(range(0, len(self.adata[0].obs[self.use_label.value].unique()))) - # ), - # ) self.list_cluster.labels = list(self.adata[0].obs[selected].cat.categories) self.list_cluster.active = list( np.array(range(0, len(self.adata[0].obs[selected].unique()))) @@ -1246,9 +1223,9 @@ def update_list(self, attrname, old, name): class Annotate(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__(adata) # Open image, and make sure it's RGB*A* @@ -1392,7 +1369,9 @@ def make_fig(self): var new_data = source_data_2.data; - new_data = addRowToAccumulator(new_data,inds,color_index.data.index[0].toString(),color_index.data.index[0]) + ci = color_index.data.index[0]; + cs = ci.toString(); + new_data = addRowToAccumulator(new_data,inds,cs,ci) source_data_2.data = new_data diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index a212a317..84254e82 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -1,66 +1,58 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np -import networkx as nx - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes +from typing import ( + Optional, # Special + ) +import matplotlib from anndata import AnnData -import warnings +from bokeh.io import output_notebook +from bokeh.plotting import show +from stlearn.plotting._docs import doc_cluster_plot, doc_spatial_base_plot from stlearn.plotting.classes import ClusterPlot from stlearn.plotting.classes_bokeh import BokehClusterPlot -from stlearn.plotting._docs import doc_spatial_base_plot, doc_cluster_plot -from stlearn.utils import _AxesSubplot, Axes, _docs_params - -from bokeh.io import push_notebook, output_notebook -from bokeh.plotting import show +from stlearn.utils import _docs_params @_docs_params(spatial_base_plot=doc_spatial_base_plot, cluster_plot=doc_cluster_plot) def cluster_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "default", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 5, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # cluster plot param - show_subcluster: Optional[bool] = False, - show_cluster_labels: Optional[bool] = False, - show_trajectories: Optional[bool] = False, - reverse: Optional[bool] = False, - show_node: Optional[bool] = False, - threshold_spots: Optional[int] = 5, - text_box_size: Optional[float] = 5, - color_bar_size: Optional[float] = 10, - bbox_to_anchor: Optional[Tuple[float, float]] = (1, 1), - # trajectory - trajectory_node_size: Optional[int] = 10, - trajectory_alpha: Optional[float] = 1.0, - trajectory_width: Optional[float] = 2.5, - trajectory_edge_color: Optional[str] = "#f4efd3", - trajectory_arrowsize: Optional[int] = 17, -) -> Optional[AnnData]: + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # cluster plot param + show_subcluster: bool | None = False, + show_cluster_labels: bool | None = False, + show_trajectories: bool | None = False, + reverse: bool | None = False, + show_node: bool | None = False, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + color_bar_size: float | None = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int | None = 10, + trajectory_alpha: float | None = 1.0, + trajectory_width: float | None = 2.5, + trajectory_edge_color: str | None = "#f4efd3", + trajectory_arrowsize: int | None = 17, +) -> AnnData | None: """\ Allows the visualization of a cluster results as the discretes values of dot points in the Spatial transcriptomics array. We also support to @@ -81,7 +73,7 @@ def cluster_plot( """ - assert use_label != None, "Please select `use_label` parameter" + assert use_label is not None, "Please select `use_label` parameter" ClusterPlot( adata, @@ -122,9 +114,8 @@ def cluster_plot( def cluster_plot_interactive( - adata: AnnData, + adata: AnnData, ): - bokeh_object = BokehClusterPlot(adata) output_notebook() show(bokeh_object.app, notebook_handle=True) diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/plotting/deconvolution_plot.py index 96f83caa..632dfadb 100644 --- a/stlearn/plotting/deconvolution_plot.py +++ b/stlearn/plotting/deconvolution_plot.py @@ -1,40 +1,39 @@ -from typing import Optional, Union -from anndata import AnnData -import matplotlib.pyplot as plt -from matplotlib import cm + import matplotlib as mpl +import matplotlib.pyplot as plt import numpy as np -import stlearn.plotting.utils as utils +from anndata import AnnData def deconvolution_plot( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - cluster: [int, str] = None, - celltype: str = None, - celltype_threshold: float = 0, - data_alpha: float = 1.0, - threshold: float = 0.0, - cmap: str = "tab20", - colors: list = None, # The colors to use for each label... - tissue_alpha: float = 1.0, - title: str = None, - spot_size: Union[float, int] = 10, - show_axis: bool = False, - show_legend: bool = True, - show_donut: bool = True, - cropped: bool = True, - margin: int = 100, - name: str = None, - dpi: int = 150, - output: str = None, - copy: bool = False, - figsize: tuple = (6.4, 4.8), - show=True, -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + cluster: [int, str] = None, + celltype: str = None, + celltype_threshold: float = 0, + data_alpha: float = 1.0, + threshold: float = 0.0, + cmap: str = "tab20", + colors: list = None, # The colors to use for each label... + tissue_alpha: float = 1.0, + title: str = None, + spot_size: float | int = 10, + show_axis: bool = False, + show_legend: bool = True, + show_donut: bool = True, + cropped: bool = True, + margin: int = 100, + name: str = None, + dpi: int = 150, + output: str = None, + copy: bool = False, + figsize: tuple = (6.4, 4.8), + show=True, +) -> AnnData | None: """\ - Clustering plot for sptial transcriptomics data. Also it has a function to display trajectory inference. + Clustering plot for sptial transcriptomics data. Also, it has a function to + display trajectory inference. Parameters ---------- @@ -61,7 +60,8 @@ def deconvolution_plot( show_donut Whether to show the donut plot or not. show_trajectory - Show the spatial trajectory or not. It requires stlearn.spatial.trajectory.pseudotimespace. + Show the spatial trajectory or not. It requires + stlearn.spatial.trajectory.pseudotimespace. show_subcluster Show subcluster or not. It requires stlearn.spatial.trajectory.global_level. name @@ -102,7 +102,7 @@ def deconvolution_plot( label_filter_ = label_filter[base.index] - if type(colors) == type(None): + if colors is None: color_vals = list(range(0, len(label_filter_), 1)) my_norm = mpl.colors.Normalize(0, len(label_filter_)) my_cmap = mpl.cm.get_cmap(cmap, len(color_vals)) @@ -143,7 +143,7 @@ def my_autopct(pct): textprops={"fontsize": 5}, ) - if show_legend == True: + if show_legend: ax_cb = fig.add_axes([0.9, 0.25, 0.03, 0.5], axisbelow=False) cb = mpl.colorbar.ColorbarBase( ax_cb, cmap=my_cmap, norm=my_norm, ticks=color_vals diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index 3f3b272a..77b0878c 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -2,59 +2,47 @@ Plotting of continuous features stored in adata.obs. """ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes +from typing import ( + Optional, # Special + ) +import matplotlib from anndata import AnnData -import warnings from stlearn.plotting.classes import FeaturePlot -from stlearn.plotting.classes_bokeh import BokehGenePlot -from stlearn.plotting._docs import doc_spatial_base_plot, doc_gene_plot -from stlearn.utils import Empty, _empty, _AxesSubplot, _docs_params - -from bokeh.io import push_notebook, output_notebook -from bokeh.plotting import show # @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def feat_plot( - adata: AnnData, - feature: str = None, - threshold: Optional[float] = None, - contour: bool = False, - step_size: Optional[int] = None, - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - color_bar_label: Optional[str] = "", - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 0.7, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - vmin: Optional[float] = None, - vmax: Optional[float] = None, -) -> Optional[AnnData]: + adata: AnnData, + feature: str = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + vmin: float | None = None, + vmax: float | None = None, +) -> AnnData | None: """\ Allows the visualization of a continuous features stored in adata.obs for Spatial transcriptomics array. diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index c755d12b..860c0b0a 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,57 +1,50 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes +from typing import ( # Special + Optional, # Classes + ) +import matplotlib from anndata import AnnData -import warnings +from bokeh.io import output_notebook +from bokeh.plotting import show +from stlearn.plotting._docs import doc_gene_plot, doc_spatial_base_plot from stlearn.plotting.classes import GenePlot from stlearn.plotting.classes_bokeh import BokehGenePlot -from stlearn.plotting._docs import doc_spatial_base_plot, doc_gene_plot -from stlearn.utils import Empty, _empty, _AxesSubplot, _docs_params - -from bokeh.io import push_notebook, output_notebook -from bokeh.plotting import show +from stlearn.utils import _docs_params @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def gene_plot( - adata: AnnData, - gene_symbols: Union[str, list] = None, - threshold: Optional[float] = None, - method: str = "CumSum", - contour: bool = False, - step_size: Optional[int] = None, - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "Spectral_r", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[matplotlib.axes.Axes] = None, - fig: Optional[matplotlib.figure.Figure] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - color_bar_label: Optional[str] = "", - zoom_coord: Optional[float] = None, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 7, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 0.7, - use_raw: Optional[bool] = False, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - vmin: Optional[float] = None, - vmax: Optional[float] = None, -) -> Optional[AnnData]: + adata: AnnData, + gene_symbols: str | list = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + vmin: float | None = None, + vmax: float | None = None, +) -> AnnData | None: """\ Allows the visualization of a single gene or multiple genes as the values of dot points or contour in the Spatial transcriptomics array. diff --git a/stlearn/plotting/mask_plot.py b/stlearn/plotting/mask_plot.py index 483163a9..6224b8c2 100644 --- a/stlearn/plotting/mask_plot.py +++ b/stlearn/plotting/mask_plot.py @@ -1,26 +1,25 @@ -import matplotlib -from matplotlib import pyplot as plt -from typing import Optional, Union +import matplotlib from anndata import AnnData +from matplotlib import pyplot as plt def plot_mask( - adata: AnnData, - library_id: str = None, - show_spot: bool = True, - spot_alpha: float = 1.0, - cmap: str = "vega_20_scanpy", - tissue_alpha: float = 1.0, - mask_alpha: float = 0.5, - spot_size: Union[float, int] = 6.5, - show_legend: bool = True, - name: str = "mask_plot", - dpi: int = 150, - output: str = None, - show_axis: bool = False, - show_plot: bool = True, -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + show_spot: bool = True, + spot_alpha: float = 1.0, + cmap: str = "vega_20_scanpy", + tissue_alpha: float = 1.0, + mask_alpha: float = 0.5, + spot_size: float | int = 6.5, + show_legend: bool = True, + name: str = "mask_plot", + dpi: int = 150, + output: str = None, + show_axis: bool = False, + show_plot: bool = True, +) -> AnnData | None: """\ mask plot for sptial transcriptomics data. @@ -59,6 +58,7 @@ def plot_mask( Nothing """ from scanpy.plotting import palettes + from stlearn.plotting import palettes_st if cmap == "vega_10_scanpy": @@ -171,5 +171,5 @@ def plot_mask( if output is not None: fig.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) - if show_plot == True: + if show_plot: plt.show() diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/plotting/non_spatial_plot.py index e7992d63..e196b8f2 100644 --- a/stlearn/plotting/non_spatial_plot.py +++ b/stlearn/plotting/non_spatial_plot.py @@ -1,22 +1,13 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np - -from stlearn._compat import Literal -from typing import Optional, Union -from anndata import AnnData -import warnings # from .utils import get_img_from_fig, checkType import scanpy +from anndata import AnnData def non_spatial_plot( - adata: AnnData, - use_label: str = "louvain", -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", +) -> AnnData | None: """\ A wrap function to plot all the non-spatial plot from scanpy. diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index f229672a..17ee34ff 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -1,35 +1,40 @@ -from typing import Optional, Union -from anndata import AnnData + import pandas as pd +from anndata import AnnData def stack_3d_plot( - adata: AnnData, - slides, - cmap="viridis", - slide_col="sample_id", - use_label=None, - gene_symbol=None, -) -> Optional[AnnData]: + adata: AnnData, + slides, + height, + width, + cmap="viridis", + slide_col="sample_id", + use_label=None, + gene_symbol=None, +) -> AnnData | None: """\ - Clustering plot for sptial transcriptomics data. Also it has a function to display trajectory inference. + Clustering plot for spatial transcriptomics data. Also, it has a function to + display trajectory inference. Parameters ---------- - adata + adata: Annotated data matrix. - slides + slides: A list of slide id - cmap + width: + Width of the plot + height: + Height of the plot + cmap: Color map - use_label + slide_col: + Obs column to use for coloring. + use_label: Choose label to plot (priotize) gene_symbol Choose gene symbol to plot - width - Witdh of the plot - height - Height of the plot Returns ------- Nothing @@ -40,19 +45,19 @@ def stack_3d_plot( raise ModuleNotFoundError("Please install plotly by `pip install plotly`") assert ( - slide_col in adata.obs.columns + slide_col in adata.obs.columns ), "Please provide the right column for slide_id!" list_df = [] for i, slide in enumerate(slides): - tmp = data.obs[data.obs[slide_col] == slide][["imagecol", "imagerow"]] + tmp = adata.obs[adata.obs[slide_col] == slide][["imagecol", "imagerow"]] tmp["sample_id"] = slide tmp["z-dimension"] = i list_df.append(tmp) df = pd.concat(list_df) - if use_label != None: + if use_label is not None: assert use_label in adata.obs.columns, "Please use the right `use_label`" df[use_label] = adata[df.index].obs[use_label].values diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index 3714f6b9..bdca7f54 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -1,50 +1,43 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes +from typing import ( + Optional, # Special + ) from anndata import AnnData -import warnings -from stlearn.plotting.classes import SubClusterPlot from stlearn.plotting._docs import doc_spatial_base_plot, doc_subcluster_plot -from stlearn.utils import _AxesSubplot, Axes, _docs_params +from stlearn.plotting.classes import SubClusterPlot +from stlearn.utils import _AxesSubplot, _docs_params @_docs_params( spatial_base_plot=doc_spatial_base_plot, subcluster_plot=doc_subcluster_plot ) def subcluster_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: Optional[Tuple[float, float]] = None, - cmap: Optional[str] = "jet", - use_label: Optional[str] = None, - list_clusters: Optional[list] = None, - ax: Optional[_AxesSubplot] = None, - show_plot: Optional[bool] = True, - show_axis: Optional[bool] = False, - show_image: Optional[bool] = True, - show_color_bar: Optional[bool] = True, - crop: Optional[bool] = True, - margin: Optional[bool] = 100, - size: Optional[float] = 5, - image_alpha: Optional[float] = 1.0, - cell_alpha: Optional[float] = 1.0, - fname: Optional[str] = None, - dpi: Optional[int] = 120, - # subcluster plot param - cluster: Optional[int] = 0, - threshold_spots: Optional[int] = 5, - text_box_size: Optional[float] = 5, - bbox_to_anchor: Optional[Tuple[float, float]] = (1, 1), -) -> Optional[AnnData]: + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: _AxesSubplot | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # subcluster plot param + cluster: int | None = 0, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), +) -> AnnData | None: """\ Allows the visualization of a subclustering results as the discretes values of dot points in the Spatial transcriptomics array. @@ -64,9 +57,9 @@ def subcluster_plot( """ - assert use_label != None, "Please select `use_label` parameter" + assert use_label is not None, "Please select `use_label` parameter" assert ( - use_label in adata.obs.columns + use_label in adata.obs.columns ), "Please run `stlearn.spatial.cluster.localization` function!" SubClusterPlot( diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/plotting/trajectory/DE_transition_plot.py index d988099c..c70bfd6e 100644 --- a/stlearn/plotting/trajectory/DE_transition_plot.py +++ b/stlearn/plotting/trajectory/DE_transition_plot.py @@ -1,17 +1,17 @@ -import matplotlib.pyplot as plt from decimal import Decimal -from typing import Optional, Union + +import matplotlib.pyplot as plt from anndata import AnnData def DE_transition_plot( - adata: AnnData, - top_genes: int = 10, - font_size: int = 6, - name: str = None, - dpi: int = 150, - output: str = None, -) -> Optional[AnnData]: + adata: AnnData, + top_genes: int = 10, + font_size: int = 6, + name: str = None, + dpi: int = 150, + output: str = None, +) -> AnnData | None: """\ Differential expression between transition markers. diff --git a/stlearn/plotting/trajectory/__init__.py b/stlearn/plotting/trajectory/__init__.py index 16681a51..4d5d7457 100644 --- a/stlearn/plotting/trajectory/__init__.py +++ b/stlearn/plotting/trajectory/__init__.py @@ -1,7 +1,17 @@ -from .pseudotime_plot import pseudotime_plot +from .check_trajectory import check_trajectory +from .DE_transition_plot import DE_transition_plot from .local_plot import local_plot -from .tree_plot_simple import tree_plot_simple -from .tree_plot import tree_plot +from .pseudotime_plot import pseudotime_plot from .transition_markers_plot import transition_markers_plot -from .DE_transition_plot import DE_transition_plot -from .check_trajectory import check_trajectory +from .tree_plot import tree_plot +from .tree_plot_simple import tree_plot_simple + +__all__ = [ + "DE_transition_plot", + "check_trajectory", + "local_plot", + "pseudotime_plot", + "transition_markers_plot", + "tree_plot", + "tree_plot_simple", +] diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/plotting/trajectory/check_trajectory.py index 29037969..5cab6744 100644 --- a/stlearn/plotting/trajectory/check_trajectory.py +++ b/stlearn/plotting/trajectory/check_trajectory.py @@ -1,35 +1,35 @@ -from anndata import AnnData -from typing import Optional, Union + import matplotlib.pyplot as plt -import scanpy as sc import numpy as np +import scanpy as sc +from anndata import AnnData def check_trajectory( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - basis: str = "umap", - pseudotime_key: str = "dpt_pseudotime", - trajectory: list = None, - figsize=(10, 4), - size_umap: int = 50, - size_spatial: int = 1.5, - img_key: str = "hires", -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + basis: str = "umap", + pseudotime_key: str = "dpt_pseudotime", + trajectory: list = None, + figsize=(10, 4), + size_umap: int = 50, + size_spatial: int = 1.5, + img_key: str = "hires", +) -> AnnData | None: trajectory = np.array(trajectory).astype(int) assert ( - trajectory in adata.uns["available_paths"].values() + trajectory in adata.uns["available_paths"].values() ), "Please choose the right path!" trajectory = trajectory.astype(str) assert ( - pseudotime_key in adata.obs.columns + pseudotime_key in adata.obs.columns ), "Please run the pseudotime or choose the right one!" assert ( - use_label in adata.obs.columns + use_label in adata.obs.columns ), "Please run the cluster or choose the right label!" assert basis in adata.obsm, ( - "Please run the " + basis + "before you check the trajectory!" + "Please run the " + basis + "before you check the trajectory!" ) if library_id is None: library_id = list(adata.uns["spatial"].keys())[0] @@ -66,7 +66,7 @@ def check_trajectory( show=False, ) - im = ax2.imshow( + ax2.imshow( adata.uns["spatial"][library_id]["images"][img_key], alpha=0, zorder=-1 ) diff --git a/stlearn/plotting/trajectory/local_plot.py b/stlearn/plotting/trajectory/local_plot.py index a520f8a9..f1c0c7a6 100644 --- a/stlearn/plotting/trajectory/local_plot.py +++ b/stlearn/plotting/trajectory/local_plot.py @@ -1,37 +1,29 @@ -from mpl_toolkits.mplot3d import Axes3D -from matplotlib.patches import FancyArrowPatch -from mpl_toolkits.mplot3d import proj3d + import matplotlib.pyplot as plt import numpy as np -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np -import networkx as nx -from stlearn._compat import Literal -from typing import Optional, Union from anndata import AnnData -import warnings +from matplotlib.patches import FancyArrowPatch +from mpl_toolkits.mplot3d import proj3d def local_plot( - adata: AnnData, - use_label: str = "louvain", - use_cluster: int = None, - reverse: bool = False, - cluster: int = 0, - data_alpha: float = 1.0, - arrow_alpha: float = 1.0, - branch_alpha: float = 0.2, - spot_size: Union[float, int] = 1, - show_color_bar: bool = True, - show_axis: bool = False, - show_plot: bool = True, - name: str = None, - dpi: int = 150, - output: str = None, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + use_cluster: int = None, + reverse: bool = False, + cluster: int = 0, + data_alpha: float = 1.0, + arrow_alpha: float = 1.0, + branch_alpha: float = 0.2, + spot_size: float | int = 1, + show_color_bar: bool = True, + show_axis: bool = False, + show_plot: bool = True, + name: str = None, + dpi: int = 150, + output: str = None, + copy: bool = False, +) -> AnnData | None: """\ Local spatial trajectory inference plot. @@ -84,8 +76,8 @@ def local_plot( order = 0 for i in ref_cluster.obs["sub_cluster_labels"].unique(): if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > adata.uns["threshold_spots"] + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > adata.uns["threshold_spots"] ): classes_.append(i) centroid_dict = adata.uns["centroid_dict"] @@ -113,9 +105,6 @@ def local_plot( x = np.linspace(centroids_[i][0], centroids_[i + j][0], 1000) z = np.linspace(centroids_[i][1], centroids_[i + j][1], 1000) - branch = ax.plot( - x, y, z, zorder=10, c="#333333", linewidth=1, alpha=branch_alpha - ) if reverse: dpt_distance = -dpt_distance if dpt_distance <= 0: diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 14c67030..6a92528f 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -1,44 +1,40 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd + import matplotlib -import numpy as np import networkx as nx -from stlearn._compat import Literal -from typing import Optional, Union +import numpy as np from anndata import AnnData -import warnings +from matplotlib import pyplot as plt from stlearn.utils import _read_graph def pseudotime_plot( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - pseudotime_key: str = "pseudotime_key", - list_clusters: Union[str, list] = None, - cell_alpha: float = 1.0, - image_alpha: float = 1.0, - edge_alpha: float = 0.8, - node_alpha: float = 1.0, - spot_size: Union[float, int] = 6.5, - node_size: float = 5, - show_color_bar: bool = True, - show_axis: bool = False, - show_graph: bool = True, - show_trajectories: bool = False, - reverse: bool = False, - show_node: bool = True, - show_plot: bool = True, - cropped: bool = True, - margin: int = 100, - dpi: int = 150, - output: str = None, - name: str = None, - copy: bool = False, - ax=None, -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + pseudotime_key: str = "pseudotime_key", + list_clusters: str | list = None, + cell_alpha: float = 1.0, + image_alpha: float = 1.0, + edge_alpha: float = 0.8, + node_alpha: float = 1.0, + spot_size: float | int = 6.5, + node_size: float = 5, + show_color_bar: bool = True, + show_axis: bool = False, + show_graph: bool = True, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = True, + show_plot: bool = True, + cropped: bool = True, + margin: int = 100, + dpi: int = 150, + output: str = None, + name: str = None, + copy: bool = False, + ax=None, +) -> AnnData | None: """\ Global trajectory inference plot (Only DPT). @@ -87,20 +83,12 @@ def pseudotime_plot( Nothing """ - # plt.rcParams['figure.dpi'] = dpi - imagecol = adata.obs["imagecol"] imagerow = adata.obs["imagerow"] - - if list_clusters == None: + if list_clusters is None: list_clusters = np.array(range(0, len(adata.obs[use_label].unique()))).astype( int ) - # Get query clusters - command = [] - # for i in list_clusters: - # command.append(use_label + ' == "' + str(i) + '"') - # tmp = adata.obs.query(" or ".join(command)) tmp = adata.obs G = _read_graph(adata, "global_graph") @@ -120,13 +108,11 @@ def pseudotime_plot( result2.append(labels[edge[::-1]] + 0.5) fig, a = plt.subplots() - if ax != None: + if ax is not None: a = ax centroid_dict = adata.uns["centroid_dict"] centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} dpt = adata.obs[pseudotime_key] - - colors = adata.obs[use_label].astype(int) vmin = min(dpt) vmax = max(dpt) # Plot scatter plot based on pixel of spots @@ -149,19 +135,9 @@ def pseudotime_plot( cmap=plt.get_cmap("viridis"), c=scale.reshape(1, -1)[0], ) - - n_clus = len(colors.unique()) - used_colors = adata.uns[use_label + "_colors"] cmaps = matplotlib.colors.LinearSegmentedColormap.from_list("", used_colors) - cmap = plt.get_cmap(cmaps) - bounds = np.linspace(0, n_clus, n_clus + 1) - norm = matplotlib.colors.BoundaryNorm(bounds, cmap.N) - - norm = matplotlib.colors.Normalize(vmin=min(colors), vmax=max(colors)) - m = matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap) - if show_graph: nx.draw_networkx( G, @@ -290,7 +266,7 @@ def pseudotime_plot( if output is not None: fig.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) - if show_plot == True: + if show_plot: plt.show() diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/plotting/trajectory/transition_markers_plot.py index b68ec7fe..267a6e64 100644 --- a/stlearn/plotting/trajectory/transition_markers_plot.py +++ b/stlearn/plotting/trajectory/transition_markers_plot.py @@ -1,17 +1,17 @@ -import matplotlib.pyplot as plt from decimal import Decimal + +import matplotlib.pyplot as plt from anndata import AnnData -from typing import Optional, Union def transition_markers_plot( - adata: AnnData, - top_genes: int = 10, - trajectory: str = None, - dpi: int = 150, - output: str = None, - name: str = None, -) -> Optional[AnnData]: + adata: AnnData, + top_genes: int = 10, + trajectory: str = None, + dpi: int = 150, + output: str = None, + name: str = None, +) -> AnnData | None: """\ Plot transition marker. @@ -34,7 +34,7 @@ def transition_markers_plot( Anndata """ - if trajectory == None: + if trajectory is None: raise ValueError("Please input the trajectory name!") if trajectory not in adata.uns: raise ValueError("Please input the right trajectory name!") diff --git a/stlearn/plotting/trajectory/tree_plot.py b/stlearn/plotting/trajectory/tree_plot.py index 90ade45f..c7999321 100644 --- a/stlearn/plotting/trajectory/tree_plot.py +++ b/stlearn/plotting/trajectory/tree_plot.py @@ -1,38 +1,31 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np -import networkx as nx import math import random -from stlearn._compat import Literal -from typing import Optional, Union + +import networkx as nx from anndata import AnnData -import warnings -import io -from copy import deepcopy +from matplotlib import pyplot as plt + from stlearn.utils import _read_graph def tree_plot( - adata: AnnData, - library_id: str = None, - figsize: Union[float, int] = (10, 4), - data_alpha: float = 1.0, - use_label: str = "louvain", - spot_size: Union[float, int] = 50, - fontsize: int = 6, - piesize: float = 0.15, - zoom: float = 0.1, - name: str = None, - output: str = None, - dpi: int = 180, - show_all: bool = False, - show_plot: bool = True, - ncols: int = 4, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + figsize: float | int = (10, 4), + data_alpha: float = 1.0, + use_label: str = "louvain", + spot_size: float | int = 50, + fontsize: int = 6, + piesize: float = 0.15, + zoom: float = 0.1, + name: str = None, + output: str = None, + dpi: int = 180, + show_all: bool = False, + show_plot: bool = True, + ncols: int = 4, + copy: bool = False, +) -> AnnData | None: """\ Hierarchical tree plot represent for the global spatial trajectory inference. @@ -108,7 +101,7 @@ def tree_plot( output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0 ) - if show_plot == True: + if show_plot: plt.show() @@ -120,23 +113,24 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 If the graph is a tree this will return the positions to plot this in a hierarchical layout. - G: the graph (must be a tree) - - root: the root node of current branch - - if the tree is directed and this is not given, - the root will be found and used - - if the tree is directed and this is given, then - the positions will be just for the descendants of this node. - - if the tree is undirected and not given, - then a random choice will be used. - - width: horizontal space allocated for this branch - avoids overlap with other branches - - vert_gap: gap between levels of hierarchy - - vert_loc: vertical location of root - - xcenter: horizontal location of root + G: + the graph (must be a tree) + root: + the root node of current branch + - if the tree is directed and this is not given, + the root will be found and used + - if the tree is directed and this is given, then + the positions will be just for the descendants of this node. + - if the tree is undirected and not given, + then a random choice will be used. + width: + horizontal space allocated for this branch - avoids overlap with other branches + vert_gap: + gap between levels of hierarchy + vert_loc: + vertical location of root + xcenter: + horizontal location of root """ if not nx.is_tree(G): raise TypeError("cannot use hierarchy_pos on a graph that is not a tree") @@ -150,7 +144,8 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 root = random.choice(list(G.nodes)) def _hierarchy_pos( - G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, parent=None + G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, + parent=None ): """ see hierarchy_pos docstring for most arguments diff --git a/stlearn/plotting/trajectory/tree_plot_simple.py b/stlearn/plotting/trajectory/tree_plot_simple.py index 3b2395fd..84c0882f 100644 --- a/stlearn/plotting/trajectory/tree_plot_simple.py +++ b/stlearn/plotting/trajectory/tree_plot_simple.py @@ -1,38 +1,31 @@ -from matplotlib import pyplot as plt -from PIL import Image -import pandas as pd -import matplotlib -import numpy as np -import networkx as nx import math import random -from stlearn._compat import Literal -from typing import Optional, Union + +import networkx as nx from anndata import AnnData -import warnings -import io -from copy import deepcopy +from matplotlib import pyplot as plt + from stlearn.utils import _read_graph def tree_plot_simple( - adata: AnnData, - library_id: str = None, - figsize: Union[float, int] = (10, 4), - data_alpha: float = 1.0, - use_label: str = "louvain", - spot_size: Union[float, int] = 50, - fontsize: int = 6, - piesize: float = 0.15, - zoom: float = 0.1, - name: str = None, - output: str = None, - dpi: int = 180, - show_all: bool = False, - show_plot: bool = True, - ncols: int = 4, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + library_id: str = None, + figsize: float | int = (10, 4), + data_alpha: float = 1.0, + use_label: str = "louvain", + spot_size: float | int = 50, + fontsize: int = 6, + piesize: float = 0.15, + zoom: float = 0.1, + name: str = None, + output: str = None, + dpi: int = 180, + show_all: bool = False, + show_plot: bool = True, + ncols: int = 4, + copy: bool = False, +) -> AnnData | None: """\ Hierarchical tree plot represent for the global spatial trajectory inference. @@ -108,7 +101,7 @@ def tree_plot_simple( output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0 ) - if show_plot == True: + if show_plot: plt.show() @@ -120,23 +113,24 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 If the graph is a tree this will return the positions to plot this in a hierarchical layout. - G: the graph (must be a tree) - - root: the root node of current branch - - if the tree is directed and this is not given, - the root will be found and used - - if the tree is directed and this is given, then - the positions will be just for the descendants of this node. - - if the tree is undirected and not given, - then a random choice will be used. - - width: horizontal space allocated for this branch - avoids overlap with other branches - - vert_gap: gap between levels of hierarchy - - vert_loc: vertical location of root - - xcenter: horizontal location of root + G: + the graph (must be a tree) + root: + the root node of current branch + - if the tree is directed and this is not given, + the root will be found and used + - if the tree is directed and this is given, then + the positions will be just for the descendants of this node. + - if the tree is undirected and not given, + then a random choice will be used. + width: + horizontal space allocated for this branch - avoids overlap with other branches + vert_gap: + gap between levels of hierarchy + vert_loc: + vertical location of root + xcenter: + horizontal location of root """ if not nx.is_tree(G): raise TypeError("cannot use hierarchy_pos on a graph that is not a tree") @@ -150,7 +144,8 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 root = random.choice(list(G.nodes)) def _hierarchy_pos( - G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, parent=None + G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, + parent=None ): """ see hierarchy_pos docstring for most arguments diff --git a/stlearn/plotting/trajectory/utils.py b/stlearn/plotting/trajectory/utils.py index f7b46284..a8095616 100644 --- a/stlearn/plotting/trajectory/utils.py +++ b/stlearn/plotting/trajectory/utils.py @@ -1,5 +1,4 @@ def checkType(arr, n=2): - # If the first two and the last two elements # of the array are in increasing order if arr[0] <= arr[1] and arr[n - 2] <= arr[n - 1]: diff --git a/stlearn/plotting/utils.py b/stlearn/plotting/utils.py index fb22686a..9c5eecf0 100644 --- a/stlearn/plotting/utils.py +++ b/stlearn/plotting/utils.py @@ -1,24 +1,12 @@ -import numpy as np -import pandas as pd - import io -from PIL import Image import matplotlib import matplotlib.pyplot as plt -from anndata import AnnData +import numpy as np +from PIL import Image from scanpy.plotting import palettes -from stlearn.plotting import palettes_st - -from typing import Optional, Union, Mapping # Special -from typing import Sequence, Iterable # ABCs -from typing import Tuple # Classes -from enum import Enum - -from matplotlib import rcParams, ticker, gridspec, axes -from matplotlib.axes import Axes -from abc import ABC +from stlearn.plotting import palettes_st def get_img_from_fig(fig, dpi=180): @@ -39,16 +27,16 @@ def get_img_from_fig(fig, dpi=180): def centroidpython(x, y): - l = len(x) - return sum(x) / l, sum(y) / l + length_of_x = len(x) + return sum(x) / length_of_x, sum(y) / length_of_x def get_cluster(search, dictionary): for ( - cl, - sub, + cl, + sub, ) in ( - dictionary.items() + dictionary.items() ): # for name, age in dictionary.iteritems(): (for Python 2.x) if search in sub: return cl @@ -82,10 +70,10 @@ def get_cmap(cmap): cmap = palettes_st.jana_40 elif cmap == "default": cmap = palettes_st.default - elif type(cmap) == str: # If refers to matplotlib cmap + elif cmap is str: # If refers to matplotlib cmap cmap_n = plt.get_cmap(cmap).N return plt.get_cmap(cmap), cmap_n - elif type(cmap) == matplotlib.colors.LinearSegmentedColormap: # already cmap + elif cmap is matplotlib.colors.LinearSegmentedColormap: # already cmap cmap_n = cmap.N return cmap, cmap_n @@ -103,12 +91,12 @@ def check_cmap(cmap): stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) - if type(cmap) == str: + if cmap is str: assert cmap in cmap_available, error_msg - elif type(cmap) != matplotlib.colors.LinearSegmentedColormap: + elif cmap is not matplotlib.colors.LinearSegmentedColormap: raise Exception(error_msg) return cmap @@ -137,7 +125,7 @@ def get_colors(adata, obs_key, cmap="default", label_set=None): adata.uns[col_key] = colors_ordered # Returning the colors of the desired labels in indicated order # - if type(label_set) != type(None): + if label_set is not None: colors_ordered = [ colors_ordered[np.where(labels_ordered == label)[0][0]] for label in label_set diff --git a/stlearn/pp.py b/stlearn/pp.py index 87407591..695efd24 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,7 +1,16 @@ +from .image_preprocessing.feature_extractor import extract_feature +from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes -from .preprocessing.normalize import normalize_total -from .preprocessing.log_scale import log1p -from .preprocessing.log_scale import scale from .preprocessing.graph import neighbors -from .image_preprocessing.image_tiling import tiling -from .image_preprocessing.feature_extractor import extract_feature +from .preprocessing.log_scale import log1p, scale +from .preprocessing.normalize import normalize_total + +__all__ = [ + "filter_genes", + "normalize_total", + "log1p", + "scale", + "neighbors", + "tiling", + "extract_feature", +] diff --git a/stlearn/preprocessing/filter_genes.py b/stlearn/preprocessing/filter_genes.py index 5f102ea6..260773bc 100644 --- a/stlearn/preprocessing/filter_genes.py +++ b/stlearn/preprocessing/filter_genes.py @@ -1,18 +1,17 @@ -from typing import Union, Optional, Tuple, Collection, Sequence, Iterable -from anndata import AnnData + import numpy as np -from scipy.sparse import issparse, isspmatrix_csr, csr_matrix, spmatrix import scanpy +from anndata import AnnData def filter_genes( - adata: AnnData, - min_counts: Optional[int] = None, - min_cells: Optional[int] = None, - max_counts: Optional[int] = None, - max_cells: Optional[int] = None, - inplace: bool = True, -) -> Union[AnnData, None, Tuple[np.ndarray, np.ndarray]]: + adata: AnnData, + min_counts: int | None = None, + min_cells: int | None = None, + max_counts: int | None = None, + max_cells: int | None = None, + inplace: bool = True, +) -> AnnData | None | tuple[np.ndarray, np.ndarray]: """\ Wrap function scanpy.pp.filter_genes diff --git a/stlearn/preprocessing/graph.py b/stlearn/preprocessing/graph.py index 8d7255c1..6d6334dd 100644 --- a/stlearn/preprocessing/graph.py +++ b/stlearn/preprocessing/graph.py @@ -1,12 +1,13 @@ +from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import Union, Optional, Any, Mapping, Callable +from typing import Any import numpy as np -import scipy +import scanpy from anndata import AnnData from numpy.random import RandomState + from .._compat import Literal -import scanpy _Method = Literal["umap", "gauss", "rapids"] _MetricFn = Callable[[np.ndarray, np.ndarray], float] @@ -33,21 +34,21 @@ "sqeuclidean", "yule", ] -_Metric = Union[_MetricSparseCapable, _MetricScipySpatial] +_Metric = _MetricSparseCapable | _MetricScipySpatial def neighbors( - adata: AnnData, - n_neighbors: int = 15, - n_pcs: Optional[int] = None, - use_rep: Optional[str] = None, - knn: bool = True, - random_state: Optional[Union[int, RandomState]] = 0, - method: Optional[_Method] = "umap", - metric: Union[_Metric, _MetricFn] = "euclidean", - metric_kwds: Mapping[str, Any] = MappingProxyType({}), - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + n_neighbors: int = 15, + n_pcs: int | None = None, + use_rep: str | None = None, + knn: bool = True, + random_state: int | RandomState | None = 0, + method: _Method | None = "umap", + metric: _Metric | _MetricFn = "euclidean", + metric_kwds: Mapping[str, Any] = MappingProxyType({}), + copy: bool = False, +) -> AnnData | None: """\ Compute a neighborhood graph of observations [McInnes18]_. The neighbor search efficiency of this heavily relies on UMAP [McInnes18]_, diff --git a/stlearn/preprocessing/log_scale.py b/stlearn/preprocessing/log_scale.py index 8ba18ec2..4fb216d8 100644 --- a/stlearn/preprocessing/log_scale.py +++ b/stlearn/preprocessing/log_scale.py @@ -1,19 +1,17 @@ -from typing import Union, Optional, Tuple, Collection, Sequence, Iterable -from anndata import AnnData + import numpy as np -from scipy.sparse import issparse, isspmatrix_csr, csr_matrix, spmatrix -from scipy import sparse -from stlearn import logging as logg import scanpy +from anndata import AnnData +from scipy.sparse import spmatrix def log1p( - adata: Union[AnnData, np.ndarray, spmatrix], - copy: bool = False, - chunked: bool = False, - chunk_size: Optional[int] = None, - base: Optional[float] = None, -) -> Optional[AnnData]: + adata: AnnData | np.ndarray | spmatrix, + copy: bool = False, + chunked: bool = False, + chunk_size: int | None = None, + base: float | None = None, +) -> AnnData | None: """\ Wrap function of scanpy.pp.log1p Copyright (c) 2017 F. Alexander Wolf, P. Angerer, Theis Lab @@ -47,11 +45,11 @@ def log1p( def scale( - adata: Union[AnnData, np.ndarray, spmatrix], - zero_center: bool = True, - max_value: Optional[float] = None, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData | np.ndarray | spmatrix, + zero_center: bool = True, + max_value: float | None = None, + copy: bool = False, +) -> AnnData | None: """\ Wrap function of scanpy.pp.scale diff --git a/stlearn/preprocessing/normalize.py b/stlearn/preprocessing/normalize.py index 0bfe006a..11fd6528 100644 --- a/stlearn/preprocessing/normalize.py +++ b/stlearn/preprocessing/normalize.py @@ -1,23 +1,22 @@ -from typing import Optional, Union, Iterable, Dict +from collections.abc import Iterable import numpy as np +import scanpy from anndata import AnnData -from scipy.sparse import issparse -from sklearn.utils import sparsefuncs + from stlearn._compat import Literal -import scanpy def normalize_total( - adata: AnnData, - target_sum: Optional[float] = None, - exclude_highly_expressed: bool = False, - max_fraction: float = 0.05, - key_added: Optional[str] = None, - layers: Union[Literal["all"], Iterable[str]] = None, - layer_norm: Optional[str] = None, - inplace: bool = True, -) -> Optional[Dict[str, np.ndarray]]: + adata: AnnData, + target_sum: float | None = None, + exclude_highly_expressed: bool = False, + max_fraction: float = 0.05, + key_added: str | None = None, + layers: Literal["all"] | Iterable[str] = None, + layer_norm: str | None = None, + inplace: bool = True, +) -> dict[str, np.ndarray] | None: """\ Wrap function from scanpy.pp.log1p Normalize counts per cell. diff --git a/stlearn/spatial.py b/stlearn/spatial.py index 8f62d633..cbf7eced 100644 --- a/stlearn/spatial.py +++ b/stlearn/spatial.py @@ -1,5 +1,9 @@ -from .spatials import clustering -from .spatials import smooth -from .spatials import trajectory -from .spatials import morphology -from .spatials import SME +from .spatials import SME, clustering, morphology, smooth, trajectory + +__all__ = [ + "clustering", + "smooth", + "trajectory", + "morphology", + "SME", +] diff --git a/stlearn/spatials/SME/__init__.py b/stlearn/spatials/SME/__init__.py index 88321427..8fffb497 100644 --- a/stlearn/spatials/SME/__init__.py +++ b/stlearn/spatials/SME/__init__.py @@ -1,2 +1,8 @@ -from .normalize import SME_normalize from .impute import SME_impute0, pseudo_spot +from .normalize import SME_normalize + +__all__ = [ + "SME_normalize", + "SME_impute0", + "pseudo_spot", +] diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatials/SME/_weighting_matrix.py index 49553763..bd9c0bba 100644 --- a/stlearn/spatials/SME/_weighting_matrix.py +++ b/stlearn/spatials/SME/_weighting_matrix.py @@ -1,10 +1,11 @@ -from sklearn.metrics import pairwise_distances -from typing import Optional, Union -from anndata import AnnData + import numpy as np -from ..._compat import Literal +from anndata import AnnData +from sklearn.metrics import pairwise_distances from tqdm import tqdm +from ..._compat import Literal + _PLATFORM = Literal["Visium", "Old_ST"] _WEIGHTING_MATRIX = Literal[ "weights_matrix_all", @@ -19,13 +20,14 @@ def calculate_weight_matrix( adata: AnnData, - adata_imputed: Union[AnnData, None] = None, + adata_imputed: AnnData | None = None, pseudo_spots: bool = False, platform: _PLATFORM = "Visium", -) -> Optional[AnnData]: - from sklearn.linear_model import LinearRegression +) -> AnnData | None: import math + from sklearn.linear_model import LinearRegression + if platform == "Visium": img_row = adata.obs["imagerow"] img_col = adata.obs["imagecol"] @@ -105,10 +107,10 @@ def calculate_weight_matrix( def impute_neighbour( adata: AnnData, - count_embed: Union[np.ndarray, None] = None, + count_embed: np.ndarray | None = None, weights: _WEIGHTING_MATRIX = "weights_matrix_all", copy: bool = False, -) -> Optional[AnnData]: +) -> AnnData | None: coor = adata.obs[["imagecol", "imagerow"]] weights_matrix = adata.uns[weights] diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatials/SME/impute.py index 12dcebac..109814bd 100644 --- a/stlearn/spatials/SME/impute.py +++ b/stlearn/spatials/SME/impute.py @@ -1,30 +1,32 @@ -from typing import Optional, Union -from anndata import AnnData from pathlib import Path + import numpy as np -from scipy.sparse import csr_matrix import pandas as pd +import scipy +from anndata import AnnData +from scipy.sparse import csr_matrix + +import stlearn + +from ..._compat import Literal from ._weighting_matrix import ( + _PLATFORM, + _WEIGHTING_MATRIX, calculate_weight_matrix, impute_neighbour, - _WEIGHTING_MATRIX, - _PLATFORM, ) -import stlearn -import scipy -from ..._compat import Literal def SME_impute0( - adata: AnnData, - use_data: str = "raw", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - platform: _PLATFORM = "Visium", - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_data: str = "raw", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + platform: _PLATFORM = "Visium", + copy: bool = False, +) -> AnnData | None: """\ - using spatial location (S), tissue morphological feature (M) and gene expression (E) information to impute missing - values + using spatial location (S), tissue morphological feature (M) and gene + expression (E) information to impute missing values Parameters ---------- @@ -34,10 +36,10 @@ def SME_impute0( input data, can be `raw` counts or log transformed data weights weighting matrix for imputation. - if `weights_matrix_all`, matrix combined all information from spatial location (S), - tissue morphological feature (M) and gene expression (E) - if `weights_matrix_pd_md`, matrix combined information from spatial location (S), - tissue morphological feature (M) + if `weights_matrix_all`, matrix combined all information from spatial + location (S), tissue morphological feature (M) and gene expression (E) + if `weights_matrix_pd_md`, matrix combined information from spatial + location (S), tissue morphological feature (M) platform `Visium` or `Old_ST` copy @@ -85,24 +87,26 @@ def SME_impute0( def pseudo_spot( - adata: AnnData, - tile_path: Union[Path, str] = Path("/tmp/tiles"), - use_data: str = "raw", - crop_size: int = "auto", - platform: _PLATFORM = "Visium", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - copy: _COPY = "pseudo_spot_adata", -) -> Optional[AnnData]: + adata: AnnData, + tile_path: Path | str = Path("/tmp/tiles"), + use_data: str = "raw", + crop_size: int = "auto", + platform: _PLATFORM = "Visium", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + copy: _COPY = "pseudo_spot_adata", +) -> AnnData | None: """\ - using spatial location (S), tissue morphological feature (M) and gene expression (E) information to impute - gap between spots and increase resolution for gene detection + using spatial location (S), tissue morphological feature (M) and gene + expression (E) information to impute gap between spots and increase resolution + for gene detection Parameters ---------- adata Annotated data matrix. use_data - Input data, can be `raw` counts, log transformed data or dimension reduced space(`X_pca` and `X_umap`) + Input data, can be `raw` counts, log transformed data or dimension + reduced space(`X_pca` and `X_umap`) tile_path Path to save spot image tiles crop_size @@ -110,10 +114,10 @@ def pseudo_spot( if `auto`, automatically detect crop size weights Weighting matrix for imputation. - if `weights_matrix_all`, matrix combined all information from spatial location (S), - tissue morphological feature (M) and gene expression (E) - if `weights_matrix_pd_md`, matrix combined information from spatial location (S), - tissue morphological feature (M) + if `weights_matrix_all`, matrix combined all information from spatial + location (S), tissue morphological feature (M) and gene expression (E) + if `weights_matrix_pd_md`, matrix combined information from spatial + location (S), tissue morphological feature (M) platform `Visium` or `Old_ST` copy @@ -124,15 +128,15 @@ def pseudo_spot( ------- Anndata """ - from sklearn.linear_model import LinearRegression import math + from sklearn.linear_model import LinearRegression + if platform == "Visium": img_row = adata.obs["imagerow"] img_col = adata.obs["imagecol"] array_row = adata.obs["array_row"] array_col = adata.obs["array_col"] - rate = 3 obs_df_ = adata.obs[["array_row", "array_col"]].copy() obs_df_.loc[:, "array_row"] = obs_df_["array_row"].apply(lambda x: x - 2 / 3) obs_df = adata.obs[["array_row", "array_col"]].copy() @@ -145,7 +149,6 @@ def pseudo_spot( img_col = adata.obs["imagecol"] array_row = adata.obs_names.map(lambda x: x.split("x")[1]) array_col = adata.obs_names.map(lambda x: x.split("x")[0]) - rate = 1.5 obs_df_left = pd.DataFrame( {"array_row": array_row.to_list(), "array_col": array_col.to_list()}, dtype=np.float64, @@ -246,10 +249,10 @@ def pseudo_spot( reg_col = LinearRegression().fit(array_col.values.reshape(-1, 1), img_col) obs_df.loc[:, "imagerow"] = ( - obs_df.loc[:, "array_row"] * reg_row.coef_ + reg_row.intercept_ + obs_df.loc[:, "array_row"] * reg_row.coef_ + reg_row.intercept_ ) obs_df.loc[:, "imagecol"] = ( - obs_df.loc[:, "array_col"] * reg_col.coef_ + reg_col.intercept_ + obs_df.loc[:, "array_col"] * reg_col.coef_ + reg_col.intercept_ ) impute_coor = obs_df[["imagecol", "imagerow"]] @@ -257,7 +260,7 @@ def pseudo_spot( point_tree = scipy.spatial.cKDTree(coor) n_neighbour = [] - unit = math.sqrt(reg_row.coef_**2 + reg_col.coef_**2) + unit = math.sqrt(reg_row.coef_ ** 2 + reg_col.coef_ ** 2) for i in range(len(impute_coor)): current_neighbour = point_tree.query_ball_point( impute_coor.values[i], round(unit) @@ -316,10 +319,10 @@ def pseudo_spot( def _merge( - adata1: AnnData, - adata2: AnnData, - copy: bool = True, -) -> Optional[AnnData]: + adata1: AnnData, + adata2: AnnData, + copy: bool = True, +) -> AnnData | None: merged_df = adata1.to_df().append(adata2.to_df()) merged_df_obs = adata1.obs.append(adata2.obs) merged_adata = AnnData(merged_df, obs=merged_df_obs) diff --git a/stlearn/spatials/SME/normalize.py b/stlearn/spatials/SME/normalize.py index 83b132f9..38849d37 100644 --- a/stlearn/spatials/SME/normalize.py +++ b/stlearn/spatials/SME/normalize.py @@ -1,41 +1,43 @@ -from typing import Optional -from anndata import AnnData + import numpy as np -from scipy.sparse import csr_matrix import pandas as pd +from anndata import AnnData +from scipy.sparse import csr_matrix + from ._weighting_matrix import ( + _PLATFORM, + _WEIGHTING_MATRIX, calculate_weight_matrix, impute_neighbour, - _WEIGHTING_MATRIX, - _PLATFORM, ) def SME_normalize( - adata: AnnData, - use_data: str = "raw", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - platform: _PLATFORM = "Visium", - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_data: str = "raw", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + platform: _PLATFORM = "Visium", + copy: bool = False, +) -> AnnData | None: """\ - using spatial location (S), tissue morphological feature (M) and gene expression (E) information to normalize data. + using spatial location (S), tissue morphological feature (M) and gene + expression (E) information to normalize data. Parameters ---------- - adata + adata: Annotated data matrix. - use_data + use_data: Input data, can be `raw` counts or log transformed data - weights + weights: Weighting matrix for imputation. - if `weights_matrix_all`, matrix combined all information from spatial location (S), - tissue morphological feature (M) and gene expression (E) - if `weights_matrix_pd_md`, matrix combined information from spatial location (S), - tissue morphological feature (M) - platform + if `weights_matrix_all`, matrix combined all information from spatial + location (S), tissue morphological feature (M) and gene expression (E) + if `weights_matrix_pd_md`, matrix combined information from spatial + location (S), tissue morphological feature (M) + platform: `Visium` or `Old_ST` - copy + copy: Return a copy instead of writing to adata. Returns ------- diff --git a/stlearn/spatials/clustering/__init__.py b/stlearn/spatials/clustering/__init__.py index be151100..7f1e86e7 100644 --- a/stlearn/spatials/clustering/__init__.py +++ b/stlearn/spatials/clustering/__init__.py @@ -1 +1,5 @@ from .localization import localization + +__all__ = [ + "localization", +] diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index 58f83f8f..aaba5d66 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -1,18 +1,18 @@ -from anndata import AnnData -from typing import Optional, Union + import numpy as np import pandas as pd -from sklearn.cluster import DBSCAN +from anndata import AnnData from natsort import natsorted +from sklearn.cluster import DBSCAN def localization( - adata: AnnData, - use_label: str = "louvain", - eps: int = 20, - min_samples: int = 0, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + eps: int = 20, + min_samples: int = 0, + copy: bool = False, +) -> AnnData | None: """\ Perform local cluster by using DBSCAN. diff --git a/stlearn/spatials/morphology/__init__.py b/stlearn/spatials/morphology/__init__.py index 115a5979..3e5b88f5 100644 --- a/stlearn/spatials/morphology/__init__.py +++ b/stlearn/spatials/morphology/__init__.py @@ -1 +1,5 @@ from .adjust import adjust + +__all__ = [ + "adjust", +] diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 1128ae1e..039ce5f9 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -1,10 +1,11 @@ -from typing import Optional + import numpy as np -from anndata import AnnData -from ..._compat import Literal import scipy.spatial as spatial +from anndata import AnnData from tqdm import tqdm +from ..._compat import Literal + _SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] @@ -16,7 +17,7 @@ def adjust( method="mean", copy: bool = False, similarity_matrix: _SIMILARITY_MATRIX = "cosine", -) -> Optional[AnnData]: +) -> AnnData | None: """\ SME normalisation: Using spot location information and tissue morphological features to correct spot gene expression diff --git a/stlearn/spatials/smooth/__init__.py b/stlearn/spatials/smooth/__init__.py index 70f1149d..3e663461 100644 --- a/stlearn/spatials/smooth/__init__.py +++ b/stlearn/spatials/smooth/__init__.py @@ -1 +1,5 @@ from .disk import disk + +__all__ = [ + "disk", +] diff --git a/stlearn/spatials/smooth/disk.py b/stlearn/spatials/smooth/disk.py index 0517b267..dabf0450 100644 --- a/stlearn/spatials/smooth/disk.py +++ b/stlearn/spatials/smooth/disk.py @@ -1,19 +1,17 @@ -from typing import Optional, Union + import numpy as np -from anndata import AnnData -import logging as logg import scipy.spatial as spatial +from anndata import AnnData def disk( - adata: AnnData, - use_data: str = "X_umap", - radius: float = 10.0, - rates: int = 1, - method: str = "mean", - copy: bool = False, -) -> Optional[AnnData]: - + adata: AnnData, + use_data: str = "X_umap", + radius: float = 10.0, + rates: int = 1, + method: str = "mean", + copy: bool = False, +) -> AnnData | None: coor = adata.obs[["imagecol", "imagerow"]] count_embed = adata.obsm[use_data] point_tree = spatial.cKDTree(coor) @@ -48,7 +46,8 @@ def disk( adata.obsm[new_embed] = np.array(lag_coor) print( - 'Disk smoothing function is applied! The new data are stored in adata.obsm["X_diffmap_disk"]' + 'Disk smoothing function is applied! The new data are stored in ' + + 'adata.obsm["X_diffmap_disk"]' ) return adata if copy else None diff --git a/stlearn/spatials/trajectory/__init__.py b/stlearn/spatials/trajectory/__init__.py index bd6c4820..f2922e65 100644 --- a/stlearn/spatials/trajectory/__init__.py +++ b/stlearn/spatials/trajectory/__init__.py @@ -1,14 +1,30 @@ +from .compare_transitions import compare_transitions +from .detect_transition_markers import ( + detect_transition_markers_branches, + detect_transition_markers_clades, +) from .global_level import global_level from .local_level import local_level from .pseudotime import pseudotime -from .weight_optimization import weight_optimizing_global, weight_optimizing_local -from .utils import lambda_dist, resistance_distance from .pseudotimespace import pseudotimespace_global, pseudotimespace_local -from .detect_transition_markers import ( - detect_transition_markers_clades, - detect_transition_markers_branches, -) -from .compare_transitions import compare_transitions - from .set_root import set_root from .shortest_path_spatial_PAGA import shortest_path_spatial_PAGA +from .utils import lambda_dist, resistance_distance +from .weight_optimization import weight_optimizing_global, weight_optimizing_local + +__all__ = [ + "global_level", + "local_level", + "pseudotime", + "weight_optimizing_global", + "weight_optimizing_local", + "lambda_dist", + "resistance_distance", + "pseudotimespace_global", + "pseudotimespace_local", + "detect_transition_markers_clades", + "detect_transition_markers_branches", + "compare_transitions", + "set_root", + "shortest_path_spatial_PAGA", +] diff --git a/stlearn/spatials/trajectory/detect_transition_markers.py b/stlearn/spatials/trajectory/detect_transition_markers.py index 56497ada..d703894b 100644 --- a/stlearn/spatials/trajectory/detect_transition_markers.py +++ b/stlearn/spatials/trajectory/detect_transition_markers.py @@ -1,20 +1,21 @@ -from scipy.stats import spearmanr +import warnings + import numpy as np import pandas as pd -import warnings -import networkx as nx +from scipy.stats import spearmanr + from ...utils import _read_graph warnings.filterwarnings("ignore", category=RuntimeWarning) def detect_transition_markers_clades( - adata, - clade, - cutoff_spearman=0.4, - cutoff_pvalue=0.05, - screening_genes=None, - use_raw_count=False, + adata, + clade, + cutoff_spearman=0.4, + cutoff_pvalue=0.05, + screening_genes=None, + use_raw_count=False, ): """\ Transition markers detection of a clade. @@ -68,7 +69,7 @@ def detect_transition_markers_clades( ) negative = spearman_result[ spearman_result["score"] <= cutoff_spearman * (-1) - ].sort_values("score") + ].sort_values("score") result = pd.concat([positive, negative]) @@ -80,12 +81,12 @@ def detect_transition_markers_clades( def detect_transition_markers_branches( - adata, - branch, - cutoff_spearman=0.4, - cutoff_pvalue=0.05, - screening_genes=None, - use_raw_count=False, + adata, + branch, + cutoff_spearman=0.4, + cutoff_pvalue=0.05, + screening_genes=None, + use_raw_count=False, ): """\ Transition markers detection of a branch. @@ -125,7 +126,7 @@ def detect_transition_markers_branches( ) negative = spearman_result[ spearman_result["score"] <= cutoff_spearman * (-1) - ].sort_values("score") + ].sort_values("score") result = pd.concat([positive, negative]) @@ -152,7 +153,7 @@ def get_rank_cor(adata, screening_genes=None, use_raw_count=True): tmp = tmp.to_df() else: tmp = adata.to_df() - if screening_genes != None: + if screening_genes is not None: tmp = tmp[screening_genes] dpt = adata.obs["dpt_pseudotime"].values genes = [] diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index 6a898238..8d7b4493 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -1,24 +1,23 @@ -from anndata import AnnData -from typing import Optional, Union -import numpy as np -import pandas as pd + import networkx as nx +import numpy as np +from anndata import AnnData from scipy.spatial.distance import cdist + from stlearn.utils import _read_graph -from sklearn.metrics import pairwise_distances def global_level( - adata: AnnData, - use_label: str = "louvain", - use_rep: str = "X_pca", - n_dims: int = 40, - list_clusters: list = [], - return_graph: bool = False, - w: float = None, - verbose: bool = True, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + use_rep: str = "X_pca", + n_dims: int = 40, + list_clusters: list = [], + return_graph: bool = False, + w: float = None, + verbose: bool = True, + copy: bool = False, +) -> AnnData | None: """\ Perform global sptial trajectory inference. @@ -51,7 +50,7 @@ def global_level( inds_cat = {v: k for (k, v) in cat_inds.items()} # Query cluster - if type(list_clusters[0]) == str: + if list_clusters[0] is str: list_clusters = [cat_inds[label] for label in list_clusters] query_nodes = list_clusters @@ -115,7 +114,8 @@ def global_level( H_sub = H.edge_subgraph(edge_list) if not nx.is_connected(H_sub.to_undirected()): raise ValueError( - "The chosen clusters are not available to construct the spatial trajectory! Please choose other path." + "The chosen clusters are not available to construct the spatial " + + "trajectory! Please choose other path." ) H_sub = nx.DiGraph(H_sub) prepare_root = [] @@ -179,11 +179,7 @@ def global_level( return H_sub -######################## -## Global level PTS ## -######################## - - +# Global level PTS def get_node(node_list, split_node): result = np.array([]) for node in node_list: @@ -201,42 +197,6 @@ def ordering_nodes(node_list, use_label, adata): return list(np.array(node_list)[np.argsort(mean_dpt)]) -# def dpt_distance_matrix(adata, cluster1, cluster2, use_label): -# tmp = adata.obs[adata.obs[use_label] == str(cluster1)] -# chosen_adata1 = adata[list(tmp.index)] -# tmp = adata.obs[adata.obs[use_label] == str(cluster2)] -# chosen_aadata = adata[list(tmp.index)] - -# sub_dpt1 = [] -# chosen_sub1 = chosen_adata1.obs["sub_cluster_labels"].unique() -# for i in range(0, len(chosen_sub1)): -# sub_dpt1.append( -# chosen_adata1.obs[ -# chosen_adata1.obs["sub_cluster_labels"] == chosen_sub1[i] -# ]["dpt_pseudotime"].median() -# ) - -# sub_dpt2 = [] -# chosen_sub2 = chosen_aadata.obs["sub_cluster_labels"].unique() -# for i in range(0, len(chosen_sub2)): -# sub_dpt2.append( -# chosen_aadata.obs[ -# chosen_aadata.obs["sub_cluster_labels"] == chosen_sub2[i] -# ]["dpt_pseudotime"].median() -# ) - -# dm = cdist( -# np.array(sub_dpt1).reshape(-1, 1), -# np.array(sub_dpt2).reshape(-1, 1), -# lambda u, v: v - u, -# ) -# from sklearn.preprocessing import MinMaxScaler -# scaler = MinMaxScaler() -# scale_dm = scaler.fit_transform(dm) -# # scale_dm = (dm + (-np.min(dm))) / np.max(dm) -# return scale_dm - - def spatial_distance_matrix(adata, cluster1, cluster2, use_label): tmp = adata.obs[adata.obs[use_label] == str(cluster1)] chosen_adata1 = adata[list(tmp.index)] @@ -258,8 +218,6 @@ def spatial_distance_matrix(adata, cluster1, cluster2, use_label): sdm = cdist(np.array(sub_coord1), np.array(sub_coord2), "euclidean") - from sklearn.preprocessing import MinMaxScaler - # scaler = MinMaxScaler() # scale_sdm = scaler.fit_transform(sdm) scale_sdm = sdm / np.max(sdm) @@ -304,15 +262,12 @@ def ge_distance_matrix(adata, cluster1, cluster2, use_label, use_rep, n_dims): results.append(cdist(sub_coord1[i], sub_coord2[j], "cosine").mean()) results = np.array(results).reshape(len(sub_coord1), len(sub_coord2)) - from sklearn.preprocessing import MinMaxScaler - # scaler = MinMaxScaler() # scale_sdm = scaler.fit_transform(results) scale_sdm = results / np.max(results) return scale_sdm - # def _density_normalize(other: Union[np.ndarray, spmatrix] # ) -> Union[np.ndarray, spmatrix]: # """ diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index 79c0b6e4..a5dacb76 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -1,20 +1,18 @@ -from anndata import AnnData -from typing import Optional, Union + import numpy as np -from stlearn.em import run_pca, run_diffmap -from stlearn.pp import neighbors +from anndata import AnnData from scipy.spatial.distance import cdist def local_level( - adata: AnnData, - use_label: str = "louvain", - cluster: int = 9, - w: float = 0.5, - return_matrix: bool = False, - verbose: bool = True, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + cluster: int = 9, + w: float = 0.5, + return_matrix: bool = False, + verbose: bool = True, + copy: bool = False, +) -> AnnData | None: """\ Perform local sptial trajectory inference (required run pseudotime first). @@ -51,8 +49,8 @@ def local_level( centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} for i in list_cluster: if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > adata.uns["threshold_spots"] + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > adata.uns["threshold_spots"] ): dpt.append( cluster_data.obs[cluster_data.obs["sub_cluster_labels"] == i][ diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 3710ee61..8ae61b74 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -1,58 +1,57 @@ -from anndata import AnnData -from typing import Optional, Union + +import networkx as nx import numpy as np import pandas as pd -import networkx as nx -from scipy.spatial.distance import cdist import scanpy +from anndata import AnnData def pseudotime( - adata: AnnData, - use_label: str = None, - eps: float = 20, - n_neighbors: int = 25, - use_rep: str = "X_pca", - threshold: float = 0.01, - radius: int = 50, - method: str = "mean", - threshold_spots: int = 5, - use_sme: bool = False, - reverse: bool = False, - pseudotime_key: str = "dpt_pseudotime", - max_nodes: int = 4, - run_knn: bool = False, - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = None, + eps: float = 20, + n_neighbors: int = 25, + use_rep: str = "X_pca", + threshold: float = 0.01, + radius: int = 50, + method: str = "mean", + threshold_spots: int = 5, + use_sme: bool = False, + reverse: bool = False, + pseudotime_key: str = "dpt_pseudotime", + max_nodes: int = 4, + run_knn: bool = False, + copy: bool = False, +) -> AnnData | None: """\ Perform pseudotime analysis. Parameters ---------- - adata + adata: Annotated data matrix. - use_label + use_label: Use label result of cluster method. - eps + eps: The maximum distance between two samples for one to be considered as in the neighborhood of the other. This is not a maximum bound on the distances of points within a cluster. This is the most important DBSCAN parameter to choose appropriately for your data set and distance function. - threshold + threshold: Threshold to find the significant connection for PAGA graph. - radius + radius: radius to adjust data for diffusion map - method + method: method to adjust the data. - use_sme + use_sme: Use adjusted feature by SME normalization or not - reverse + reverse: Reverse the pseudotime score - pseudotime_key + pseudotime_key: Key to store pseudotime - max_nodes + max_nodes: Maximum number of node in available paths - copy + copy: Return a copy instead of writing to adata. Returns ------- @@ -68,7 +67,7 @@ def pseudotime( except: pass - assert use_label != None, "Please choose the right `use_label`!" + assert use_label is not None, "Please choose the right `use_label`!" # Localize from stlearn.spatials.clustering import localization @@ -114,8 +113,8 @@ def pseudotime( "sub_cluster_labels" ].unique(): if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > threshold_spots + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > threshold_spots ): meaningful_sub.append(i) @@ -190,9 +189,7 @@ def closest_node(node, nodes): return adata if copy else None -######## utils ######## - - +# Utils def replace_with_dict(ar, dic): # Extract out keys and values k = np.array(list(dic.keys()), dtype=object) @@ -212,7 +209,6 @@ def selection_sort(x): def store_available_paths(adata, threshold, use_label, max_nodes, pseudotime_key): - # Read original PAGA graph G = nx.from_numpy_array(adata.uns["paga"]["connectivities"].toarray()) edge_weights = nx.get_edge_attributes(G, "weight") @@ -247,7 +243,6 @@ def store_available_paths(adata, threshold, use_label, max_nodes, pseudotime_key adata.uns["available_paths"] = all_paths print( - "All available trajectory paths are stored in adata.uns['available_paths'] with length < " - + str(max_nodes) - + " nodes" + "All available trajectory paths are stored in adata.uns['available_paths'] " + + "with length < " + str(max_nodes) + " nodes" ) diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index d238a428..7f362c1d 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -1,35 +1,41 @@ + from anndata import AnnData -from typing import Optional, Union -from .weight_optimization import weight_optimizing_global, weight_optimizing_local + from .global_level import global_level from .local_level import local_level +from .weight_optimization import weight_optimizing_global, weight_optimizing_local def pseudotimespace_global( - adata: AnnData, - use_label: str = "louvain", - use_rep: str = "X_pca", - n_dims: int = 40, - list_clusters: list = [], - model: str = "spatial", - step=0.01, - k=10, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + use_rep: str = "X_pca", + n_dims: int = 40, + list_clusters=None, + model: str = "spatial", + step=0.01, + k=10, +) -> AnnData | None: """\ Perform pseudo-time-space analysis with global level. Parameters ---------- - adata + adata: Annotated data matrix. - use_label + use_label: Use label result of cluster method. - list_clusters + use_rep: + Which obsm location to use. + n_dims: + Number of dimensions to use in PCA + list_clusters: List of cluster used to reconstruct spatial trajectory. - w - Weighting factor to balance between spatial data and gene expression - step - Step for screeing weighting factor + model: + Can be mixed, spatial or gene expression. spatial sets weight to 0, + gene expression sets weight to 1 and mixed uses the list_clusters, step and k. + step: + Step for screening weighting factor k The number of eigenvalues to be compared Returns @@ -37,8 +43,10 @@ def pseudotimespace_global( Anndata """ - if model == "mixed": + if list_clusters is None: + list_clusters = [] + if model == "mixed": w = weight_optimizing_global( adata, use_label=use_label, list_clusters=list_clusters, step=step, k=k ) @@ -47,8 +55,9 @@ def pseudotimespace_global( elif model == "gene_expression": w = 1 else: - raise ValidationError( - "Please choose the right model! Available models: 'mixed', 'spatial' and 'gene_expression' " + raise ValueError( + "Please choose the right model! Available models: 'mixed', 'spatial' " + + "and 'gene_expression' " ) global_level( @@ -62,29 +71,31 @@ def pseudotimespace_global( def pseudotimespace_local( - adata: AnnData, - use_label: str = "louvain", - cluster: list = [], - w: float = None, -) -> Optional[AnnData]: + adata: AnnData, + use_label: str = "louvain", + cluster=None, + w: float = None, +) -> AnnData | None: """\ Perform pseudo-time-space analysis with local level. Parameters ---------- - adata + adata: Annotated data matrix. - use_label + use_label: Use label result of cluster method. - cluster - Cluster used to reconstruct intraregional spatial trajectory. - w + cluster: + Cluster used to reconstruct intra regional spatial trajectory. + w: Weighting factor to balance between spatial data and gene expression Returns ------- Anndata """ + if cluster is None: + cluster = [] if w is None: w = weight_optimizing_local(adata, use_label=use_label, cluster=cluster) diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index 0805c0c6..88f8251e 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -1,6 +1,6 @@ -from anndata import AnnData -from typing import Optional, Union import numpy as np +from anndata import AnnData + from stlearn.spatials.trajectory.utils import _correlation_test_helper @@ -28,9 +28,9 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False # Subset the data based on the chosen cluster tmp_adata = tmp_adata[ - tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : - ] - if use_raw == True: + tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : + ] + if use_raw: tmp_adata = tmp_adata.raw.to_adata() # Borrow from Cellrank to calculate CytoTrace score diff --git a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py b/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py index bfd6b359..7d7ea2ae 100644 --- a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py +++ b/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py @@ -1,5 +1,6 @@ import networkx as nx import numpy as np + from stlearn.utils import _read_graph diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index e7ab2909..8381cc74 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -1,9 +1,18 @@ +from collections.abc import Sequence + +import networkx as nx +import numpy as np from numpy import linalg as la +from scipy import linalg as spla +from scipy import sparse as sps +from scipy.sparse import csr_matrix, issparse, isspmatrix_csr, spmatrix +from scipy.sparse import linalg as sparse_spla +from scipy.stats import norm def lambda_dist(A1, A2, k=None, p=2, kind="laplacian"): - """The function is migrated from NetComp package. The lambda distance between graphs, which is defined as - d(G1,G2) = norm(L_1 - L_2) + """The function is migrated from NetComp package. The lambda distance between + graphs, which is defined as d(G1,G2) = norm(L_1 - L_2) where L_1 is a vector of the top k eigenvalues of the appropriate matrix associated with G1, and L2 is defined similarly. Parameters @@ -52,7 +61,7 @@ def lambda_dist(A1, A2, k=None, p=2, kind="laplacian"): def resistance_distance( - A1, A2, p=2, renormalized=False, attributed=False, check_connected=True, beta=1 + A1, A2, p=2, renormalized=False, attributed=False, check_connected=True, beta=1 ): """Compare two graphs using resistance distance (possibly renormalized). Parameters @@ -99,11 +108,11 @@ def resistance_distance( ] try: distance_vector = np.sum((R1 - R2) ** p, axis=1) - except ValueError: - raise InputError( + except ValueError as e: + raise ValueError( "Input matrices are different sizes. Please use " "renormalized resistance distance." - ) + ) from e if attributed: return distance_vector ** (1 / p) else: @@ -114,20 +123,7 @@ def resistance_distance( # Eigenstuff # ********** # Functions for calculating eigenstuff of graphs. - - -from scipy import sparse as sps -import numpy as np -from scipy.sparse import linalg as spla -from numpy import linalg as la - -from scipy.sparse import issparse - -###################### -## Helper Functions ## -###################### - - +# Helper Functions def _eigs(M, which="SR", k=None): """Helper function for getting eigenstuff. Parameters @@ -155,7 +151,7 @@ def _eigs(M, which="SR", k=None): raise ValueError("which must be either 'LR' or 'SR'.") M = M.astype(float) if issparse(M) and k < n - 1: - evals, evecs = spla.eigs(M, k=k, which=which) + evals, evecs = sparse_spla.eigs(M, k=k, which=which) else: try: M = M.todense() @@ -174,11 +170,7 @@ def _eigs(M, which="SR", k=None): return np.real(evals), np.real(evecs) -##################### -## Get Eigenstuff ## -##################### - - +# Get Eigenstuff def normalized_laplacian_eig(A, k=None): """Return the eigenstuff of the normalized Laplacian matrix of graph associated with adjacency matrix A. @@ -213,9 +205,7 @@ def normalized_laplacian_eig(A, k=None): nx.normalized_laplacian_matrix """ n, m = A.shape - ## - ## TODO: implement checks on the adjacency matrix - ## + # TODO: implement checks on the adjacency matrix degs = _flat(A.sum(axis=1)) # the below will break if inv_root_degs = [d ** (-1 / 2) if d > _eps else 0 for d in degs] @@ -234,18 +224,10 @@ def normalized_laplacian_eig(A, k=None): # Matrices associated with graphs. Also contains linear algebraic helper functions. # """ - -from scipy import sparse as sps -from scipy.sparse import issparse -import numpy as np - _eps = 10 ** (-10) # a small parameter -###################### -## Helper Functions ## -###################### - +# Helper Functions def _flat(D): """Flatten column or row matrices, as well as arrays.""" if issparse(D): @@ -274,11 +256,7 @@ def _pad(A, N): return A_pad -######################## -## Matrices of Graphs ## -######################## - - +# Matrices of Graphs def degree_matrix(A): """Diagonal degree matrix of graph with adjacency matrix A Parameters @@ -338,16 +316,6 @@ class UndefinedException(Exception): # Resistance matrix. Renormalized version, as well as conductance and commute matrices. # """ -import networkx as nx -from numpy import linalg as la -from scipy import linalg as spla -import numpy as np -from scipy.sparse import issparse - -# from netcomp.linalg.matrices import laplacian_matrix -# from netcomp.exception import UndefinedException - - def resistance_matrix(A, check_connected=True): """Return the resistance matrix of G. Parameters @@ -543,37 +511,10 @@ def conductance_matrix(A): return C -######################## -## CytoTrace wrapper ## -######################## - -from typing import ( - Any, - Dict, - List, - Tuple, - Union, - TypeVar, - Hashable, - Iterable, - Optional, - Sequence, -) -import numpy as np -import pandas as pd -from pandas import Series -from scipy.stats import norm -from numpy.linalg import norm as d_norm -from scipy.sparse import eye as speye -from scipy.sparse import diags, issparse, spmatrix, csr_matrix, isspmatrix_csr -from sklearn.cluster import KMeans -from pandas.api.types import infer_dtype, is_categorical_dtype -from scipy.sparse.linalg import norm as sparse_norm - - +# CytoTrace wrapper def _mat_mat_corr_sparse( - X: csr_matrix, - Y: np.ndarray, + X: csr_matrix, + Y: np.ndarray, ) -> np.ndarray: """\ This function is borrow from cellrank @@ -581,7 +522,8 @@ def _mat_mat_corr_sparse( n = X.shape[1] X_bar = np.reshape(np.array(X.mean(axis=1)), (-1, 1)) - X_std = np.reshape(np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar**2)), (-1, 1)) + X_std = np.reshape(np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar ** 2)), + (-1, 1)) y_bar = np.reshape(np.mean(Y, axis=0), (1, -1)) y_std = np.reshape(np.std(Y, axis=0), (1, -1)) @@ -594,13 +536,13 @@ def _mat_mat_corr_sparse( def _correlation_test_helper( - X: Union[np.ndarray, spmatrix], - Y: np.ndarray, - n_perms: Optional[int] = None, - seed: Optional[int] = None, - confidence_level: float = 0.95, - **kwargs, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + X: np.ndarray | spmatrix, + Y: np.ndarray, + n_perms: int | None = None, + seed: int | None = None, + confidence_level: float = 0.95, + **kwargs, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ This function is borrow from cellrank. Compute the correlation between rows in matrix ``X`` columns of matrix ``Y``. @@ -622,13 +564,13 @@ def _correlation_test_helper( Keyword arguments for :func:`cellrank.ul._parallelize.parallelize`. Returns ------- - Correlations, p-values, corrected p-values, lower and upper bound of 95% confidence interval. - Each array if of shape ``(n_genes, n_lineages)``. + Correlations, p-values, corrected p-values, lower and upper bound of 95% + confidence interval. Each array if of shape ``(n_genes, n_lineages)``. """ def perm_test_extractor( - res: Sequence[Tuple[np.ndarray, np.ndarray]], - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + res: Sequence[tuple[np.ndarray, np.ndarray]], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: pvals, corr_bs = zip(*res) pvals = np.sum(pvals, axis=0) / float(n_perms) @@ -641,7 +583,8 @@ def perm_test_extractor( if not (0 <= confidence_level <= 1): raise ValueError( - f"Expected `confidence_level` to be in interval `[0, 1]`, found `{confidence_level}`." + "Expected `confidence_level` to be in interval `[0, 1]`, " + + f"found `{confidence_level}`." ) n = X.shape[1] # genes x cells @@ -653,7 +596,8 @@ def perm_test_extractor( corr = _mat_mat_corr_sparse(X, Y) if issparse(X) else _mat_mat_corr_dense(X, Y) - # see: https://en.wikipedia.org/wiki/Pearson_correlation_coefficient#Using_the_Fisher_transformation + # see: + # https://en.wikipedia.org/wiki/Pearson_correlation_coefficient#Using_the_Fisher_transformation mean, se = np.arctanh(corr), 1.0 / np.sqrt(n - 3) z_score = (np.arctanh(corr) - np.arctanh(0)) * np.sqrt(n - 3) diff --git a/stlearn/spatials/trajectory/weight_optimization.py b/stlearn/spatials/trajectory/weight_optimization.py index 8bc2b363..11e09e9d 100644 --- a/stlearn/spatials/trajectory/weight_optimization.py +++ b/stlearn/spatials/trajectory/weight_optimization.py @@ -1,20 +1,21 @@ +import networkx as nx import numpy as np import pandas as pd -import networkx as nx +from tqdm import tqdm + from .global_level import global_level from .local_level import local_level from .utils import lambda_dist, resistance_distance -from tqdm import tqdm def weight_optimizing_global( - adata, - use_label=None, - list_clusters=None, - step=0.01, - k=10, - use_rep="X_pca", - n_dims=40, + adata, + use_label=None, + list_clusters=None, + step=0.01, + k=10, + use_rep="X_pca", + n_dims=40, ): # Screening PTS graph print("Screening PTS global graph...") @@ -22,12 +23,11 @@ def weight_optimizing_global( j = 0 with tqdm( - total=int(1 / step + 1), - desc="Screening", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step + 1), + desc="Screening", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): - Gs.append( nx.to_scipy_sparse_array( global_level( @@ -59,9 +59,9 @@ def weight_optimizing_global( ].unique() ) with tqdm( - total=int(1 / step - 1), - desc="Calculating", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step - 1), + desc="Calculating", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(1, int(1 / step)): w += step @@ -100,12 +100,11 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): Gs = [] j = 0 with tqdm( - total=int(1 / step + 1), - desc="Screening", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step + 1), + desc="Screening", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): - Gs.append( local_level( adata, @@ -129,9 +128,9 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): w = 0 with tqdm( - total=int(1 / step - 1), - desc="Calculating", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step - 1), + desc="Calculating", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(1, int(1 / step)): w += step diff --git a/stlearn/tl.py b/stlearn/tl.py index 073ef289..3a3c0d9e 100644 --- a/stlearn/tl.py +++ b/stlearn/tl.py @@ -1,3 +1,9 @@ from .tools import clustering -from .tools.microenv import cci from .tools.label import label +from .tools.microenv import cci + +__all__ = [ + "clustering", + "cci", + "label", +] diff --git a/stlearn/tools/clustering/__init__.py b/stlearn/tools/clustering/__init__.py index 391d4b0e..d68d6df2 100644 --- a/stlearn/tools/clustering/__init__.py +++ b/stlearn/tools/clustering/__init__.py @@ -1,3 +1,9 @@ +from .annotate import annotate_interactive from .kmeans import kmeans from .louvain import louvain -from .annotate import annotate_interactive + +__all__ = [ + "kmeans", + "louvain", + "annotate_interactive", +] diff --git a/stlearn/tools/clustering/annotate.py b/stlearn/tools/clustering/annotate.py index e195351b..d1dfabf2 100644 --- a/stlearn/tools/clustering/annotate.py +++ b/stlearn/tools/clustering/annotate.py @@ -1,8 +1,9 @@ from anndata import AnnData -from stlearn.plotting.classes_bokeh import Annotate from bokeh.io import output_notebook from bokeh.plotting import show +from stlearn.plotting.classes_bokeh import Annotate + def annotate_interactive( adata: AnnData, diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tools/clustering/kmeans.py index 43d4d6da..c436e3c2 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tools/clustering/kmeans.py @@ -1,25 +1,25 @@ -from sklearn.cluster import KMeans -from anndata import AnnData -from typing import Optional, Union -import pandas as pd + import numpy as np +import pandas as pd +from anndata import AnnData from natsort import natsorted +from sklearn.cluster import KMeans def kmeans( - adata: AnnData, - n_clusters: int = 20, - use_data: str = "X_pca", - init: str = "k-means++", - n_init: int = 10, - max_iter: int = 300, - tol: float = 0.0001, - random_state: str = None, - copy_x: bool = True, - algorithm: str = "auto", - key_added: str = "kmeans", - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + n_clusters: int = 20, + use_data: str = "X_pca", + init: str = "k-means++", + n_init: int = 10, + max_iter: int = 300, + tol: float = 0.0001, + random_state: str = None, + copy_x: bool = True, + algorithm: str = "auto", + key_added: str = "kmeans", + copy: bool = False, +) -> AnnData | None: """\ Perform kmeans cluster for spatial transcriptomics data diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 8f5ea899..87d7700d 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -1,12 +1,11 @@ +from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Optional, Tuple, Sequence, Type, Mapping, Any, Union +from typing import Any -import numpy as np -import pandas as pd from anndata import AnnData -from natsort import natsorted from numpy.random.mtrand import RandomState from scipy.sparse import spmatrix + from stlearn._compat import Literal try: @@ -21,19 +20,19 @@ class MutableVertexPartition: def louvain( - adata: AnnData, - resolution: Optional[float] = None, - random_state: Optional[Union[int, RandomState]] = 0, - restrict_to: Optional[Tuple[str, Sequence[str]]] = None, - key_added: str = "louvain", - adjacency: Optional[spmatrix] = None, - flavor: Literal["vtraag", "igraph", "rapids"] = "vtraag", - directed: bool = True, - use_weights: bool = False, - partition_type: Optional[Type[MutableVertexPartition]] = None, - partition_kwargs: Mapping[str, Any] = MappingProxyType({}), - copy: bool = False, -) -> Optional[AnnData]: + adata: AnnData, + resolution: float | None = None, + random_state: int | RandomState | None = 0, + restrict_to: tuple[str, Sequence[str]] | None = None, + key_added: str = "louvain", + adjacency: spmatrix | None = None, + flavor: Literal["vtraag", "igraph", "rapids"] = "vtraag", # noqa: F821 + directed: bool = True, + use_weights: bool = False, + partition_type: type[MutableVertexPartition] | None = None, + partition_kwargs: Mapping[str, Any] = MappingProxyType({}), + copy: bool = False, +) -> AnnData | None: """\ Wrap function scanpy.tl.louvain Cluster cells into subgroups [Blondel08]_ [Levine15]_ [Traag17]_. diff --git a/stlearn/tools/label/label.py b/stlearn/tools/label/label.py index 2fb1960d..9dea9a20 100644 --- a/stlearn/tools/label/label.py +++ b/stlearn/tools/label/label.py @@ -3,18 +3,19 @@ """ import os + import numpy as np -import pandas as pd import scanpy as sc import stlearn.tools.microenv.cci.r_helpers as rhs def run_label_transfer( - st_data, sc_data, sc_label_col, r_path, st_label_col=None, n_highly_variable=2000 + st_data, sc_data, sc_label_col, r_path, st_label_col=None, + n_highly_variable=2000 ): """Runs Seurat label transfer.""" - st_label_col = sc_label_col if type(st_label_col) == type(None) else st_label_col + st_label_col = sc_label_col if st_label_col is None else st_label_col # Setting up the R environment # rhs.rpy2_setup(r_path) @@ -90,20 +91,20 @@ def run_label_transfer( def get_counts(data): """Gets count data from anndata if available.""" # Standard layer has counts # - if type(data.X) != np.ndarray and np.all(np.mod(data.X[0, :].todense(), 1) == 0): + if data.X is not np.ndarray and np.all(np.mod(data.X[0, :].todense(), 1) == 0): counts = data.to_df().transpose() - elif type(data.X) == np.ndarray and np.all(np.mod(data.X[0, :], 1) == 0): + elif data.X is np.ndarray and np.all(np.mod(data.X[0, :], 1) == 0): counts = data.to_df().transpose() elif ( - type(data.X) != np.ndarray - and hasattr(data, "raw") - and np.all(np.mod(data.raw.X[0, :].todense(), 1) == 0) + data.X is not np.ndarray + and hasattr(data, "raw") + and np.all(np.mod(data.raw.X[0, :].todense(), 1) == 0) ): counts = data.raw.to_adata()[data.obs_names, data.var_names].to_df().transpose() elif ( - type(data.X) == np.ndarray - and hasattr(data, "raw") - and np.all(np.mod(data.raw.X[0, :], 1) == 0) + data.X is np.ndarray + and hasattr(data, "raw") + and np.all(np.mod(data.raw.X[0, :], 1) == 0) ): counts = data.raw.to_adata()[data.obs_names, data.var_names].to_df().transpose() else: @@ -116,20 +117,20 @@ def get_counts(data): def run_rctd( - st_data, - sc_data, - sc_label_col, - r_path, - st_label_col=None, - n_highly_variable=5000, - min_cells=10, - doublet_mode="full", - n_cores=1, + st_data, + sc_data, + sc_label_col, + r_path, + st_label_col=None, + n_highly_variable=5000, + min_cells=10, + doublet_mode="full", + n_cores=1, ): """Runs RCTD for deconvolution.""" - st_label_col = sc_label_col if type(st_label_col) == type(None) else st_label_col + st_label_col = sc_label_col if st_label_col is None else st_label_col - ########### Setting up the R environment ############# + # Setting up the R environment rhs.rpy2_setup(r_path) # Adding the source R code # @@ -160,7 +161,7 @@ def run_rctd( sc_data.var["highly_variable"].values, st_data.var["highly_variable"].values ) - ###### Getting the count data (if available) ############ + # Getting the count data (if available) st_counts = get_counts(st_data) sc_counts = get_counts(sc_data) @@ -169,9 +170,9 @@ def run_rctd( st_coords = st_data.obs.loc[:, ["imagecol", "imagerow"]] sc_labels = sc_data.obs[sc_label_col].values.astype(str) - print(f"Finished extracting counts data.") + print("Finished extracting counts data.") - ####### Converting to R objects ######### + # Converting to R objects sc_labels_r = rhs.ro.StrVector(sc_labels) with rhs.localconverter(rhs.ro.default_converter + rhs.pandas2ri.converter): st_coords_r = rhs.ro.conversion.py2rpy(st_coords) @@ -179,7 +180,7 @@ def run_rctd( sc_counts_r = rhs.ro.conversion.py2rpy(sc_counts) print("Finished py->rpy conversion.") - ######## Running RCTD ########## + # Running RCTD print("Running RCTD...") rctd_proportions_r = rctd_r( st_counts_r, @@ -201,27 +202,27 @@ def run_rctd( st_data_orig.obs[st_label_col] = labels st_data_orig.obs[st_label_col] = st_data_orig.obs[st_label_col].astype("category") st_data_orig.uns[st_label_col] = rctd_proportions.loc[ - st_data_orig.obs_names.values, : - ] + st_data_orig.obs_names.values, : + ] print(f"Spot labels added to st_data.obs[{st_label_col}].") print(f"Spot label scores added to st_data.uns[{st_label_col}].") def run_singleR( - st_data, - sc_data, - sc_label_col, - r_path, - st_label_col=None, - n_highly_variable=5000, - n_centers=3, - de_n=200, - de_method="t", + st_data, + sc_data, + sc_label_col, + r_path, + st_label_col=None, + n_highly_variable=5000, + n_centers=3, + de_n=200, + de_method="t", ): """Runs SingleR spot annotation.""" - st_label_col = sc_label_col if type(st_label_col) == type(None) else st_label_col - ########### Setting up the R environment ############# + st_label_col = sc_label_col if st_label_col is None else st_label_col + # Setting up the R environment rhs.rpy2_setup(r_path) # Adding the source R code # @@ -253,13 +254,13 @@ def run_singleR( ) sc_data = sc_data[:, genes_bool] st_data = st_data[:, genes_bool] - print(f"Finished selecting & subsetting to hvgs.") + print("Finished selecting & subsetting to hvgs.") # Extracting the relevant information from anndatas # st_expr_df = st_data.to_df().transpose() sc_expr_df = sc_data.to_df().transpose() sc_labels = sc_data.obs[sc_label_col].values.astype(str) - print(f"Finished extracting data.") + print("Finished extracting data.") # R conversion of the data # sc_labels_r = rhs.ro.StrVector(sc_labels) diff --git a/stlearn/tools/microenv/cci/__init__.py b/stlearn/tools/microenv/cci/__init__.py index 343fa4f3..efea98c1 100644 --- a/stlearn/tools/microenv/cci/__init__.py +++ b/stlearn/tools/microenv/cci/__init__.py @@ -4,4 +4,13 @@ # from .het import edge_core, get_between_spot_edge_array # from .merge import merge # from .permutation import get_rand_pairs -from .analysis import load_lrs, grid, run, adj_pvals, run_lr_go, run_cci +from .analysis import adj_pvals, grid, load_lrs, run, run_cci, run_lr_go + +__all__ = [ + "load_lrs", + "grid", + "run", + "adj_pvals", + "run_lr_go", + "run_cci", +] diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index d6421a64..81ec454d 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -3,45 +3,48 @@ """ import os + import numba -from numba import types -from numba.typed import List import numpy as np import pandas as pd -from typing import Union from anndata import AnnData +from statsmodels.stats.multitest import multipletests from tqdm import tqdm -from .base import calc_neighbours, get_lrs_scores, calc_distance -from .permutation import perform_spot_testing + +from .base import calc_distance, calc_neighbours, get_lrs_scores from .go import run_GO from .het import ( count, - get_neighbourhoods, get_data_for_counting, get_interaction_matrix, get_interaction_pvals, + get_neighbourhoods, grid_parallel, ) -from statsmodels.stats.multitest import multipletests +from .permutation import perform_spot_testing -################################################################################ -# Functions related to Ligand-Receptor interactions # -################################################################################ -def load_lrs(names: Union[str, list, None] = None, species: str = "human") -> np.array: - """Loads inputted LR database, & concatenates into consistent database set of pairs without duplicates. If None loads 'connectomeDB2020_lit'. +# Functions related to Ligand-Receptor interactions +def load_lrs(names: str | list | None = None, species: str = "human") -> np.array: + """Loads inputted LR database, & concatenates into consistent database set of + pairs without duplicates. If None loads 'connectomeDB2020_lit'. Parameters ---------- - names: list Databases to load, options: 'connectomeDB2020_lit' (literature verified), 'connectomeDB2020_put' (putative). If more than one specified, loads all & removes duplicates. - species: str Format of the LR genes, either 'human' or 'mouse'. + names: list + Databases to load, options: 'connectomeDB2020_lit' (literature verified), + 'connectomeDB2020_put' (putative). If more than one specified, loads all & + removes duplicates. + species: str + Format of the LR genes, either 'human' or 'mouse'. Returns ------- - lrs: np.array lr pairs from the database in format ['L1_R1', 'LN_RN'] + lrs: np.array + lr pairs from the database in format ['L1_R1', 'LN_RN'] """ - if type(names) == type(None): + if names is None: names = ["connectomeDB2020_lit"] - if type(names) == str: + if names is str: names = [names] path = os.path.dirname(os.path.realpath(__file__)) @@ -103,7 +106,7 @@ def grid( print("Gridding...") # Setting threads for paralellisation # - if type(n_cpus) != type(None): + if n_cpus is not None: numba.set_num_threads(n_cpus) # Retrieving the coordinates of each grid # @@ -122,10 +125,10 @@ def grid( grid_expr = np.zeros((n_squares, adata.shape[1])) grid_coords = np.zeros((n_squares, 2)) - grid_cell_counts = np.zeros((n_squares), dtype=np.int64) - # If use_label specified, then will generate deconvolution information + grid_cell_counts = np.zeros(n_squares, dtype=np.int64) + # If use_label is specified, then it will generate deconvolution information cell_labels, cell_set, cell_info = None, None, None - if type(use_label) != type(None): + if use_label is not None: cell_labels = adata.obs[use_label].values.astype(str) cell_set = np.unique(cell_labels).astype(str) cell_info = np.zeros((n_squares, len(cell_set)), dtype=np.float64) @@ -143,7 +146,7 @@ def grid( grid_cell_counts, grid_expr, adata.X, - type(use_label) != type(None), + use_label is not None, cell_labels, cell_info, cell_set, @@ -162,7 +165,7 @@ def grid( grid_data.obsm["spatial"] = grid_coords grid_data.uns["spatial"] = adata.uns["spatial"] - if type(use_label) != type(None): + if use_label is not None: grid_data.uns[use_label] = pd.DataFrame( cell_info, index=grid_data.obs_names.values.astype(str), columns=cell_set ) @@ -177,7 +180,7 @@ def grid( # Subsetting to only gridded spots that contain cells # grid_data = grid_data[grid_data.obs["n_cells"] > 0, :].copy() - if type(use_label) != type(None): + if use_label is not None: grid_data.uns[use_label] = grid_data.uns[use_label].loc[grid_data.obs_names, :] grid_data.uns["grid_counts"] = grid_counts @@ -250,7 +253,8 @@ def run( adata.uns['lr_summary'] Summary of significant spots detected per LR, the LRs listed in the index is the same order of LRs in the columns of - results stored in adata.obsm below. Hence the order of this must be maintained. + results stored in adata.obsm below. Hence, the order of this must be + maintained. adata.obsm Additional keys are added; 'lr_scores', 'lr_sig_scores', 'p_vals', 'p_adjs', '-log10(p_adjs)'. All are numpy matrices, with columns @@ -258,10 +262,11 @@ def run( is the raw scores, while 'lr_sig_scores' is the same except only for significant scores; non-significant scores are set to zero. adata.obsm['het'] - Only if use_label specified; contains the counts of the cell types found per spot. + Only if use_label specified; contains the counts of the cell types found + per spot. """ - # Setting threads for paralellisation # - if type(n_cpus) != type(None): + # Setting threads for parallelisation + if n_cpus is not None: numba.set_num_threads(n_cpus) # Making sure none of the var_names contains '_' already, these will need @@ -306,10 +311,10 @@ def run( ) # Conduct with cell heterogeneity info if label_transfer provided # - cell_het = type(use_label) != type(None) and use_label in adata.uns.keys() + cell_het = use_label is not None and use_label in adata.uns.keys() if cell_het: if verbose: - print("Calculating cell hetereogeneity...") + print("Calculating cell heterogeneity...") # Calculating cell heterogeneity # count(adata, distance=distance, use_label=use_label, use_het=use_label) @@ -402,12 +407,12 @@ def adj_pvals( spot_padjs = multipletests(lr_ps, method=adj_method)[1] padjs[spot_indices, lr_i] = spot_padjs sig_scores[spot_indices[spot_padjs >= pval_adj_cutoff], lr_i] = 0 - elif type(correct_axis) == type(None): + elif correct_axis is None: padjs = ps.copy() sig_scores[padjs >= pval_adj_cutoff] = 0 else: raise Exception( - f"Invalid correct_axis input, must be one of: " f"'LR', 'spot', or None" + "Invalid correct_axis input, must be one of: 'LR', 'spot', or None" ) # Counting spots significant per lr # @@ -419,7 +424,7 @@ def adj_pvals( adata.uns["lr_summary"].loc[:, "n_spots_sig_pval"] = lr_counts_pval new_order = np.argsort(-adata.uns["lr_summary"].loc[:, "n_spots_sig"].values) adata.uns["lr_summary"] = adata.uns["lr_summary"].iloc[new_order, :] - print(f"Updated adata.uns[lr_summary]") + print("Updated adata.uns[lr_summary]") scores_ordered = scores[:, new_order] sig_scores_ordered = sig_scores[:, new_order] ps_ordered = ps[:, new_order] @@ -469,18 +474,19 @@ def run_lr_go( q_cutoff: float Q-value cutoff below which results will be returned. onts: str - As per clusterProfiler; One of "BP", "MF", and "CC" subontologies, or "ALL" for all three. + As per clusterProfiler; One of "BP", "MF", and "CC" subontologies, or "ALL" + for all three. Returns ------- adata: AnnData Relevant information stored in adata.uns['lr_go'] """ - #### Making sure inputted correct species #### + # Making sure inputted correct species all_species = ["human", "mouse"] if species not in all_species: raise Exception(f"Got {species} for species, must be one of " f"{all_species}") - #### Getting the genes from the top LR pairs #### + # Getting the genes from the top LR pairs if "lr_summary" not in adata.uns: raise Exception("Need to run st.tl.cci.run first.") lrs = adata.uns["lr_summary"].index.values.astype(str) @@ -488,12 +494,12 @@ def run_lr_go( top_lrs = lrs[n_sig > min_sig_spots][0:n_top] top_genes = np.unique([lr.split("_") for lr in top_lrs]) - ## Determining the background genes if not inputted ## - if type(bg_genes) == type(None): + # Determining the background genes if not inputted + if bg_genes is None: all_lrs = load_lrs("connectomeDB2020_put") bg_genes = np.unique([lr_.split("_") for lr_ in all_lrs]) - #### Running the GO analysis #### + # Running the GO analysis go_results = run_GO( top_genes, bg_genes, @@ -508,9 +514,7 @@ def run_lr_go( print("GO results saved to adata.uns['lr_go']") -################################################################################ -# Functions for calling Celltype-Celltype interactions # -################################################################################ +# Functions for calling Celltype-Celltype interactions def run_cci( adata: AnnData, use_label: str, @@ -523,7 +527,8 @@ def run_cci( n_cpus: int = 1, verbose: bool = True, ): - """Calls significant celltype-celltype interactions based on cell-type data randomisation. + """Calls significant celltype-celltype interactions based on cell-type data + randomisation. Parameters ---------- @@ -591,7 +596,7 @@ def run_cci( subsetted to significant CCIs. """ # Setting threads for paralellisation # - if type(n_cpus) != type(None): + if n_cpus is not None: numba.set_num_threads(n_cpus) ran_lr = "lr_summary" in adata.uns @@ -639,12 +644,12 @@ def run_cci( msg = msg + "Rows do not correspond to adata.obs_names.\n" raise Exception(msg) - #### Checking for case where have cell types that are never dominant - #### in a spot, so need to include these in all_set + # Checking for case where have cell types that are never dominant + # in a spot, so need to include these in all_set if len(all_set) < adata.uns[uns_key].shape[1]: all_set = adata.uns[uns_key].columns.values.astype(str) - #### Getting minimum necessary information for edge counting #### + # Getting minimum necessary information for edge counting if verbose: print("Getting cached neighbourhood information...") # Getting the neighbourhoods # @@ -676,20 +681,21 @@ def run_cci( per_lr_cci = {} # Per LR significant CCI counts # per_lr_cci_pvals = {} # Per LR CCI p-values # per_lr_cci_raw = {} # Per LR raw CCI counts # - lr_n_spot_cci = np.zeros((lr_summary.shape[0])) - lr_n_spot_cci_sig = np.zeros((lr_summary.shape[0])) - lr_n_cci_sig = np.zeros((lr_summary.shape[0])) + lr_n_spot_cci = np.zeros(lr_summary.shape[0]) + lr_n_spot_cci_sig = np.zeros(lr_summary.shape[0]) + lr_n_cci_sig = np.zeros(lr_summary.shape[0]) with tqdm( total=len(best_lrs), - desc=f"Counting celltype-celltype interactions per LR and permutating {n_perms} times.", + desc="Counting celltype-celltype interactions per LR and permuting " + + f"{n_perms} times.", bar_format="{l_bar}{bar} [ time left: {remaining} ]", - disable=verbose == False, + disable=verbose is False, ) as pbar: for i, best_lr in enumerate(best_lrs): - l, r = best_lr.split("_") + ligand, receptor = best_lr.split("_") - L_bool = lr_expr.loc[:, l].values > 0 - R_bool = lr_expr.loc[:, r].values > 0 + L_bool = lr_expr.loc[:, ligand].values > 0 + R_bool = lr_expr.loc[:, receptor].values > 0 lr_index = np.where(adata.uns["lr_summary"].index.values == best_lr)[0][0] sig_bool = adata.obsm[col][:, lr_index] > 0 diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index ecea7ecc..6f88750e 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -1,36 +1,47 @@ import numpy as np import pandas as pd import scipy as sc -from numba import njit, prange -from numba.typed import List import scipy.spatial as spatial from anndata import AnnData +from numba import njit, prange +from numba.typed import List + from .het import create_grids def lr( - adata: AnnData, - use_lr: str = "cci_lr", - distance: float = None, - verbose: bool = True, - neighbours: list = None, - fast: bool = True, + adata: AnnData, + use_lr: str = "cci_lr", + distance: float = None, + verbose: bool = True, + neighbours: list = None, + fast: bool = True, ) -> AnnData: - """Calculate the proportion of known ligand-receptor co-expression among the neighbouring spots or within spots + """Calculate the proportion of known ligand-receptor co-expression among the + neighbouring spots or within spots Parameters ---------- - adata: AnnData The data object to scan - use_lr: str object to keep the result (default: adata.uns['cci_lr']) - distance: float Distance to determine the neighbours (default: closest), distance=0 means within spot - neighbours: list List of the neighbours for each spot, if None then computed. Useful for speeding up function. - fast: bool Whether to use the fast implimentation or not. + adata: AnnData + The data object to scan + use_lr: str + object to keep the result (default: adata.uns['cci_lr']) + distance: float + Distance to determine the neighbours (default: closest), distance=0 means + within spot + neighbours: list + List of the neighbours for each spot, if None then computed. Useful for + speeding up function. + fast: bool + Whether to use the fast implementation or not. Returns ------- - adata: AnnData The data object including the results + adata: AnnData + The data object including the results """ - # automatically calculate distance if not given, won't overwrite distance=0 which is within-spot + # automatically calculate distance if not given, won't overwrite distance=0 + # which is within-spot distance = calc_distance(adata, distance) # # expand the LR pairs list by swapping ligand-receptor positions @@ -41,7 +52,7 @@ def lr( print("Altogether " + str(spot_lr1.shape[1]) + " valid L-R pairs") # get neighbour spots for each spot according to the specified distance - if type(neighbours) == type(None): + if neighbours is None: neighbours = calc_neighbours(adata, distance, index=fast) # Calculating the scores, can have either the fast or the pandas version # @@ -65,51 +76,64 @@ def calc_distance(adata: AnnData, distance: float): distance=0 which is within-spot. Parameters ---------- - adata: AnnData The data object to scan - distance: float Distance to determine the neighbours (default: closest), distance=0 means within spot + adata: AnnData + The data object to scan + distance: float + Distance to determine the neighbours (default: closest), distance=0 means + within spot Returns ------- - distance: float The automatically calcualted distance (or inputted distance) + distance: float + The automatically calcualted distance (or inputted distance) """ if not distance and distance != 0: # for arranged-spots scalefactors = next(iter(adata.uns["spatial"].values()))["scalefactors"] library_id = list(adata.uns["spatial"].keys())[0] distance = ( - scalefactors["spot_diameter_fullres"] - * scalefactors[ - "tissue_" + adata.uns["spatial"][library_id]["use_quality"] + "_scalef" - ] - * 2 + scalefactors["spot_diameter_fullres"] + * scalefactors[ + "tissue_" + adata.uns["spatial"][library_id][ + "use_quality"] + "_scalef" + ] + * 2 ) return distance def get_lrs_scores( - adata: AnnData, - lrs: np.array, - neighbours: np.array, - het_vals: np.array, - min_expr: float, - filter_pairs: bool = True, - spot_indices: np.array = None, + adata: AnnData, + lrs: np.array, + neighbours: np.array, + het_vals: np.array, + min_expr: float, + filter_pairs: bool = True, + spot_indices: np.array = None, ): """Gets the scores for the indicated set of LR pairs & the heterogeneity values. Parameters ---------- - adata: AnnData See run() doc-string. - lrs: np.array See run() doc-string. - neighbours: np.array Array of arrays with indices specifying neighbours of each spot. - het_vals: np.array Cell heterogeneity counts per spot. - min_expr: float Minimum gene expression of either L or R for spot to be considered to have reasonable score. - filter_pairs: bool Whether to filter to valid pairs or not. - spot_indices: np.array Array of integers speci + adata: AnnData + See run() doc-string. + lrs: np.array + See run() doc-string. + neighbours: np.array + Array of arrays with indices specifying neighbours of each spot. + het_vals: np.array + Cell heterogeneity counts per spot. + min_expr: float + Minimum gene expression of either L or R for spot to be considered to + have reasonable score. + filter_pairs: bool + Whether to filter to valid pairs or not. + spot_indices: np.array + Array of integers speci Returns ------- lrs: np.array lr pairs from the database in format ['L1_R1', 'LN_RN'] """ - if type(spot_indices) == type(None): + if spot_indices is None: spot_indices = np.array(list(range(len(adata))), dtype=np.int32) spot_lr1s = get_spot_lrs( @@ -121,7 +145,7 @@ def get_lrs_scores( if filter_pairs: lrs = np.array( [ - "_".join(spot_lr1s.columns.values[i : i + 2]) + "_".join(spot_lr1s.columns.values[i: i + 2]) for i in range(0, spot_lr1s.shape[1], 2) ] ) @@ -138,22 +162,28 @@ def get_lrs_scores( def get_spot_lrs( - adata: AnnData, - lr_pairs: list, - lr_order: bool, - filter_pairs: bool = True, + adata: AnnData, + lr_pairs: list, + lr_order: bool, + filter_pairs: bool = True, ): """ Parameters ---------- - adata: AnnData The adata object to scan - lr_pairs: list List of the lr pairs (e.g. ['L1_R1', 'L2_R2',...] - lr_order: bool Forward version of the spot lr pairs (L1_R1), False indicates reverse (R1_L1) - filter_pairs: bool Whether to filter the pairs or not (check if present before subsetting). + adata (AnnData): + The adata object to scan + lr_pairs (list): + List of the lr pairs (e.g. ['L1_R1', 'L2_R2',...] + lr_order (bool): + Forward version of the spot lr pairs (L1_R1), False indicates reverse (R1_L1) + filter_pairs (bool): + Whether to filter the pairs or not (check if present before sub-setting). Returns ------- - spot_lrs: pd.DataFrame Spots*GeneOrder, in format l1, r1, ... ln, rn if lr_order True, else r1, l1, ... rn, ln + spot_lrs: pd.DataFrame + Spots*GeneOrder, in format l1, r1, ... ln, rn if lr_order True, else r1, + l1, ... rn, ln """ df = adata.to_df() pairs_rev = [f'{pair.split("_")[1]}_{pair.split("_")[0]}' for pair in lr_pairs] @@ -168,27 +198,36 @@ def get_spot_lrs( if lr.split("_")[0] in df.columns and lr.split("_")[1] in df.columns ] - lr_cols = [pair.split("_")[int(lr_order == False)] for pair in pairs_wRev] + lr_cols = [pair.split("_")[int(lr_order is False)] for pair in pairs_wRev] spot_lrs = df[lr_cols] return spot_lrs def calc_neighbours( - adata: AnnData, - distance: float = None, - index: bool = True, - verbose: bool = True, + adata: AnnData, + distance: float = None, + index: bool = True, + verbose: bool = True, ) -> List: - """Calculate the proportion of known ligand-receptor co-expression among the neighbouring spots or within spots + """Calculate the proportion of known ligand-receptor co-expression among the + neighbouring spots or within spots Parameters ---------- - adata: AnnData The data object to scan - distance: float Distance to determine the neighbours (default: closest), distance=0 means within spot - index: bool Indicates whether to return neighbours as indices to other spots or names of other spots. + adata (AnnData): + The data object to scan + distance (float): + Distance to determine the neighbours (default: closest), distance=0 means + within spot + index (bool): + Indicates whether to return neighbours as indices to other spots or names of + other spots. + verbose (bool): + Display debugging information Returns ------- - neighbours: numba.typed.List List of np.array's indicating neighbours by indices for each spot. + neighbours (numba.typed.List): + List of np.array's indicating neighbours by indices for each spot. """ if verbose: print("Calculating neighbours...") @@ -219,7 +258,7 @@ def calc_neighbours( n_neighs = np.array([len(neigh) for neigh in neighbours]) if verbose: print( - f"{len(np.where(n_neighs==0)[0])} spots with no neighbours, " + f"{len(np.where(n_neighs == 0)[0])} spots with no neighbours, " f"{int(np.median(n_neighs))} median spot neighbours." ) @@ -234,22 +273,27 @@ def calc_neighbours( @njit def lr_core( - spot_lr1: np.ndarray, - spot_lr2: np.ndarray, - neighbours: List, - min_expr: float, - spot_indices: np.array, + spot_lr1: np.ndarray, + spot_lr2: np.ndarray, + neighbours: List, + min_expr: float, + spot_indices: np.array, ) -> np.ndarray: """Calculate the lr scores for each spot. Parameters ---------- - spot_lr1: np.ndarray Spots*Ligands - spot_lr2: np.ndarray Spots*Receptors - neighbours: numba.typed.List List of np.array's indicating neighbours by indices for each spot. - min_expr: float Minimum expression for gene to be considered expressed. + spot_lr1: np.ndarray + Spots*Ligands + spot_lr2: np.ndarray + Spots*Receptors + neighbours: numba.typed.List + List of np.array's indicating neighbours by indices for each spot. + min_expr: float + Minimum expression for gene to be considered expressed. Returns ------- - lr_scores: numpy.ndarray Cells*LR-scores. + lr_scores: numpy.ndarray + Cells*LR-scores. """ # Calculating mean of lr2 expressions from neighbours of each spot nb_lr2 = np.zeros((len(spot_indices), spot_lr2.shape[1]), np.float64) @@ -263,30 +307,35 @@ def lr_core( nb_lr2[i, :] = nb_expr_mean scores = ( - spot_lr1[spot_indices, :] * (nb_lr2 > min_expr) - + (spot_lr1[spot_indices, :] > min_expr) * nb_lr2 + spot_lr1[spot_indices, :] * (nb_lr2 > min_expr) + + (spot_lr1[spot_indices, :] > min_expr) * nb_lr2 ) spot_lr = scores.sum(axis=1) return spot_lr / 2 def lr_pandas( - spot_lr1: np.ndarray, - spot_lr2: np.ndarray, - neighbours: list, + spot_lr1: np.ndarray, + spot_lr2: np.ndarray, + neighbours: list, ) -> np.ndarray: """Calculate the lr scores for each spot. Parameters ---------- - spot_lr1: pd.DataFrame Cells*Ligands - spot_lr2: pd.DataFrame Cells*Receptors - neighbours: list List of neighbours by indices for each spot. + spot_lr1 (pd.DataFrame): + Cells*Ligands + spot_lr2 (pd.DataFrame): + Cells*Receptors + neighbours (list): + List of neighbours by indices for each spot. Returns ------- - lr_scores: numpy.ndarray Cells*LR-scores. + lr_scores (numpy.ndarray): + Cells*LR-scores. """ - # function to calculate mean of lr2 expression between neighbours or within spot (distance==0) for each spot + # function to calculate mean of lr2 expression between neighbours or within + # spot (distance==0) for each spot def mean_lr2(x): # get lr2 expressions from the neighbour(s) n_spots = neighbours[spot_lr2.index.tolist().index(x.name)] @@ -314,29 +363,35 @@ def mean_lr2(x): @njit(parallel=True) def get_scores( - spot_lr1s: np.ndarray, - spot_lr2s: np.ndarray, - neighbours: List, - het_vals: np.array, - min_expr: float, - spot_indices: np.array, + spot_lr1s: np.ndarray, + spot_lr2s: np.ndarray, + neighbours: List, + het_vals: np.array, + min_expr: float, + spot_indices: np.array, ) -> np.array: """Calculates the scores. Parameters ---------- - spot_lr1s: np.ndarray Spots*GeneOrder1, in format l1, r1, ... ln, rn - spot_lr2s: np.ndarray Spots*GeneOrder2, in format r1, l1, ... rn, ln - het_vals: np.ndarray Spots*Het counts - neighbours: numba.typed.List List of np.array's indicating neighbours by indices for each spot. - min_expr: float Minimum expression for gene to be considered expressed. + spot_lr1s: np.ndarray + Spots*GeneOrder1, in format l1, r1, ... ln, rn + spot_lr2s: np.ndarray + Spots*GeneOrder2, in format r1, l1, ... rn, ln + het_vals: np.ndarray + Spots*Het counts + neighbours: numba.typed.List + List of np.array's indicating neighbours by indices for each spot. + min_expr: float + Minimum expression for gene to be considered expressed. Returns ------- - spot_scores: np.ndarray Spots*LR pair of the LR scores per spot. + spot_scores: np.ndarray + Spots*LR pair of the LR scores per spot. """ spot_scores = np.zeros((len(spot_indices), spot_lr1s.shape[1] // 2), np.float64) for i in prange(0, spot_lr1s.shape[1] // 2): i_ = i * 2 # equivalent to range(0, spot_lr1s.shape[1], 2) - spot_lr1, spot_lr2 = spot_lr1s[:, i_ : (i_ + 2)], spot_lr2s[:, i_ : (i_ + 2)] + spot_lr1, spot_lr2 = spot_lr1s[:, i_: (i_ + 2)], spot_lr2s[:, i_: (i_ + 2)] lr_scores = lr_core(spot_lr1, spot_lr2, neighbours, min_expr, spot_indices) # The merge scores # lr_scores = np.multiply(het_vals[spot_indices], lr_scores) @@ -345,25 +400,33 @@ def get_scores( def lr_grid( - adata: AnnData, - num_row: int = 10, - num_col: int = 10, - use_lr: str = "cci_lr_grid", - radius: int = 1, - verbose: bool = True, + adata: AnnData, + num_row: int = 10, + num_col: int = 10, + use_lr: str = "cci_lr_grid", + radius: int = 1, + verbose: bool = True, ) -> AnnData: - """Calculate the proportion of known ligand-receptor co-expression among the neighbouring grids or within each grid + """Calculate the proportion of known ligand-receptor co-expression among the + neighbouring grids or within each grid Parameters ---------- - adata: AnnData The data object to scan - num_row: int Number of grids on height - num_col: int Number of grids on width - use_lr: str object to keep the result (default: adata.uns['cci_lr']) - radius: int Distance to determine the neighbour grids (default: 1=nearest), radius=0 means within grid + adata: AnnData + The data object to scan + num_row: int + Number of grids on height + num_col: int + Number of grids on width + use_lr: str + object to keep the result (default: adata.uns['cci_lr']) + radius: int + Distance to determine the neighbour grids (default: 1=nearest), + radius=0 means within grid Returns ------- - adata: AnnData The data object with the cci_lr grid result updated + adata: AnnData + The data object with the cci_lr grid result updated """ # prepare data as pd.dataframe @@ -388,7 +451,7 @@ def lr_grid( & (coor["imagecol"] < grid[0] + width) & (coor["imagerow"] < grid[1]) & (coor["imagerow"] > grid[1] - height) - ] + ] df_grid.loc[n] = df.loc[spots.index].sum() # expand the LR pairs list by swapping ligand-receptor positions @@ -406,7 +469,8 @@ def lr_grid( if verbose: print("Altogether " + str(len(avail)) + " valid L-R pairs") - # function to calculate mean of lr2 expression between neighbours or within spot (distance==0) for each spot + # function to calculate mean of lr2 expression between neighbours or within spot + # (distance==0) for each spot def mean_lr2(x): # get the neighbour(s)' lr2 expressions nbs = grid_lr2.loc[neighbours[df_grid.index.tolist().index(x.name)], :] diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index 882bb011..b8a05e7a 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -2,38 +2,49 @@ similar tissues. """ -from stlearn.pl import het_plot -from sklearn.cluster import DBSCAN, AgglomerativeClustering -from anndata import AnnData -from tqdm import tqdm +import matplotlib.pyplot as plt import numpy as np import pandas as pd -import matplotlib.pyplot as plt import seaborn as sb +from anndata import AnnData +from sklearn.cluster import DBSCAN, AgglomerativeClustering +from tqdm import tqdm + +from stlearn.pl import het_plot def get_hotspots( - adata: AnnData, - lr_scores: np.ndarray, - lrs: np.array, - eps: float, - quantile=0.05, - verbose=True, - plot_diagnostics: bool = False, - show_plot: bool = False, + adata: AnnData, + lr_scores: np.ndarray, + lrs: np.array, + eps: float, + quantile=0.05, + verbose=True, + plot_diagnostics: bool = False, + show_plot: bool = False, ): - """Determines the hotspots for the inputted scores by progressively setting more stringent cutoffs & cluster in space, chooses point which maximises number of clusters. + """Determines the hotspots for the inputted scores by progressively setting + more stringent cutoffs & cluster in space, chooses point which maximises number + of clusters. Parameters ---------- - adata: AnnData The data object - lr_scores: np.ndarray LR_pair*Spots containing the LR scores. - lrs: np.array The LR_pairs, in-line with the rows of scores. - eps: float The eps parameter used in DBScan to get the number of clusters. - quantile: float The quantiles to use for the cutoffs, if 0.05 then will take non-zero quantiles of 0.05, 0.1,..., 1 quantiles to cluster. + adata: AnnData + The data object + lr_scores: np.ndarray + LR_pair*Spots containing the LR scores. + lrs: np.array + The LR_pairs, in-line with the rows of scores. + eps: float + The eps parameter used in DBScan to get the number of clusters. + quantile: float + The quantiles to use for the cutoffs, if 0.05 then will take non-zero + quantiles of 0.05, 0.1,..., 1 quantiles to cluster. Returns ------- - lr_hot_scores: np.ndarray, lr_cutoffs: np.array First is the LR scores for just the hotspots, second is the cutoff used to get those LR_scores. + lr_hot_scores: np.ndarray, lr_cutoffs: np.array + First is the LR scores for just the hotspots, second is the cutoff used to + get those LR_scores. """ coors = adata.obs[["imagerow", "imagecol"]].values lr_summary, lr_hot_scores = hotspot_core( @@ -107,26 +118,25 @@ def get_hotspots( adata.obsm["cluster_scores"] = cluster_scores if verbose: - print(f"\tSummary values of lrs in adata.uns['lr_summary'].") + print("\tSummary values of lrs in adata.uns['lr_summary'].") print( - f"\tMatrix of lr scores in same order as the summary in adata.obsm['lr_scores']." - ) - print(f"\tMatrix of the hotspot scores in adata.obsm['lr_hot_scores'].") - print( - f"\tMatrix of the mean LR cluster scores in adata.obsm['cluster_scores']." + "\tMatrix of lr scores in same order as the summary in " + + "adata.obsm['lr_scores']." ) + print("\tMatrix of the hotspot scores in adata.obsm['lr_hot_scores'].") + print("\tMatrix of the mean LR cluster scores in adata.obsm['cluster_scores'].") def hotspot_core( - lr_scores, - lrs, - coors, - eps, - quantile, - plot_diagnostics=False, - adata=None, - verbose=True, - max_score=False, + lr_scores, + lrs, + coors, + eps, + quantile, + plot_diagnostics=False, + adata=None, + verbose=True, + max_score=False, ): """Made code for getting the hotspot information.""" score_copy = lr_scores.copy() @@ -137,7 +147,7 @@ def hotspot_core( # cols: spot_counts, cutoff, hotspot_counts, lr_cluster lr_summary = np.zeros((score_copy.shape[0], 4)) - ### Also creating grouping lr_pairs by quantiles to plot diagnostics ### + # Also creating grouping lr_pairs by quantiles to plot diagnostics if plot_diagnostics: lr_quantiles = [(i / 6) for i in range(1, 7)][::-1] lr_mean_scores = np.apply_along_axis(non_zero_mean, 1, score_copy) @@ -149,10 +159,10 @@ def hotspot_core( # Determining the cutoffs for hotspots # with tqdm( - total=len(lrs), - desc="Removing background lr scores...", - bar_format="{l_bar}{bar}", - disable=verbose == False, + total=len(lrs), + desc="Removing background lr scores...", + bar_format="{l_bar}{bar}", + disable=verbose is False, ) as pbar: for i, lr_ in enumerate(lrs): lr_score_ = score_copy[i, :] @@ -185,7 +195,7 @@ def hotspot_core( lr_summary[i, 2] = len(np.where(lr_score_ > 0)[0]) # Adding the diagnostic plots # - if plot_diagnostics and lr_ in quant_lrs and type(adata) != type(None): + if plot_diagnostics and lr_ in quant_lrs and adata is not None: add_diagnostic_plots( adata, i, @@ -211,17 +221,17 @@ def non_zero_mean(vals): def add_diagnostic_plots( - adata, - i, - lr_, - quant_lrs, - lr_quantiles, - lr_scores, - lr_hot_scores, - axes, - cutoffs, - n_clusters, - best_cutoff, + adata, + i, + lr_, + quant_lrs, + lr_quantiles, + lr_scores, + lr_hot_scores, + axes, + cutoffs, + n_clusters, + best_cutoff, ): """Adds diagnostic plots for the quantile LR pair to a figure to illustrate \ how the cutoff is functioning. @@ -230,7 +240,7 @@ def add_diagnostic_plots( # Scatter plot # axes[q_i][0].scatter(cutoffs, n_clusters) - axes[q_i][0].set_title(f"n_clusts*mean_spot_score vs cutoff") + axes[q_i][0].set_title("n_clusts*mean_spot_score vs cutoff") axes[q_i][0].set_xlabel("cutoffs") axes[q_i][0].set_ylabel("n_clusts*mean_spot_score") diff --git a/stlearn/tools/microenv/cci/go.py b/stlearn/tools/microenv/cci/go.py index eff77d09..ee7b98fe 100644 --- a/stlearn/tools/microenv/cci/go.py +++ b/stlearn/tools/microenv/cci/go.py @@ -1,6 +1,7 @@ """Wrapper for performing the LR GO analysis.""" import os + import stlearn.tools.microenv.cci.r_helpers as rhs @@ -20,7 +21,7 @@ def run_GO(genes, bg_genes, species, r_path, p_cutoff=0.01, q_cutoff=0.5, onts=" # Running the function on the genes # genes_r = rhs.ro.StrVector(genes) - if type(bg_genes) != type(None): + if bg_genes is not None: bg_genes_r = rhs.ro.StrVector(bg_genes) else: bg_genes_r = rhs.ro.r["as.null"]() diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index bc6fb221..7b3a7bdf 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -1,16 +1,15 @@ import numpy as np import pandas as pd -from anndata import AnnData import scipy.spatial as spatial - +from anndata import AnnData +from numba import jit, njit, prange from numba.typed import List -from numba import njit, jit, prange from stlearn.tools.microenv.cci.het_helpers import ( + add_unique_edges, edge_core, get_between_spot_edge_array, get_data_for_counting, - add_unique_edges, get_neighbourhoods, init_edge_list, ) @@ -26,20 +25,28 @@ def count( """Count the cell type densities Parameters ---------- - adata: AnnData The data object including the cell types to count - use_label: The cell type results to use in counting - use_het: The stoarge place for result - distance: int Distance to determine the neighbours (default is the nearest neighbour), distance=0 means within spot + adata: AnnData + The data object including the cell types to count + use_label: + The cell type results to use in counting + use_het: + The storage place for result + distance: int + Distance to determine the neighbours (default is the nearest neighbour), + distance=0 means within spot Returns ------- - adata: AnnData With the counts of specified clusters in nearby spots stored as adata.uns['het'] + adata: AnnData + With the counts of specified clusters in nearby spots stored as + adata.uns['het'] """ library_id = list(adata.uns["spatial"].keys())[0] # between spot if distance != 0: - # automatically calculate distance if not given, won't overwrite distance=0 which is within-spot + # automatically calculate distance if not given, won't overwrite distance=0 + # which is within-spot if not distance: # calculate default neighbour distance scalefactors = next(iter(adata.uns["spatial"].values()))["scalefactors"] @@ -92,10 +99,15 @@ def get_edges(adata: AnnData, L_bool: np.array, R_bool: np.array, sig_bool: np.a Parameters ---------- - adata: AnnData - L_bool: np.array len(L_bool)==len(adata), True if ligand expressed in that spot. - R_bool: np.array len(R_bool)==len(adata), True if receptor expressed in that spot. - sig_bool np.array: len(sig_bool)==len(adata), True if spot has significant LR interactions. + adata : AnnData + Annotated data object containing spatial transcriptomics data. + L_bool : np.ndarray of bool, shape (n_spots,) + Boolean array indicating spots where the ligand is expressed. + R_bool : np.ndarray of bool, shape (n_spots,) + Boolean array indicating spots where the receptor is expressed. + sig_bool : np.ndarray of bool, shape (n_spots,) + Boolean array indicating spots with significant ligand-receptor interactions. + Returns ------- edge_list_unique: list> Either a list of tuples (directed), or @@ -266,7 +278,8 @@ def get_interaction_matrix( # 1) sig spot with ligand, only neighbours with receptor relevant # 2) sig spot with receptor, only neighbours with ligand relevant # NOTE, A<->B is double counted, but on different side of matrix. - # (if bidirectional interaction between two spots, counts as two seperate interactions). + # (if bidirectional interaction between two spots, counts as two seperate + # interactions). LR_edges = get_interactions( cell_data, neighbourhood_bcs, @@ -446,13 +459,15 @@ def count_grid( adata: AnnData The data object including the cell types to count num_row: int Number of grids on height num_col: int Number of grids on width - use_label: The cell type results to use in counting - use_het: The stoarge place for result - radius: int Distance to determine the neighbour grids (default: 1=nearest), radius=0 means within grid + use_label: The cell type results to use in counting + use_het: The storage place for result + radius: int Distance to determine the neighbour grids + (default: 1=nearest), radius=0 means within grid Returns ------- - adata: AnnData With the counts of specified clusters in each grid of the tissue stored as adata.uns['het'] + adata (AnnData): With the counts of specified clusters in each grid of the + tissue stored as adata.uns['het'] """ coor = adata.obs[["imagerow", "imagecol"]] diff --git a/stlearn/tools/microenv/cci/het_helpers.py b/stlearn/tools/microenv/cci/het_helpers.py index 270e811c..c1b76f9b 100644 --- a/stlearn/tools/microenv/cci/het_helpers.py +++ b/stlearn/tools/microenv/cci/het_helpers.py @@ -3,21 +3,19 @@ """ import numpy as np -import numba -from numba import types +from numba import njit from numba.typed import List -from numba import njit, jit @njit def edge_core( - cell_data: np.ndarray, - cell_type_index: int, - neighbourhood_bcs: List, - neighbourhood_indices: List, - spot_indices: np.array = None, - neigh_bool: np.array = None, - cutoff: float = 0.2, + cell_data: np.ndarray, + cell_type_index: int, + neighbourhood_bcs: List, + neighbourhood_indices: List, + spot_indices: np.array = None, + neigh_bool: np.array = None, + cutoff: float = 0.2, ) -> np.array: """Gets the edges which connect inputted spots to neighbours of a given cell type. @@ -32,7 +30,7 @@ def edge_core( cell_type_index: int Column of cell_data that contains the \ cell type of interest. - neighbourhood_bcs: List List of lists, inner list for each \ + neighbourhood_bcs (List): List of lists, inner list for each \ spot. First element of inner list is \ spot barcode, second element is array \ of neighbourhood spot barcodes. @@ -77,7 +75,7 @@ def edge_core( elif len(spot_indices) == 0: return edge_list[1:] - ### Within-spot mode + # Within-spot mode # within-spot, will have only itself as a neighbour in this mode within_mode = edge_list[0][0] == edge_list[0][1] if within_mode: @@ -86,7 +84,7 @@ def edge_core( if neigh_bool[i] and cell_data[i] > cutoff: edge_list.append((neighbourhood_bcs[i][0], neighbourhood_bcs[i][1][0])) - ### Between-spot mode + # Between-spot mode else: # Subsetting the neighbourhoods to relevant spots # neighbourhood_bcs_sub = List() @@ -130,12 +128,12 @@ def init_edge_list(neighbourhood_bcs): @njit def get_between_spot_edge_array( - edge_list: List, - neighbourhood_bcs: List, - neighbourhood_indices: List, - neigh_bool: np.array, - cell_data: np.array, - cutoff: float = 0, + edge_list: List, + neighbourhood_bcs: List, + neighbourhood_indices: List, + neigh_bool: np.array, + cell_data: np.array, + cutoff: float = 0, ): """ Populates edge_list with edges linking spots with a valid neighbour \ of a given cell type. Validity of neighbour determined by neigh_bool, \ @@ -186,7 +184,7 @@ def add_unique_edges(edge_list, edge_starts, edge_ends): edge_startj, edge_endj = edge_starts[j], edge_ends[j] # Direction doesn't matter # if (edge_start == edge_startj and edge_end == edge_endj) or ( - edge_end == edge_startj and edge_start == edge_endj + edge_end == edge_startj and edge_start == edge_endj ): edge_added[j] = True @@ -263,12 +261,12 @@ def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): # @njit def get_neighbourhoods_FAST( - spot_bcs: np.array, - spot_neigh_bcs: np.ndarray, - n_spots: int, - str_dtype: str, - neigh_indices: np.array, - neigh_bcs: np.array, + spot_bcs: np.array, + spot_neigh_bcs: np.ndarray, + n_spots: int, + str_dtype: str, + neigh_indices: np.array, + neigh_bcs: np.array, ): """Gets the neighbourhood information, njit compiled.""" @@ -277,12 +275,12 @@ def get_neighbourhoods_FAST( # neighbourhood_bcs = List((numba.int64, numba.int64[:])) # neighbourhood_indices = List( (types.unicode_type, types.unicode_type[:]) ) - ### Numba version + # Numba version # neighbours = List([neigh_indices])[1:] # neighbourhood_bcs = List() # neighbourhood_indices = List([(0, neigh_indices)])[1:] - #### Trying normal lists + # Trying normal lists neighbours, neighbourhood_bcs, neighbourhood_indices = [], [], [] for i in range(spot_neigh_bcs.shape[0]): @@ -297,12 +295,10 @@ def get_neighbourhoods_FAST( # neigh_bcs_array = np.empty(len(neigh_bcs_sub), dtype=str_dtype) # neigh_indices = np.zeros((len(neigh_bcs_sub)), dtype=np.int64) neigh_bcs_array, neigh_indices = [], [] - neigh_bcs_sub = List() for j, neigh_bc in enumerate(neigh_bcs): bc_indices = np.where(spot_bcs == neigh_bc)[0] if len(bc_indices) > 0: - neigh_bcs_array.append(neigh_bc) neigh_indices.append(bc_indices[0]) @@ -351,12 +347,12 @@ def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): def get_neighbourhoods_FAST( - spot_bcs: np.array, - spot_neigh_bcs: np.ndarray, - n_spots: int, - str_dtype: str, - neigh_indices: np.array, - neigh_bcs: np.array, + spot_bcs: np.array, + spot_neigh_bcs: np.ndarray, + n_spots: int, + str_dtype: str, + neigh_indices: np.array, + neigh_bcs: np.array, ): """Gets the neighbourhood information, njit compiled.""" @@ -368,7 +364,6 @@ def get_neighbourhoods_FAST( neigh_bcs = neigh_bcs[neigh_bcs != ""] neigh_bcs_array, neigh_indices = [], [] - neigh_bcs_sub = List() for j, neigh_bc in enumerate(neigh_bcs): bc_indices = np.where(spot_bcs == neigh_bc)[0] @@ -391,7 +386,7 @@ def get_neighbourhoods(adata): # Old stlearn version where didn't store neighbourhood barcodes, not good # for anndata subsetting!! - if not "spot_neigh_bcs" in adata.obsm: + if "spot_neigh_bcs" not in adata.obsm: # Determining the neighbour spots used for significance testing # neighbours = List() for i in range(adata.obsm["spot_neighbours"].shape[0]): diff --git a/stlearn/tools/microenv/cci/merge.py b/stlearn/tools/microenv/cci/merge.py index 6f25908b..15017fe1 100644 --- a/stlearn/tools/microenv/cci/merge.py +++ b/stlearn/tools/microenv/cci/merge.py @@ -1,13 +1,12 @@ import numpy as np -import pandas as pd from anndata import AnnData def merge( - adata: AnnData, - use_lr: str = "cci_lr", - use_het: str = "cci_het", - verbose: bool = True, + adata: AnnData, + use_lr: str = "cci_lr", + use_het: str = "cci_het", + verbose: bool = True, ) -> AnnData: """Merge results from cell type heterogeneity and L-R cluster Parameters @@ -25,7 +24,8 @@ def merge( if verbose: print( - "Results of spatial interaction analysis has been written to adata.uns['merged']" + "Results of spatial interaction analysis has been written to " + + "adata.uns['merged']" ) return adata diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 083ae1ef..b7d9ab61 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -1,10 +1,9 @@ import numpy as np import pandas as pd -from scipy.spatial.distance import euclidean, canberra -from sklearn.preprocessing import MinMaxScaler - from numba import njit, prange from numba.typed import List +from scipy.spatial.distance import canberra +from sklearn.preprocessing import MinMaxScaler from .base import get_lrs_scores @@ -13,7 +12,7 @@ def nonzero_quantile(expr, q, interpolation): """Calculating the non-zero quantiles.""" nonzero_expr = expr[expr > 0] quants = np.quantile(nonzero_expr, q=q, interpolation=interpolation) - if type(quants) != np.array and type(quants) != np.ndarray: + if quants is not np.array and quants is not np.ndarray: quants = np.array([quants]) return quants @@ -36,7 +35,9 @@ def get_lr_quants( """Gets the quantiles per gene in the LR pair, & then concatenates. Returns ------- - lr_quants, l_quants, r_quants: np.ndarray First is concatenation of two latter. Each row is a quantile value, each column is a LR pair. + lr_quants, l_quants, r_quants (np.ndarray): First is concatenation of two latter. + Each row is a quantile value, each + column is an LR pair. """ quant_func = nonzero_quantile if method != "quantiles" else np.quantile @@ -58,7 +59,9 @@ def get_lr_zeroprops(lr_expr: pd.DataFrame, l_indices: list, r_indices: list): """Gets the proportion of zeros per gene in the LR pair, & then concatenates. Returns ------- - lr_props, l_props, r_props: np.ndarray First is concatenation of two latter. Each row is a prop value, each column is a LR pair. + lr_props, l_props, r_props (np.ndarray): First is concatenation of two latter. + Each row is a prop value, each column + is an LR pair. """ # First getting the quantiles of gene expression # @@ -76,7 +79,8 @@ def get_lr_bounds(lr_value: float, bin_bounds: np.array): """For the given lr_value, returns the bin where it belongs. Returns ------- - lr_bin: tuple Tuple of length 2, first is the lower bound of the bin, second is upper bound of the bin. + lr_bin (tuple): Tuple of length 2, first is the lower bound of the bin, second + is upper bound of the bin. """ if np.any(bin_bounds == lr_value): # If sits on a boundary lr_i = np.where(bin_bounds == lr_value)[0][0] @@ -105,17 +109,17 @@ def get_similar_genes( by measuring distance between the gene expression quantiles. Parameters ---------- - ref_quants: np.array The pre-calculated quantiles. - ref_props: np.array The query zero proportions. - n_genes: int Number of equivalent genes to select. + ref_quants: np.array The pre-calculated quantiles. + ref_props: np.array The query zero proportions. + n_genes: int Number of equivalent genes to select. candidate_expr: np.ndarray Expression of gene candidates (cells*genes). candidate_genes: np.array Same as candidate_expr.shape[1], indicating gene names. - quantiles: tuple The quantile to use + quantiles: tuple The quantile to use Returns ------- similar_genes: np.array Array of strings for gene names. """ - if type(quantiles) == float: + if quantiles is float: quantiles = np.array([quantiles]) else: quantiles = np.array(quantiles) @@ -168,17 +172,21 @@ def get_similar_genes_Quantiles( by measuring distance between the gene expression quantiles. Parameters ---------- - gene_expr: np.array Expression of the gene of interest, or, if the same length as quantiles, then assumes is the pre-calculated quantiles. - n_genes: int Number of equivalent genes to select. - candidate_quants: np.ndarray Expression quantiles of gene candidates (quantiles*genes). - candidate_genes: np.array Same as candidate_expr.shape[1], indicating gene names. - quantiles: tuple The quantile to use + gene_expr: np.array Expression of the gene of interest, or, if the + same length as quantiles, then assumes is the + pre-calculated quantiles. + n_genes: int Number of equivalent genes to select. + candidate_quants: np.ndarray Expression quantiles of gene candidates + (quantiles*genes). + candidate_genes: np.array Same as candidate_expr.shape[1], indicating gene + names. + quantiles: tuple The quantile to use Returns ------- similar_genes: np.array Array of strings for gene names. """ - if type(quantiles) == float: + if quantiles is float: quantiles = np.array([quantiles]) else: quantiles = np.array(quantiles) @@ -295,7 +303,7 @@ def get_lr_features(adata, lr_expr, lrs, quantiles): # Calculating the zero proportions, for grouping based on median/zeros # lr_props, l_props, r_props = get_lr_zeroprops(lr_expr, l_indices, r_indices) - ######## Getting lr features for later diagnostics ####### + # Getting lr features for later diagnostics lr_meds, l_meds, r_meds = get_lr_quants( lr_expr, l_indices, r_indices, quantiles=np.array([0.5]), method="" ) diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 6ca6ce12..6abebe31 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -1,34 +1,38 @@ -import sys, os, random, scipy +import os +import random +import sys + import numpy as np import pandas as pd -from numba.typed import List +import scipy import statsmodels.api as sm +from anndata import AnnData +from numba.typed import List +from sklearn.cluster import AgglomerativeClustering from statsmodels.stats.multitest import multipletests - from tqdm import tqdm -from sklearn.cluster import AgglomerativeClustering -from anndata import AnnData -from .base import lr, calc_neighbours, get_spot_lrs, get_lrs_scores, get_scores +from .base import calc_neighbours, get_lrs_scores, get_scores, get_spot_lrs, lr from .merge import merge -from .perm_utils import get_lr_features, get_lr_bg +from .perm_utils import get_lr_bg, get_lr_features # Newest method # def perform_spot_testing( - adata: AnnData, - lr_scores: np.ndarray, - lrs: np.array, - n_pairs: int, - neighbours: List, - het_vals: np.array, - min_expr: float, - adj_method: str = "fdr_bh", - pval_adj_cutoff: float = 0.05, - verbose: bool = True, - save_bg=False, - neg_binom=False, - quantiles=(0.5, 0.75, 0.85, 0.9, 0.95, 0.97, 0.98, 0.99, 0.995, 0.9975, 0.999, 1), + adata: AnnData, + lr_scores: np.ndarray, + lrs: np.array, + n_pairs: int, + neighbours: List, + het_vals: np.array, + min_expr: float, + adj_method: str = "fdr_bh", + pval_adj_cutoff: float = 0.05, + verbose: bool = True, + save_bg=False, + neg_binom=False, + quantiles=( + 0.5, 0.75, 0.85, 0.9, 0.95, 0.97, 0.98, 0.99, 0.995, 0.9975, 0.999, 1), ): """Calls significant spots by creating random gene pairs with similar expression to given LR pair; only generate background for spots @@ -55,7 +59,7 @@ def perform_spot_testing( ) return - ####### Quantiles to select similar gene to LRs to gen. rand-pairs ####### + # Quantiles to select similar gene to LRs to gen. rand-pairs lr_expr = adata[:, lr_genes].to_df() lr_feats = get_lr_features(adata, lr_expr, lrs, quantiles) l_quants = lr_feats.loc[ @@ -72,7 +76,7 @@ def perform_spot_testing( r_quants = r_quants.astype(" AnnData: """Permutation test for merged result Parameters @@ -356,16 +361,23 @@ def permutation( adata: AnnData The data object including the cell types to count n_pairs: int Number of gene pairs to run permutation test (default: 1000) distance: int Distance between spots (default: 30) - use_lr: str LR cluster used for permutation test (default: 'lr_neighbours_louvain_max') - use_het: str cell type diversity counts used for permutation test (default 'het') - neg_binom: bool Whether to fit neg binomial paramaters to bg distribution for p-val est. - adj_method: str Method used by statsmodels.stats.multitest.multipletests for MHT correction. - neighbours: list List of the neighbours for each spot, if None then computed. Useful for speeding up function. + use_lr: str LR cluster used for permutation test + (default: 'lr_neighbours_louvain_max') + use_het: str cell type diversity counts used for permutation test + (default 'het') + neg_binom: bool Whether to fit neg binomial parameters to bg distribution + for p-val est. + adj_method: str Method used by statsmodels.stats.multitest.multipletests + for MHT correction. + neighbours: list List of the neighbours for each spot, if None then + computed. Useful for speeding up function. **kwargs: Extra arguments parsed to lr. Returns ------- - adata: AnnData Data Frame of p-values from permutation test for each window stored in adata.uns['merged_pvalues'] - Final significant merged scores stored in adata.uns['merged_sign'] + adata: AnnData Data Frame of p-values from permutation test for each + window stored in adata.uns['merged_pvalues'] + Final significant merged scores stored in + adata.uns['merged_sign'] """ # blockPrint() @@ -374,7 +386,7 @@ def permutation( genes = get_valid_genes(adata, n_pairs) if len(adata.uns["lr"]) > 1: raise ValueError("Permutation test only supported for one LR pair scenario.") - elif type(bg_pairs) == type(None): + elif bg_pairs is None: pairs = get_rand_pairs(adata, genes, n_pairs, lrs=adata.uns["lr"]) else: pairs = bg_pairs @@ -383,11 +395,13 @@ def permutation( # generate random pairs lr1 = adata.uns['lr'][0].split('_')[0] lr2 = adata.uns['lr'][0].split('_')[1] - genes = [item for item in adata.var_names.tolist() if not (item.startswith('MT-') or item.startswith('MT_') or item==lr1 or item==lr2)] + genes = [item for item in adata.var_names.tolist() if not + (item.startswith('MT-') or item.startswith('MT_') or + item==lr1 or item==lr2)] random.shuffle(genes) pairs = [i + '_' + j for i, j in zip(genes[:n_pairs], genes[-n_pairs:])] """ - if use_het != None: + if use_het is not None: scores = adata.obsm["merged"] else: scores = adata.obsm[use_lr] @@ -396,12 +410,11 @@ def permutation( query_pair = adata.uns["lr"] # If neighbours not inputted, then compute # - if type(neighbours) == type(None): + if neighbours is None: neighbours = calc_neighbours(adata, distance, index=run_fast) - if not run_fast and type(background) == type( - None - ): # Run original way if 'fast'=False argument inputted. + if not run_fast and background is None: + # Run original way if 'fast'=False argument inputted. background = [] for item in pairs: adata.uns["lr"] = [item] @@ -413,19 +426,19 @@ def permutation( neighbours=neighbours, **kwargs, ) - if use_het != None: + if use_het is not None: merge(adata, use_lr=use_lr, use_het=use_het, verbose=False) background += adata.obsm["merged"].tolist() else: background += adata.obsm[use_lr].tolist() background = np.array(background) - elif type(background) == type(None): # Run fast if background not inputted + elif background is None: # Run fast if background not inputted spot_lr1s = get_spot_lrs(adata, pairs, lr_order=True, filter_pairs=False) spot_lr2s = get_spot_lrs(adata, pairs, lr_order=False, filter_pairs=False) het_vals = ( - np.array([1] * len(adata)) if use_het == None else adata.obsm[use_het] + np.array([1] * len(adata)) if use_het is None else adata.obsm[use_het] ) background = get_scores( spot_lr1s.values, spot_lr2s.values, neighbours, het_vals @@ -434,12 +447,12 @@ def permutation( # log back the original query adata.uns["lr"] = query_pair - #### Negative Binomial fit + # Negative Binomial fit pvals, pvals_adj, log10_pvals, lr_sign = get_stats( scores, background, neg_binom, adj_method ) - if use_het != None: + if use_het is not None: adata.obsm["merged"] = scores adata.obsm["merged_pvalues"] = log10_pvals adata.obsm["merged_sign"] = lr_sign @@ -463,13 +476,13 @@ def permutation( def get_stats( - scores: np.array, - background: np.array, - total_bg: int, - neg_binom: bool = False, - adj_method: str = "fdr_bh", - pval_adj_cutoff: float = 0.01, - return_negbinom_params: bool = False, + scores: np.array, + background: np.array, + total_bg: int, + neg_binom: bool = False, + adj_method: str = "fdr_bh", + pval_adj_cutoff: float = 0.01, + return_negbinom_params: bool = False, ): """Retrieves valid candidate genes to be used for random gene pairs. Parameters @@ -477,19 +490,23 @@ def get_stats( scores: np.array Per spot scores for a particular LR pair. background: np.array Background distribution for non-zero scores. total_bg: int Total number of background values calculated. - neg_binom: bool Whether to use neg-binomial distribution to estimate p-values, NOT appropriate with log1p data, alternative is to use background distribution itself (recommend higher number of n_pairs for this). - adj_method: str Parsed to statsmodels.stats.multitest.multipletests for multiple hypothesis testing correction. + neg_binom: bool Whether to use neg-binomial distribution to estimate + p-values, NOT appropriate with log1p data, alternative is + to use background distribution itself (recommend higher + number of n_pairs for this). + adj_method: str Parsed to statsmodels.stats.multitest.multipletests for + multiple hypothesis testing correction. Returns ------- - stats: tuple Per spot pvalues, pvals_adj, log10_pvals_adj, lr_sign (the LR scores for significant spots). + stats: tuple Per spot pvalues, pvals_adj, log10_pvals_adj, lr_sign + (the LR scores for significant spots). """ - ##### Negative Binomial fit + # Negative Binomial fit if neg_binom: # Need to make full background for fitting !!! background = np.array(list(background) + [0] * (total_bg - len(background))) - pmin, pmax = min(background), max(background) + pmin = min(background) background2 = [item - pmin for item in background] - x = np.linspace(pmin, pmax, 1000) res = sm.NegativeBinomial( background2, np.ones(len(background2)), loglike_method="nb2" ).fit(start_params=[0.1, 0.3], disp=0) @@ -497,7 +514,7 @@ def get_stats( mu = np.exp(res.params[0]) alpha = res.params[1] Q = 0 - size = 1.0 / alpha * mu**Q + size = 1.0 / alpha * mu ** Q prob = size / (size + mu) if return_negbinom_params: # For testing purposes # @@ -506,11 +523,12 @@ def get_stats( # Calculate probability for all spots pvals = 1 - scipy.stats.nbinom.cdf(scores - pmin, size, prob) - else: ###### Using the actual values to estimate p-values + else: + # Using the actual values to estimate p-values pvals = np.zeros((1, len(scores)), dtype=np.float)[0, :] nonzero_score_bool = scores > 0 nonzero_score_indices = np.where(nonzero_score_bool)[0] - zero_score_indices = np.where(nonzero_score_bool == False)[0] + zero_score_indices = np.where(nonzero_score_bool is False)[0] pvals[zero_score_indices] = (total_bg - len(background)) / total_bg pvals[nonzero_score_indices] = [ len(np.where(background >= scores[i])[0]) / total_bg @@ -557,31 +575,33 @@ def get_valid_genes(adata: AnnData, n_pairs: int) -> np.array: def get_rand_pairs( - adata: AnnData, - genes: np.array, - n_pairs: int, - lrs: list = None, - im: int = None, + adata: AnnData, + genes: np.array, + n_pairs: int, + lrs: list = None, + im: int = None, ): """Gets equivalent random gene pairs for the inputted lr pair. Parameters ---------- - adata: AnnData The data object including the cell types to count - lr: int The lr pair string to get equivalent random pairs for (e.g. 'L_R') - genes: np.array Candidate genes to use as pairs. - n_pairs: int Number of random pairs to generate. + adata (AnnData): The data object including the cell types to count + genes (np.array): Candidate genes to use as pairs. + n_pairs (int): Number of random pairs to generate. + lr (int): The lr pair string to get equivalent random pairs + for (e.g. 'L_R') Returns ------- - pairs: list List of random gene pairs with equivalent mean expression (e.g. ['L_R']) + pairs (list) List of random gene pairs with equivalent mean expression + (e.g. ['L_R']) """ lr_genes = [lr.split("_")[0] for lr in lrs] lr_genes += [lr.split("_")[1] for lr in lrs] # get the position of the median of the means between the two genes means_ordered, genes_ordered = get_ordered(adata, genes) - if type(im) == type(None): # Single background per lr pair mode - l, r = lrs[0].split("_") - im = get_median_index(l, r, means_ordered.values, genes_ordered) + if im is None: # Single background per lr pair mode + ligand, receptor = lrs[0].split("_") + im = get_median_index(ligand, receptor, means_ordered.values, genes_ordered) # get n_pair genes sorted by distance to im selected = ( @@ -590,7 +610,7 @@ def get_rand_pairs( .drop(lr_genes)[: n_pairs * 2] .index.tolist() ) - selected = selected[0 : n_pairs * 2] + selected = selected[0: n_pairs * 2] adata.uns["selected"] = selected # form gene pairs from selected randomly random.shuffle(selected) @@ -605,21 +625,23 @@ def get_ordered(adata, genes): return means_ordered, genes_ordered -def get_median_index(l, r, means_ordered, genes_ordered): - """ "Retrieves the index of the gene with a mean expression between the two genes in the lr pair. +def get_median_index(ligand, receptor, means_ordered, genes_ordered): + """ Retrieves the index of the gene with a mean expression between the two genes + in the lr pair. Parameters ---------- - X: np.ndarray Spot*Gene expression. - l: str Ligand gene. - r: str Receptor gene. - genes: np.array Candidate genes to use as pairs. + ligand: Ligand gene. + receptor: Receptor gene. + genes_ordered: + means_ordered: Returns ------- - pairs: list List of random gene pairs with equivalent mean expression (e.g. ['L_R']) + pairs (list): List of random gene pairs with equivalent mean expression + (e.g. ['L_R']) """ # sort the mean of each gene expression - i1 = np.where(genes_ordered == l)[0][0] - i2 = np.where(genes_ordered == r)[0][0] + i1 = np.where(genes_ordered == ligand)[0][0] + i2 = np.where(genes_ordered == receptor)[0][0] if means_ordered[i1] > means_ordered[i2]: it = i1 i1 = i2 diff --git a/stlearn/utils.py b/stlearn/utils.py index ba9e1280..b658fe26 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -1,13 +1,10 @@ -import numpy as np -from anndata import AnnData -import networkx as nx - -from typing import Optional, Union, Mapping # Special -from typing import Tuple # Classes - +from collections.abc import Mapping +from enum import Enum from textwrap import dedent -from enum import Enum +import networkx as nx +import numpy as np +from anndata import AnnData from matplotlib import axes from matplotlib.axes import Axes @@ -24,7 +21,7 @@ class _AxesSubplot(Axes, axes.SubplotBase): def _check_spot_size( - spatial_data: Optional[Mapping], spot_size: Optional[float] + spatial_data: Mapping | None, spot_size: float | None ) -> float: """ Resolve spot_size value. @@ -42,9 +39,9 @@ def _check_spot_size( def _check_scale_factor( - spatial_data: Optional[Mapping], - img_key: Optional[str], - scale_factor: Optional[float], + spatial_data: Mapping | None, + img_key: str | None, + scale_factor: float | None, ) -> float: """Resolve scale_factor, defaults to 1.""" if scale_factor is not None: @@ -56,8 +53,8 @@ def _check_scale_factor( def _check_spatial_data( - uns: Mapping, library_id: Union[Empty, None, str] -) -> Tuple[Optional[str], Optional[Mapping]]: + uns: Mapping, library_id: Empty | None | str +) -> tuple[str | None, Mapping | None]: """ Given a mapping, try and extract a library id/ mapping with spatial data. Assumes this is `.uns` from how we parse visium data. @@ -81,11 +78,11 @@ def _check_spatial_data( def _check_img( - spatial_data: Optional[Mapping], - img: Optional[np.ndarray], - img_key: Union[None, str, Empty], + spatial_data: Mapping | None, + img: np.ndarray | None, + img_key: None | str | Empty, bw: bool = False, -) -> Tuple[Optional[np.ndarray], Optional[str]]: +) -> tuple[np.ndarray | None, str | None]: """ Resolve image for spatial plots. """ @@ -101,8 +98,8 @@ def _check_img( def _check_coords( - obsm: Optional[Mapping], scale_factor: Optional[float] -) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + obsm: Mapping | None, scale_factor: float | None +) -> tuple[np.ndarray | None, np.ndarray | None]: image_coor = obsm["spatial"] * scale_factor imagecol = image_coor[:, 0] imagerow = image_coor[:, 1] @@ -110,7 +107,7 @@ def _check_coords( return [imagecol, imagerow] -def _read_graph(adata: AnnData, graph_type: Optional[str]): +def _read_graph(adata: AnnData, graph_type: str | None): if graph_type == "PTS_graph": graph = nx.from_scipy_sparse_array( adata.uns[graph_type]["graph"], create_using=nx.DiGraph diff --git a/stlearn/wrapper/concatenate_spatial_adata.py b/stlearn/wrapper/concatenate_spatial_adata.py index c5d1ae07..cc9273d7 100644 --- a/stlearn/wrapper/concatenate_spatial_adata.py +++ b/stlearn/wrapper/concatenate_spatial_adata.py @@ -121,7 +121,7 @@ def concatenate_spatial_adata(adata_list, ncols=2, fixed_size=(2000, 2000)): for min_id in range(0, len(use_adata_list), ncols): img_row = np.hstack(imgs[min_id : min_id + ncols]) img_rows.append(img_row) - imgs_comb = np.vstack((i for i in img_rows)) + imgs_comb = np.vstack(i for i in img_rows) adata_concat = use_adata_list[0].concatenate(use_adata_list[1:]) adata_concat.uns["spatial"] = use_adata_list[0].uns["spatial"] diff --git a/stlearn/wrapper/convert_scanpy.py b/stlearn/wrapper/convert_scanpy.py index e384f0a0..2f11e047 100644 --- a/stlearn/wrapper/convert_scanpy.py +++ b/stlearn/wrapper/convert_scanpy.py @@ -1,11 +1,11 @@ -from typing import Optional + from anndata import AnnData def convert_scanpy( adata: AnnData, use_quality: str = "hires", -) -> Optional[AnnData]: +) -> AnnData | None: adata.var_names_make_unique() diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 9db3dc25..030e7d82 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -1,31 +1,33 @@ """Reading and Writing""" +import json +import logging as logg from pathlib import Path -from typing import Optional, Union -from anndata import AnnData + +import matplotlib.pyplot as plt import numpy as np -from PIL import Image import pandas as pd -import stlearn -from .._compat import Literal import scanpy -import matplotlib.pyplot as plt +from anndata import AnnData from matplotlib.image import imread -import json -import logging as logg +from PIL import Image + +import stlearn + +from .._compat import Literal _QUALITY = Literal["fulres", "hires", "lowres"] _background = ["black", "white"] def Read10X( - path: Union[str, Path], - genome: Optional[str] = None, + path: str | Path, + genome: str | None = None, count_file: str = "filtered_feature_bc_matrix.h5", library_id: str = None, - load_images: Optional[bool] = True, + load_images: bool | None = True, quality: _QUALITY = "hires", - image_path: Union[str, Path] = None, + image_path: str | Path = None, ) -> AnnData: """\ Read Visium data from 10X (wrap read_visium from scanpy) @@ -35,23 +37,26 @@ def Read10X( coordinates and scale factors. Based on the `Space Ranger output docs`_. - .. _Space Ranger output docs: https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/output/overview + _Space Ranger output docs: + https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/output/overview Parameters ---------- path - Path to directory for visium datafiles. + The path to directory for Visium datafiles. genome Filter expression to genes within this genome. count_file - Which file in the passed directory to use as the count file. Typically would be one of: - 'filtered_feature_bc_matrix.h5' or 'raw_feature_bc_matrix.h5'. + Which file in the directory to use as the count file. Typically, it would be one + of: 'filtered_feature_bc_matrix.h5' or 'raw_feature_bc_matrix.h5'. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the Visium library. Can be modified when concatenating multiple + adata objects. load_images Load image or not. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] image_path Path to image. Only need when loading full resolution image. @@ -200,9 +205,9 @@ def Read10X( def ReadOldST( - count_matrix_file: Union[str, Path] = None, - spatial_file: Union[str, Path] = None, - image_file: Union[str, Path] = None, + count_matrix_file: str | Path = None, + spatial_file: str | Path = None, + image_file: str | Path = None, library_id: str = "OldST", scale: float = 1.0, quality: str = "hires", @@ -216,15 +221,17 @@ def ReadOldST( count_matrix_file Path to count matrix file. spatial_file - Path to spatial location file. + Path to the spatial location file. image_file Path to the tissue image file library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the Visium library. Can be modified when concatenating multiple + adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] spot_diameter_fullres Diameter of spot in full resolution @@ -248,8 +255,8 @@ def ReadOldST( def ReadSlideSeq( - count_matrix_file: Union[str, Path], - spatial_file: Union[str, Path], + count_matrix_file: str | Path, + spatial_file: str | Path, library_id: str = None, scale: float = None, quality: str = "hires", @@ -264,17 +271,19 @@ def ReadSlideSeq( count_matrix_file Path to count matrix file. spatial_file - Path to spatial location file. + Path to the spatial location file. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the Visium library. Can be modified when concatenating + multiple adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] spot_diameter_fullres Diameter of spot in full resolution background_color - Color of the backgound. Only `black` or `white` is allowed. + Color of the background. Only `black` or `white` is allowed. Returns ------- @@ -290,7 +299,7 @@ def ReadSlideSeq( adata.obs["index"] = meta["index"].values - if scale == None: + if scale is None: max_coor = np.max(meta[["x", "y"]].values) scale = 2000 / max_coor @@ -329,8 +338,8 @@ def ReadSlideSeq( def ReadMERFISH( - count_matrix_file: Union[str, Path], - spatial_file: Union[str, Path], + count_matrix_file: str | Path, + spatial_file: str | Path, library_id: str = None, scale: float = None, quality: str = "hires", @@ -345,17 +354,19 @@ def ReadMERFISH( count_matrix_file Path to count matrix file. spatial_file - Path to spatial location file. + Path to the spatial location file. library_id - Identifier for the visium library. Can be modified when concatenating multiple adata objects. + Identifier for the Visium library. Can be modified when concatenating + multiple adata objects. scale Set scale factor. quality - Set quality that convert to stlearn to use. Store in anndata.obs['imagecol' & 'imagerow'] + Set quality that convert to stlearn to use. Store in + anndata.obs['imagecol' & 'imagerow'] spot_diameter_fullres Diameter of spot in full resolution background_color - Color of the backgound. Only `black` or `white` is allowed. + Color of the background. Only `black` or `white` is allowed. Returns ------- @@ -410,8 +421,8 @@ def ReadMERFISH( def ReadSeqFish( - count_matrix_file: Union[str, Path], - spatial_file: Union[str, Path], + count_matrix_file: str | Path, + spatial_file: str | Path, library_id: str = None, scale: float = 1.0, quality: str = "hires", @@ -441,7 +452,7 @@ def ReadSeqFish( spot_diameter_fullres Diameter of spot in full resolution background_color - Color of the backgound. Only `black` or `white` is allowed. + Color of the background. Only `black` or `white` is allowed. Returns ------- AnnData @@ -498,9 +509,9 @@ def ReadSeqFish( def ReadXenium( - feature_cell_matrix_file: Union[str, Path], - cell_summary_file: Union[str, Path], - image_path: Optional[Path] = None, + feature_cell_matrix_file: str | Path, + cell_summary_file: str | Path, + image_path: Path | None = None, library_id: str = None, scale: float = 1.0, quality: str = "hires", @@ -529,7 +540,7 @@ def ReadXenium( spot_diameter_fullres Diameter of spot in full resolution background_color - Color of the backgound. Only `black` or `white` is allowed. + Color of the background. Only `black` or `white` is allowed. Returns ------- AnnData @@ -594,7 +605,7 @@ def create_stlearn( count: pd.DataFrame, spatial: pd.DataFrame, library_id: str, - image_path: Optional[Path] = None, + image_path: Path | None = None, scale: float = None, quality: str = "hires", spot_diameter_fullres: float = 50, @@ -620,7 +631,7 @@ def create_stlearn( spot_diameter_fullres Diameter of spot in full resolution background_color - Color of the backgound. Only `black` or `white` is allowed. + Color of the background. Only `black` or `white` is allowed. Returns ------- AnnData diff --git a/tests/test_CCI.py b/tests/test_CCI.py index e31e887f..720f0181 100644 --- a/tests/test_CCI.py +++ b/tests/test_CCI.py @@ -8,10 +8,9 @@ from numba.typed import List import stlearn as st -from tests.utils import read_test_data - -import stlearn.tools.microenv.cci.het_helpers as het_hs import stlearn.tools.microenv.cci.het as het +import stlearn.tools.microenv.cci.het_helpers as het_hs +from tests.utils import read_test_data global adata adata = read_test_data() diff --git a/tests/test_PSTS.py b/tests/test_PSTS.py index 1a6b7676..21089a6a 100644 --- a/tests/test_PSTS.py +++ b/tests/test_PSTS.py @@ -5,10 +5,12 @@ import unittest -import stlearn as st +import numpy as np import scanpy as sc + +import stlearn as st + from .utils import read_test_data -import numpy as np global adata adata = read_test_data() diff --git a/tests/test_SME.py b/tests/test_SME.py index 96eec81f..a1f38200 100644 --- a/tests/test_SME.py +++ b/tests/test_SME.py @@ -5,8 +5,10 @@ import unittest -import stlearn as st import scanpy as sc + +import stlearn as st + from .utils import read_test_data global adata diff --git a/tests/utils.py b/tests/utils.py index 6a5cc78f..98482f96 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,8 @@ import os + +import numpy as np import scanpy as sc from PIL import Image -import numpy as np def read_test_data(): diff --git a/tox.ini b/tox.ini index 473ed981..76e1b229 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] requires = tox>=4 -env_list = lint, type, 3.1{3,2,1,0}, flake8 +env_list = lint, type, 3.1{3,2,1,0}, ruff [testenv:lint] description = run linters @@ -17,11 +17,13 @@ deps = commands = mypy {posargs:stlearn tests} -[testenv:flake8] -description = run flake8 linting +[testenv:ruff] +description = run ruff linting and formatting skip_install = true -deps = flake8 -commands = flake8 stlearn tests +deps = ruff +commands = + ruff check stlearn tests + ruff format --check stlearn tests [testenv] setenv = @@ -29,8 +31,3 @@ setenv = deps = pytest commands = pytest {posargs} - -[flake8] -max-line-length = 88 -extend-ignore = E203, W503 -exclude = .git,__pycache__,build,dist \ No newline at end of file From 27e081de90a6179409b0403fe51b3914a64c2d76 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 09:10:37 +1000 Subject: [PATCH 008/123] More style issues. --- CONTRIBUTING.rst | 2 +- stlearn/_settings.py | 62 +-- stlearn/adds/add_labels.py | 12 +- stlearn/adds/add_lr.py | 12 +- stlearn/adds/annotation.py | 1 - stlearn/app/source/forms/forms.py | 17 +- stlearn/app/source/forms/utils.py | 1 + stlearn/app/source/forms/view_helpers.py | 1 - stlearn/classes.py | 1 - stlearn/em.py | 1 - stlearn/embedding/diffmap.py | 4 +- stlearn/embedding/fa.py | 1 - stlearn/embedding/ica.py | 5 +- stlearn/embedding/pca.py | 5 +- stlearn/embedding/umap.py | 29 +- .../image_preprocessing/feature_extractor.py | 1 - stlearn/image_preprocessing/image_tiling.py | 4 +- stlearn/image_preprocessing/segmentation.py | 1 - stlearn/pl.py | 1 - stlearn/plotting/QC_plot.py | 1 - stlearn/plotting/cci_plot.py | 405 ++++++++------- stlearn/plotting/cci_plot_helpers.py | 166 +++--- stlearn/plotting/classes.py | 477 +++++++++--------- stlearn/plotting/classes_bokeh.py | 40 +- stlearn/plotting/cluster_plot.py | 78 +-- stlearn/plotting/deconvolution_plot.py | 49 +- stlearn/plotting/feat_plot.py | 58 +-- stlearn/plotting/gene_plot.py | 60 +-- stlearn/plotting/mask_plot.py | 29 +- stlearn/plotting/non_spatial_plot.py | 5 +- stlearn/plotting/stack_3d_plot.py | 19 +- stlearn/plotting/subcluster_plot.py | 52 +- .../plotting/trajectory/DE_transition_plot.py | 12 +- .../plotting/trajectory/check_trajectory.py | 33 +- stlearn/plotting/trajectory/local_plot.py | 37 +- .../plotting/trajectory/pseudotime_plot.py | 51 +- .../trajectory/transition_markers_plot.py | 12 +- stlearn/plotting/trajectory/tree_plot.py | 35 +- .../plotting/trajectory/tree_plot_simple.py | 35 +- stlearn/plotting/utils.py | 10 +- stlearn/preprocessing/filter_genes.py | 13 +- stlearn/preprocessing/graph.py | 20 +- stlearn/preprocessing/log_scale.py | 19 +- stlearn/preprocessing/normalize.py | 16 +- stlearn/spatials/SME/_weighting_matrix.py | 1 - stlearn/spatials/SME/impute.py | 36 +- stlearn/spatials/SME/normalize.py | 11 +- stlearn/spatials/clustering/localization.py | 11 +- stlearn/spatials/morphology/adjust.py | 1 - stlearn/spatials/smooth/disk.py | 17 +- .../trajectory/detect_transition_markers.py | 28 +- stlearn/spatials/trajectory/global_level.py | 24 +- stlearn/spatials/trajectory/local_level.py | 19 +- stlearn/spatials/trajectory/pseudotime.py | 41 +- .../spatials/trajectory/pseudotimespace.py | 29 +- stlearn/spatials/trajectory/set_root.py | 4 +- stlearn/spatials/trajectory/utils.py | 28 +- .../trajectory/weight_optimization.py | 38 +- stlearn/tools/clustering/kmeans.py | 25 +- stlearn/tools/clustering/louvain.py | 24 +- stlearn/tools/label/label.py | 55 +- stlearn/tools/microenv/cci/analysis.py | 4 +- stlearn/tools/microenv/cci/base.py | 103 ++-- stlearn/tools/microenv/cci/base_grouping.py | 68 +-- stlearn/tools/microenv/cci/het_helpers.py | 52 +- stlearn/tools/microenv/cci/merge.py | 12 +- stlearn/tools/microenv/cci/permutation.py | 127 +++-- stlearn/utils.py | 4 +- stlearn/wrapper/convert_scanpy.py | 1 - 69 files changed, 1307 insertions(+), 1349 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index fbc922f4..40cc4228 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -88,7 +88,7 @@ Ready to contribute? Here's how to set up `stlearn` for local development. $ black stlearn tests $ flake8 stlearn tests - $ mypy stlearn + $ mypy stlearn tests $ pytest Or run everything with tox:: diff --git a/stlearn/_settings.py b/stlearn/_settings.py index 91d8b617..b3c06afa 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -62,32 +62,32 @@ def _type_check(var: Any, varname: str, types: type | tuple[type, ...]): raise TypeError(f"{varname} must be of type {possible_types_str}") -class stLearnConfig: # noqa N801 +class stLearnConfig: # noqa N801 """\ Config manager for scanpy. """ def __init__( - self, - *, - verbosity: str = "warning", - plot_suffix: str = "", - file_format_data: str = "h5ad", - file_format_figs: str = "pdf", - autosave: bool = False, - autoshow: bool = True, - writedir: str | Path = "./write/", - cachedir: str | Path = "./cache/", - datasetdir: str | Path = "./data/", - figdir: str | Path = "./figures/", - cache_compression: str | None = "lzf", - max_memory=15, - n_jobs=1, - logfile: str | Path | None = None, - categories_to_ignore: Iterable[str] = ("N/A", "dontknow", "no_gate", "?"), - _frameon: bool = True, - _vector_friendly: bool = False, - _low_resolution_warning: bool = True, + self, + *, + verbosity: str = "warning", + plot_suffix: str = "", + file_format_data: str = "h5ad", + file_format_figs: str = "pdf", + autosave: bool = False, + autoshow: bool = True, + writedir: str | Path = "./write/", + cachedir: str | Path = "./cache/", + datasetdir: str | Path = "./data/", + figdir: str | Path = "./figures/", + cache_compression: str | None = "lzf", + max_memory=15, + n_jobs=1, + logfile: str | Path | None = None, + categories_to_ignore: Iterable[str] = ("N/A", "dontknow", "no_gate", "?"), + _frameon: bool = True, + _vector_friendly: bool = False, + _low_resolution_warning: bool = True, ): # logging self._root_logger = _RootLogger(logging.INFO) # level will be replaced @@ -399,16 +399,16 @@ def categories_to_ignore(self, categories_to_ignore: Iterable[str]): ] def set_figure_params( - self, - dpi: int = 80, - dpi_save: int = 150, - frameon: bool = True, - vector_friendly: bool = True, - fontsize: int = 14, - color_map: str | None = None, - format: _Format = "pdf", - transparent: bool = False, - ipython_format: str = "png2x", + self, + dpi: int = 80, + dpi_save: int = 150, + frameon: bool = True, + vector_friendly: bool = True, + fontsize: int = 14, + color_map: str | None = None, + format: _Format = "pdf", + transparent: bool = False, + ipython_format: str = "png2x", ): """\ Set resolution/size, styling and format of figures. diff --git a/stlearn/adds/add_labels.py b/stlearn/adds/add_labels.py index cb7210bf..5b0875f7 100644 --- a/stlearn/adds/add_labels.py +++ b/stlearn/adds/add_labels.py @@ -5,12 +5,12 @@ def labels( - adata: AnnData, - label_filepath: str = None, - index_col: int = 0, - use_label: str = None, - sep: str = "\t", - copy: bool = False, + adata: AnnData, + label_filepath: str = None, + index_col: int = 0, + use_label: str = None, + sep: str = "\t", + copy: bool = False, ) -> AnnData | None: """\ Add label transfer results into AnnData object diff --git a/stlearn/adds/add_lr.py b/stlearn/adds/add_lr.py index bafc45ea..d979a2ac 100644 --- a/stlearn/adds/add_lr.py +++ b/stlearn/adds/add_lr.py @@ -3,11 +3,11 @@ def lr( - adata: AnnData, - db_filepath: str = None, - sep: str = "\t", - source: str = "connectomedb", - copy: bool = False, + adata: AnnData, + db_filepath: str = None, + sep: str = "\t", + source: str = "connectomedb", + copy: bool = False, ) -> AnnData | None: """Add significant Ligand-Receptor pairs into AnnData object @@ -44,7 +44,7 @@ def lr( elif source == "connectomedb": ctdb = pd.read_csv(db_filepath, sep=sep, quotechar='"', encoding="latin1") adata.uns["lr"] = ( - ctdb["Ligand gene symbol"] + "_" + ctdb["Receptor gene symbol"] + ctdb["Ligand gene symbol"] + "_" + ctdb["Receptor gene symbol"] ).values.tolist() print("connectomedb results added to adata.uns['ctdb']") print("Added ligand receptor pairs to adata.uns['lr'].") diff --git a/stlearn/adds/annotation.py b/stlearn/adds/annotation.py index fcd6fe52..809c0cea 100644 --- a/stlearn/adds/annotation.py +++ b/stlearn/adds/annotation.py @@ -1,4 +1,3 @@ - from anndata import AnnData diff --git a/stlearn/app/source/forms/forms.py b/stlearn/app/source/forms/forms.py index 91aff56e..790bc97e 100644 --- a/stlearn/app/source/forms/forms.py +++ b/stlearn/app/source/forms/forms.py @@ -197,13 +197,13 @@ def getCCIForm(adata): related to CCI analysis. """ elements = [ - "Cell information (only discrete labels available, unless mixture already in " + - "anndata.uns)", + "Cell information (only discrete labels available, unless mixture already in " + + "anndata.uns)", "Minimum spots for LR to be considered", - "Spot mixture (only if the 'Cell Information' label selected available in " + - "anndata.uns)", - "Cell proportion cutoff (value above which cell is considered in spot " + - "if 'Spot mixture' selected)", + "Spot mixture (only if the 'Cell Information' label selected available in " + + "anndata.uns)", + "Cell proportion cutoff (value above which cell is considered in spot " + + "if 'Spot mixture' selected)", "Permutations (recommend atleast 1000)", ] element_fields = [ @@ -217,9 +217,7 @@ def getCCIForm(adata): fields = [] mix = False else: - fields = [ - key for key in adata.obs.keys() if adata.obs[key].values[0] is str - ] + fields = [key for key in adata.obs.keys() if adata.obs[key].values[0] is str] mix = fields[0] in adata.uns.keys() element_values = [fields, 20, mix, 0.2, 100] return createSuperForm(elements, element_fields, element_values) @@ -324,6 +322,7 @@ def getDEAForm(list_labels, methods): element_values = [list_labels, methods] return createSuperForm(elements, element_fields, element_values) + ######################## Junk Code ############################################# # def getCCIForm(step_log): # """ Gets the CCI form generated from the superform above. diff --git a/stlearn/app/source/forms/utils.py b/stlearn/app/source/forms/utils.py index 43284e68..1d5d0c75 100644 --- a/stlearn/app/source/forms/utils.py +++ b/stlearn/app/source/forms/utils.py @@ -1,4 +1,5 @@ """Helper utilities and decorators.""" + from flask import flash diff --git a/stlearn/app/source/forms/view_helpers.py b/stlearn/app/source/forms/view_helpers.py index a5613b61..3c2de3d0 100644 --- a/stlearn/app/source/forms/view_helpers.py +++ b/stlearn/app/source/forms/view_helpers.py @@ -1,7 +1,6 @@ """Helper functions for views.py.""" - def getVal(form, element): return getattr(form, element).data diff --git a/stlearn/classes.py b/stlearn/classes.py index f9ef77c2..12c25ede 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -4,7 +4,6 @@ Date: 20 Feb 2021 """ - import numpy as np from anndata import AnnData diff --git a/stlearn/em.py b/stlearn/em.py index 6768d1c6..d16c7bec 100644 --- a/stlearn/em.py +++ b/stlearn/em.py @@ -1,2 +1 @@ - # from .embedding.scvi import run_ldvae diff --git a/stlearn/embedding/diffmap.py b/stlearn/embedding/diffmap.py index 93a5c480..93338007 100644 --- a/stlearn/embedding/diffmap.py +++ b/stlearn/embedding/diffmap.py @@ -37,8 +37,8 @@ def run_diffmap(adata: AnnData, n_comps: int = 15, copy: bool = False): scanpy.tl.diffmap(adata, n_comps=n_comps, copy=copy) print( - "Diffusion Map is done! Generated in adata.obsm['X_diffmap'] and " + - "adata.uns['diffmap_evals']" + "Diffusion Map is done! Generated in adata.obsm['X_diffmap'] and " + + "adata.uns['diffmap_evals']" ) return adata if copy else None diff --git a/stlearn/embedding/fa.py b/stlearn/embedding/fa.py index a707efb3..b982c3d8 100644 --- a/stlearn/embedding/fa.py +++ b/stlearn/embedding/fa.py @@ -1,4 +1,3 @@ - from anndata import AnnData from scipy.sparse import issparse from sklearn.decomposition import FactorAnalysis diff --git a/stlearn/embedding/ica.py b/stlearn/embedding/ica.py index e99e77ca..5b990788 100644 --- a/stlearn/embedding/ica.py +++ b/stlearn/embedding/ica.py @@ -1,4 +1,3 @@ - from anndata import AnnData from scipy.sparse import issparse from sklearn.decomposition import FastICA @@ -62,8 +61,8 @@ def my_g(x): adata.uns["ica"] = {"params": {"n_factors": n_factors, "fun": fun, "tol": tol}} print( - "ICA is done! Generated in adata.obsm['X_ica'] and parameters in " + - "adata.uns['ica']" + "ICA is done! Generated in adata.obsm['X_ica'] and parameters in " + + "adata.uns['ica']" ) return adata if copy else None diff --git a/stlearn/embedding/pca.py b/stlearn/embedding/pca.py index d4b66f15..22ae94fb 100644 --- a/stlearn/embedding/pca.py +++ b/stlearn/embedding/pca.py @@ -1,4 +1,3 @@ - import numpy as np import scanpy from anndata import AnnData @@ -99,6 +98,6 @@ def run_pca( ) print( - "PCA is done! Generated in adata.obsm['X_pca'], adata.uns['pca'] and " + - "adata.varm['PCs']" + "PCA is done! Generated in adata.obsm['X_pca'], adata.uns['pca'] and " + + "adata.varm['PCs']" ) diff --git a/stlearn/embedding/umap.py b/stlearn/embedding/umap.py index 85f6e8b1..9b375a80 100644 --- a/stlearn/embedding/umap.py +++ b/stlearn/embedding/umap.py @@ -1,4 +1,3 @@ - import numpy as np import scanpy from anndata import AnnData @@ -10,20 +9,20 @@ def run_umap( - adata: AnnData, - min_dist: float = 0.5, - spread: float = 1.0, - n_components: int = 2, - maxiter: int | None = None, - alpha: float = 1.0, - gamma: float = 1.0, - negative_sample_rate: int = 5, - init_pos: _InitPos | np.ndarray | None = "spectral", - random_state: int | RandomState | None = 0, - a: float | None = None, - b: float | None = None, - copy: bool = False, - method: Literal["umap", "rapids"] = "umap", # noqa: F821 + adata: AnnData, + min_dist: float = 0.5, + spread: float = 1.0, + n_components: int = 2, + maxiter: int | None = None, + alpha: float = 1.0, + gamma: float = 1.0, + negative_sample_rate: int = 5, + init_pos: _InitPos | np.ndarray | None = "spectral", + random_state: int | RandomState | None = 0, + a: float | None = None, + b: float | None = None, + copy: bool = False, + method: Literal["umap", "rapids"] = "umap", # noqa: F821 ) -> AnnData | None: """\ Wrap function scanpy.pp.umap diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 0d451041..75e3a2a2 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -1,4 +1,3 @@ - import numpy as np import pandas as pd from anndata import AnnData diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index 4daee2a8..ee338816 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -91,9 +91,7 @@ def tiling( tile.save(out_tile, "PNG") if verbose: - print( - f"generate tile at location ({str(imagecol)}, {str(imagerow)})" - ) + print(f"generate tile at location ({str(imagecol)}, {str(imagerow)})") pbar.update(1) diff --git a/stlearn/image_preprocessing/segmentation.py b/stlearn/image_preprocessing/segmentation.py index 8c7f4dfe..6d975aab 100644 --- a/stlearn/image_preprocessing/segmentation.py +++ b/stlearn/image_preprocessing/segmentation.py @@ -1,4 +1,3 @@ - import histomicstk as htk import numpy as np import scipy as sp diff --git a/stlearn/pl.py b/stlearn/pl.py index 54be1ef2..a41cb8d6 100644 --- a/stlearn/pl.py +++ b/stlearn/pl.py @@ -1,2 +1 @@ - # from .plotting.cci_plot import het_plot_interactive diff --git a/stlearn/plotting/QC_plot.py b/stlearn/plotting/QC_plot.py index 9b4af383..ebe0faa6 100644 --- a/stlearn/plotting/QC_plot.py +++ b/stlearn/plotting/QC_plot.py @@ -1,4 +1,3 @@ - import numpy as np from anndata import AnnData from matplotlib import pyplot as plt diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index a2816a37..4cc3467f 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -3,7 +3,7 @@ import sys from typing import ( Optional, # Special - ) +) import matplotlib import matplotlib.patches as patches @@ -42,14 +42,14 @@ def lr_diagnostics( - adata, - highlight_lrs: list = None, - n_top: int = None, - color0: str = "turquoise", - color1: str = "plum", - figsize: tuple = (10, 4), - lr_text_fp: dict = None, - show: bool = True, + adata, + highlight_lrs: list = None, + n_top: int = None, + color0: str = "turquoise", + color1: str = "plum", + figsize: tuple = (10, 4), + lr_text_fp: dict = None, + show: bool = True, ): """Diagnostic plot looking at relationship between technical features of lrs and lr rank. Two plots generated: left is the average of the median for nonzero @@ -107,17 +107,17 @@ def lr_diagnostics( def lr_summary( - adata, - n_top: int = 50, - highlight_lrs: list = None, - y: str = "n_spots_sig", - color: str = "gold", - figsize: tuple = None, - highlight_color: str = "red", - max_text: int = 50, - lr_text_fp: dict = None, - ax: Axes = None, - show: bool = True, + adata, + n_top: int = 50, + highlight_lrs: list = None, + y: str = "n_spots_sig", + color: str = "gold", + figsize: tuple = None, + highlight_color: str = "red", + max_text: int = 50, + lr_text_fp: dict = None, + ax: Axes = None, + show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -175,17 +175,17 @@ def lr_summary( def lr_n_spots( - adata, - n_top: int = 100, - font_dict: dict = None, - xtick_dict: dict = None, - bar_width: float = 1, - max_text: int = 50, - non_sig_color: str = "dodgerblue", - sig_color: str = "springgreen", - figsize: tuple = (6, 4), - show_title: bool = True, - show: bool = True, + adata, + n_top: int = 100, + font_dict: dict = None, + xtick_dict: dict = None, + bar_width: float = 1, + max_text: int = 50, + non_sig_color: str = "dodgerblue", + sig_color: str = "springgreen", + figsize: tuple = (6, 4), + show_title: bool = True, + show: bool = True, ): """Bar plot showing for each LR no. of sig versus non-sig spots. @@ -257,15 +257,15 @@ def lr_n_spots( def lr_go( - adata, - n_top: int = 20, - highlight_go: list = None, - figsize=(6, 4), - rot: float = 50, - lr_text_fp: dict = None, - highlight_color: str = "yellow", - max_text: int = 50, - show: bool = True, + adata, + n_top: int = 20, + highlight_go: list = None, + figsize=(6, 4), + rot: float = 50, + lr_text_fp: dict = None, + highlight_color: str = "yellow", + max_text: int = 50, + show: bool = True, ): """Plots the results from the LR GO analysis. @@ -321,13 +321,13 @@ def lr_go( def cci_check( - adata: AnnData, - use_label: str, - figsize=(16, 10), - cell_label_size=20, - axis_text_size=18, - tick_size=14, - show=True, + adata: AnnData, + use_label: str, + figsize=(16, 10), + cell_label_size=20, + axis_text_size=18, + tick_size=14, + show=True, ): """Checks relationship between no. of significant CCI-LR interactions and cell type frequency. @@ -422,32 +422,32 @@ def cci_check( # Functions for visualisation the LR results per spot. def lr_result_plot( - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: float | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, ): """Plots the per spot statistics for given LR. @@ -538,35 +538,35 @@ def lr_result_plot( # @_docs_params(het_plot=doc_lr_plot) def lr_plot( - adata: AnnData, - lr: str, - min_expr: float = 0, - sig_spots=True, - use_label: str = None, - outer_mode: str = "continuous", - l_cmap=None, - r_cmap=None, - lr_cmap=None, - inner_cmap=None, - inner_size_prop: float = 0.25, - middle_size_prop: float = 0.5, - outer_size_prop: float = 1, - pt_scale: int = 100, - title="", - show_image: bool = True, - show_arrows: bool = False, - fig: Figure = None, - ax: Axes = None, - arrow_head_width: float = 4, - arrow_width: float = 0.001, - arrow_cmap: str = None, - arrow_vmax: float = None, - sig_cci: bool = False, - lr_colors: dict = None, - figsize: tuple = (6.4, 4.8), - use_mix: bool = None, - # plotting params - **kwargs, + adata: AnnData, + lr: str, + min_expr: float = 0, + sig_spots=True, + use_label: str = None, + outer_mode: str = "continuous", + l_cmap=None, + r_cmap=None, + lr_cmap=None, + inner_cmap=None, + inner_size_prop: float = 0.25, + middle_size_prop: float = 0.5, + outer_size_prop: float = 1, + pt_scale: int = 100, + title="", + show_image: bool = True, + show_arrows: bool = False, + fig: Figure = None, + ax: Axes = None, + arrow_head_width: float = 4, + arrow_width: float = 0.001, + arrow_cmap: str = None, + arrow_vmax: float = None, + sig_cci: bool = False, + lr_colors: dict = None, + figsize: tuple = (6.4, 4.8), + use_mix: bool = None, + # plotting params + **kwargs, ) -> AnnData | None: """Creates different kinds of spatial visualisations for the LR analysis results. To see combinations of parameters refer to stLearn CCI tutorial. @@ -672,10 +672,10 @@ def lr_plot( # Making sure have run_cci first with respective labelling # if ( - show_arrows - and sig_cci - and use_label - and f"per_lr_cci_{use_label}" not in adata.uns + show_arrows + and sig_cci + and use_label + and f"per_lr_cci_{use_label}" not in adata.uns ): raise Exception( "Cannot subset arrow interactions to significant ccis " @@ -701,19 +701,16 @@ def lr_plot( "to adata.uns matching the use_mix ({use_mix}) key." ) elif ( - use_label is not None - and use_label in lr_use_labels - and ran_sig - and not lr_sig + use_label is not None and use_label in lr_use_labels and ran_sig and not lr_sig ): raise Exception( "Since use_label refers to lr stats & ran permutation testing, " "LR needs to be significant to view stats." ) elif ( - use_label is not None - and use_label not in adata.obs.keys() - and use_label not in lr_use_labels + use_label is not None + and use_label not in adata.obs.keys() + and use_label not in lr_use_labels ): raise Exception( f"use_label must be in adata.obs or " f"one of lr stats: {lr_use_labels}." @@ -905,34 +902,34 @@ def lr_plot( #### from old data structure when only test individual LRs. @_docs_params(spatial_base_plot=doc_spatial_base_plot, het_plot=doc_het_plot) def het_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # cci_rank param - use_het: str | None = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + use_het: str | None = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, ) -> AnnData | None: """\ Allows the visualization of significant cell-cell interaction @@ -989,22 +986,22 @@ def het_plot( def ccinet_plot( - adata: AnnData, - use_label: str, - lr: str = None, - pos: dict = None, - return_pos: bool = False, - cmap: str = "default", - font_size: int = 12, - node_size_exp: int = 1, - node_size_scaler: int = 1, - min_counts: int = 0, - sig_interactions: bool = True, - fig: matplotlib.figure.Figure = None, - ax: matplotlib.axes.Axes = None, - pad=0.25, - title: str = None, - figsize: tuple = (10, 10), + adata: AnnData, + use_label: str, + lr: str = None, + pos: dict = None, + return_pos: bool = False, + cmap: str = "default", + font_size: int = 12, + node_size_exp: int = 1, + node_size_scaler: int = 1, + min_counts: int = 0, + sig_interactions: bool = True, + fig: matplotlib.figure.Figure = None, + ax: matplotlib.axes.Axes = None, + pad=0.25, + title: str = None, + figsize: tuple = (10, 10), ): """Circular celltype-celltype interaction network based on LR-CCI analysis. The size of the nodes drawn for each cell type indicates the total no. of @@ -1086,10 +1083,9 @@ def ccinet_plot( node_sizes = np.array( [ ( - ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[ - i, i]) / total) - * 10000 - * node_size_scaler + ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[i, i]) / total) + * 10000 + * node_size_scaler ) ** (node_size_exp) for i in node_indices @@ -1103,8 +1099,8 @@ def ccinet_plot( trans_i = np.where(all_set == edge[0][0])[0][0] receive_i = np.where(all_set == edge[0][1])[0][0] e_total = ( - sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) - - int_matrix[trans_i, receive_i] + sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) + - int_matrix[trans_i, receive_i] ) # so don't double count e_totals.append(e_total) edge_weights = [edge[1]["weight"] / e_totals[i] for i, edge in enumerate(edges)] @@ -1171,15 +1167,15 @@ def ccinet_plot( def cci_map( - adata: AnnData, - use_label: str, - lr: str = None, - ax: matplotlib.figure.Axes = None, - show: bool = False, - figsize: tuple = None, - cmap: str = "Spectral_r", - sig_interactions: bool = True, - title=None, + adata: AnnData, + use_label: str, + lr: str = None, + ax: matplotlib.figure.Axes = None, + show: bool = False, + figsize: tuple = None, + cmap: str = "Spectral_r", + sig_interactions: bool = True, + title=None, ): """Heatmap visualising sender->receivers of cell type interactions. @@ -1250,18 +1246,18 @@ def cci_map( def lr_cci_map( - adata: AnnData, - use_label: str, - lrs: list or np.array = None, - n_top_lrs: int = 5, - n_top_ccis: int = 15, - min_total: int = 0, - ax: matplotlib.figure.Axes = None, - figsize: tuple = (6.48, 4.8), - show: bool = False, - cmap: str = "Spectral_r", - square_scaler: int = 700, - sig_interactions: bool = True, + adata: AnnData, + use_label: str, + lrs: list or np.array = None, + n_top_lrs: int = 5, + n_top_ccis: int = 15, + min_total: int = 0, + ax: matplotlib.figure.Axes = None, + figsize: tuple = (6.48, 4.8), + show: bool = False, + cmap: str = "Spectral_r", + square_scaler: int = 700, + sig_interactions: bool = True, ): """Heatmap of interaction counts. Rows are lrs and columns are celltype->celltype interactions. @@ -1368,18 +1364,18 @@ def lr_cci_map( def lr_chord_plot( - adata: AnnData, - use_label: str, - lr: str = None, - min_ints: int = 2, - n_top_ccis: int = 10, - cmap: str = "default", - sig_interactions: bool = True, - label_size: int = 10, - label_rotation: float = 0, - title: str = None, - figsize: tuple = (8, 8), - show: bool = True, + adata: AnnData, + use_label: str, + lr: str = None, + min_ints: int = 2, + n_top_ccis: int = 10, + cmap: str = "default", + sig_interactions: bool = True, + label_size: int = 10, + label_rotation: float = 0, + title: str = None, + figsize: tuple = (8, 8), + show: bool = True, ): """Chord diagram of interactions between cell types. Note that interaction is measured as the total no. of edges connecting @@ -1487,7 +1483,7 @@ def lr_chord_plot( rotation = nodePos[i][2] # Prevent text going upside down at certain rotations if (rotation < 90 and rotation > 18 and label_rotation != 0) or ( - rotation < 120 and rotation > 90 + rotation < 120 and rotation > 90 ): label_rotation_ = -label_rotation else: @@ -1503,13 +1499,13 @@ def lr_chord_plot( def grid_plot( - adata, - use_label: str = None, - n_row: int = 10, - n_col: int = 10, - size: int = 1, - figsize=(4.5, 4.5), - show: bool = False, + adata, + use_label: str = None, + n_row: int = 10, + n_col: int = 10, + size: int = 1, + figsize=(4.5, 4.5), + show: bool = False, ): """Plots grid over the top of spatial data to show how cells will be grouped if gridded. @@ -1587,6 +1583,7 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) + # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 1b7b456c..9fca0baa 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -19,21 +19,21 @@ def lr_scatter( - data, - feature, - highlight_lrs=None, - show_text=True, - n_top=50, - color="gold", - alpha=0.5, - lr_text_fp=None, - axis_text_fp=None, - ax=None, - show=True, - max_text=100, - highlight_color="red", - figsize: tuple = None, - show_all: bool = False, + data, + feature, + highlight_lrs=None, + show_text=True, + n_top=50, + color="gold", + alpha=0.5, + lr_text_fp=None, + axis_text_fp=None, + ax=None, + show=True, + max_text=100, + highlight_color="red", + figsize: tuple = None, + show_all: bool = False, ): """General plotting of the LR features.""" highlight = highlight_lrs is not None @@ -108,28 +108,28 @@ def lr_scatter( def rank_scatter( - items, - y, - y_label: str = "", - x_label: str = "", - highlight_items=None, - show_text=True, - color="gold", - alpha=0.5, - lr_text_fp=None, - axis_text_fp=None, - ax=None, - show=True, - highlight_color="red", - rot: float = 90, - point_sizes: np.array = None, - pad=0.2, - figsize=None, - width_ratio=7.5 / 50, - height=4, - point_size_name="Sizes", - point_size_exp=2, - show_all: bool = False, + items, + y, + y_label: str = "", + x_label: str = "", + highlight_items=None, + show_text=True, + color="gold", + alpha=0.5, + lr_text_fp=None, + axis_text_fp=None, + ax=None, + show=True, + highlight_color="red", + rot: float = 90, + point_sizes: np.array = None, + pad=0.2, + figsize=None, + width_ratio=7.5 / 50, + height=4, + point_size_name="Sizes", + point_size_exp=2, + show_all: bool = False, ): """General plotting function for showing ranked list of items.""" ranks = np.array(list(range(len(items)))) @@ -154,7 +154,7 @@ def rank_scatter( y, alpha=alpha, c=color, - s=None if point_sizes is None else point_sizes ** point_size_exp, + s=None if point_sizes is None else point_sizes**point_size_exp, edgecolors="none", ) y_min, y_max = ax.get_ylim() @@ -167,12 +167,12 @@ def rank_scatter( starts = [label.find("{") for label in labels] ends = [label.find("}") + 1 for label in labels] sizes = [ - float(label[(starts[i] + 1): (ends[i] - 1)]) + float(label[(starts[i] + 1) : (ends[i] - 1)]) for i, label in enumerate(labels) ] counts = [int(size ** (1 / point_size_exp)) for size in sizes] labels2 = [ - label.replace(label[(starts[i]): (ends[i])], "{" + str(counts[i]) + "}") + label.replace(label[(starts[i]) : (ends[i])], "{" + str(counts[i]) + "}") for i, label in enumerate(labels) ] ax.legend( @@ -220,19 +220,19 @@ def rank_scatter( def add_arrows( - adata: AnnData, - l_expr: np.array, - r_expr: np.array, - min_expr: float, - sig_bool: np.array, - fig, - ax: Axes, - use_label: str, - int_df: pd.DataFrame, - head_width=4, - width=0.001, - arrow_cmap=None, - arrow_vmax=None, + adata: AnnData, + l_expr: np.array, + r_expr: np.array, + min_expr: float, + sig_bool: np.array, + fig, + ax: Axes, + use_label: str, + int_df: pd.DataFrame, + head_width=4, + width=0.001, + arrow_cmap=None, + arrow_vmax=None, ): """ Adds arrows to the current plot for significant spots to neighbours \ which is interacting with. @@ -368,15 +368,15 @@ def add_arrows( def add_arrows_by_edges( - ax, - adata, - edges, - scale_factor, - head_width, - width, - forward=True, - edge_colors=None, - axc=None, + ax, + adata, + edges, + scale_factor, + head_width, + width, + forward=True, + edge_colors=None, + axc=None, ): """Adds the arrows using an edge list.""" for i, edge in enumerate(edges): @@ -499,11 +499,11 @@ def polar2xy(r, theta): def hex2rgb(c): - return tuple(int(c[i: i + 2], 16) / 256.0 for i in (1, 3, 5)) + return tuple(int(c[i : i + 2], 16) / 256.0 for i in (1, 3, 5)) def IdeogramArc( - start=0, end=60, radius=1.0, width=0.2, ax=None, color=(1, 0, 0), curve_steps=1 + start=0, end=60, radius=1.0, width=0.2, ax=None, color=(1, 0, 0), curve_steps=1 ): # start, end should be in [0, 360) if start > end: @@ -543,22 +543,22 @@ def IdeogramArc( for i in range(1, curve_steps + 1) ] verts_inner = ( - verts_inner_start - + verts_inner_curve - + [polar2xy(inner, start), polar2xy(radius, start)] + verts_inner_start + + verts_inner_curve + + [polar2xy(inner, start), polar2xy(radius, start)] ) verts = verts_upper + verts_inner codes = ( - [Path.MOVETO] - + [Path.CURVE4] * curve_steps * 2 - + [Path.CURVE4, Path.LINETO] - + [Path.CURVE4] * curve_steps * 2 - + [ - Path.CURVE4, - Path.CLOSEPOLY, - ] + [Path.MOVETO] + + [Path.CURVE4] * curve_steps * 2 + + [Path.CURVE4, Path.LINETO] + + [Path.CURVE4] * curve_steps * 2 + + [ + Path.CURVE4, + Path.CLOSEPOLY, + ] ) if ax is None: @@ -572,14 +572,14 @@ def IdeogramArc( def ChordArc( - start1=0, - end1=60, - start2=180, - end2=240, - radius=1.0, - chordwidth=0.7, - ax=None, - color=(1, 0, 0), + start1=0, + end1=60, + start2=180, + end2=240, + radius=1.0, + chordwidth=0.7, + ax=None, + color=(1, 0, 0), ): # start, end should be in [0, 360) if start1 > end1: diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index a435cbf3..14efdcc2 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -8,7 +8,7 @@ import warnings from typing import ( # Special Optional, # Classes - ) +) import matplotlib import matplotlib.pyplot as plt @@ -25,31 +25,31 @@ class SpatialBasePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - **kwds, + self, + # plotting param + adata: AnnData, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + **kwds, ): super().__init__( adata, @@ -73,7 +73,7 @@ def __init__( if use_label is not None: assert ( - use_label in self.adata[0].obs.columns + use_label in self.adata[0].obs.columns ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label @@ -103,8 +103,8 @@ def __init__( stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) if cmap is str: assert cmap in cmap_available, error_msg @@ -139,7 +139,7 @@ def create_query(list_cl, use_label): if self.list_clusters is not None: # IF not all clusters specified, subset, otherwise just copy. if len(self.list_clusters) != len( - self.adata[0].obs[self.use_label].cat.categories + self.adata[0].obs[self.use_label].cat.categories ): self.query_adata = self.query_adata[ self.query_adata.obs.query( @@ -223,41 +223,42 @@ def _save_output(self): # # ################################################################ + class GenePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # gene plot param - gene_symbols: str | list = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # gene plot param + gene_symbols: str | list = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -437,38 +438,38 @@ def _add_threshold(self, gene_values, threshold): class FeaturePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # gene plot param - feature: str = None, - threshold: float | None = None, - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # gene plot param + feature: str = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -525,7 +526,7 @@ def _get_feature_values(self): self.feature + " is not in data.obs, please try another feature" ) elif not isinstance( - self.query_adata.obs[self.feature].values[0], numbers.Number + self.query_adata.obs[self.feature].values[0], numbers.Number ): raise ValueError( self.feature @@ -601,44 +602,44 @@ def _add_threshold(self, feature_values, threshold): # Cluster plot class class ClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "default", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # cluster plot param - show_subcluster: bool | None = False, - show_cluster_labels: bool | None = False, - show_trajectories: bool | None = False, - reverse: bool | None = False, - show_node: bool | None = False, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - color_bar_size: float | None = 10, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - # trajectory - trajectory_node_size: int | None = 10, - trajectory_alpha: float | None = 1.0, - trajectory_width: float | None = 2.5, - trajectory_edge_color: str | None = "#f4efd3", - trajectory_arrowsize: int | None = 17, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # cluster plot param + show_subcluster: bool | None = False, + show_cluster_labels: bool | None = False, + show_trajectories: bool | None = False, + reverse: bool | None = False, + show_node: bool | None = False, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + color_bar_size: float | None = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int | None = 10, + trajectory_alpha: float | None = 1.0, + trajectory_width: float | None = 2.5, + trajectory_edge_color: str | None = "#f4efd3", + trajectory_arrowsize: int | None = 17, ): super().__init__( adata=adata, @@ -763,7 +764,7 @@ def _add_cluster_labels(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -806,7 +807,7 @@ def _add_sub_clusters(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -816,18 +817,18 @@ def _add_sub_clusters(self): imgrow_new = subset_spatial[:, 1] * self.scale_factor if ( - len( - self.query_adata.obs[ - self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() - ) - < 2 + len( + self.query_adata.obs[ + self.query_adata.obs[self.use_label] == str(label) + ]["sub_cluster_labels"].unique() + ) + < 2 ): centroids = [centroidpython(imgcol_new, imgrow_new)] classes = np.array( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() + ]["sub_cluster_labels"].unique() ) else: @@ -838,7 +839,7 @@ def _add_sub_clusters(self): np.column_stack((imgcol_new, imgrow_new)), self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"], + ]["sub_cluster_labels"], ) centroids = clf.centroids_ @@ -846,12 +847,12 @@ def _add_sub_clusters(self): for j, label in enumerate(classes): if ( - len( - self.query_adata.obs[ - self.query_adata.obs["sub_cluster_labels"] == label - ] - ) - > self.threshold_spots + len( + self.query_adata.obs[ + self.query_adata.obs["sub_cluster_labels"] == label + ] + ) + > self.threshold_spots ): if centroids[j][0] < 1500: x = -100 @@ -953,34 +954,34 @@ def _add_trajectories(self): class SubClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "jet", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # subcluster plot param - cluster: int | None = 0, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # subcluster plot param + cluster: int | None = 0, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + **kwargs, ): super().__init__( adata=adata, @@ -1107,36 +1108,36 @@ def _add_subclusters_label(self, subset): class CciPlot(GenePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # cci_rank param - use_het: str | None = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + use_het: str | None = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): super().__init__( adata=adata, @@ -1176,36 +1177,36 @@ def _get_gene_expression(self): class LrResultPlot(GenePlot): def __init__( - self, - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # cci_rank param - contour: bool = False, - step_size: int | None = None, - vmin: float = None, - vmax: float = None, - **kwargs, + self, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + zoom_coord: float | None = None, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + # cci_rank param + contour: bool = False, + step_size: int | None = None, + vmin: float = None, + vmax: float = None, + **kwargs, ): # Making sure cci_rank has been run first # if "lr_summary" not in adata.uns: diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 91f678a3..8b5d6fd0 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -1,4 +1,3 @@ - from collections import OrderedDict import numpy as np @@ -50,9 +49,9 @@ class BokehGenePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -322,9 +321,9 @@ def create_violin(self, adata, gene_symbol, use_label): class BokehClusterPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__(adata) @@ -471,8 +470,8 @@ def __init__( if "rank_genes_groups" in self.adata[0].uns: if ( - self.use_label.value - == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] + self.use_label.value + == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] ): self.layout = column(row(self.inputs, self.make_fig()), self.add_dea()) else: @@ -519,8 +518,8 @@ def update_data(self, attrname, old, new): if "rank_genes_groups" in self.adata[0].uns: if ( - self.use_label.value - == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] + self.use_label.value + == self.adata[0].uns["rank_genes_groups"]["params"]["groupby"] ): self.layout.children[0].children[1] = self.make_fig() self.layout.children[1] = self.add_dea() @@ -764,9 +763,9 @@ def create_dea(self, adata): class BokehLRPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -942,9 +941,9 @@ def _get_lr(self, lr): class BokehSpatialCciPlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__( adata, @@ -1213,6 +1212,7 @@ def _add_edges(fig, adata, edges, arrow_size, forward=True, scale_factor=1): def update_list(self, attrname, old, name): # Initialize the color from stlearn.plotting.cluster_plot import cluster_plot + selected = self.annot_select.value.strip("raw_") cluster_plot(self.adata[0], use_label=selected, show_plot=False) self.list_cluster.labels = list(self.adata[0].obs[selected].cat.categories) @@ -1223,9 +1223,9 @@ def update_list(self, attrname, old, name): class Annotate(Spatial): def __init__( - self, - # plotting param - adata: AnnData, + self, + # plotting param + adata: AnnData, ): super().__init__(adata) # Open image, and make sure it's RGB*A* diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index 84254e82..c5d1b08e 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -1,6 +1,6 @@ from typing import ( Optional, # Special - ) +) import matplotlib from anndata import AnnData @@ -15,43 +15,43 @@ @_docs_params(spatial_base_plot=doc_spatial_base_plot, cluster_plot=doc_cluster_plot) def cluster_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "default", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # cluster plot param - show_subcluster: bool | None = False, - show_cluster_labels: bool | None = False, - show_trajectories: bool | None = False, - reverse: bool | None = False, - show_node: bool | None = False, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - color_bar_size: float | None = 10, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - # trajectory - trajectory_node_size: int | None = 10, - trajectory_alpha: float | None = 1.0, - trajectory_width: float | None = 2.5, - trajectory_edge_color: str | None = "#f4efd3", - trajectory_arrowsize: int | None = 17, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # cluster plot param + show_subcluster: bool | None = False, + show_cluster_labels: bool | None = False, + show_trajectories: bool | None = False, + reverse: bool | None = False, + show_node: bool | None = False, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + color_bar_size: float | None = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int | None = 10, + trajectory_alpha: float | None = 1.0, + trajectory_width: float | None = 2.5, + trajectory_edge_color: str | None = "#f4efd3", + trajectory_arrowsize: int | None = 17, ) -> AnnData | None: """\ Allows the visualization of a cluster results as the discretes values @@ -114,7 +114,7 @@ def cluster_plot( def cluster_plot_interactive( - adata: AnnData, + adata: AnnData, ): bokeh_object = BokehClusterPlot(adata) output_notebook() diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/plotting/deconvolution_plot.py index 632dfadb..f41a9f8d 100644 --- a/stlearn/plotting/deconvolution_plot.py +++ b/stlearn/plotting/deconvolution_plot.py @@ -1,4 +1,3 @@ - import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np @@ -6,30 +5,30 @@ def deconvolution_plot( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - cluster: [int, str] = None, - celltype: str = None, - celltype_threshold: float = 0, - data_alpha: float = 1.0, - threshold: float = 0.0, - cmap: str = "tab20", - colors: list = None, # The colors to use for each label... - tissue_alpha: float = 1.0, - title: str = None, - spot_size: float | int = 10, - show_axis: bool = False, - show_legend: bool = True, - show_donut: bool = True, - cropped: bool = True, - margin: int = 100, - name: str = None, - dpi: int = 150, - output: str = None, - copy: bool = False, - figsize: tuple = (6.4, 4.8), - show=True, + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + cluster: [int, str] = None, + celltype: str = None, + celltype_threshold: float = 0, + data_alpha: float = 1.0, + threshold: float = 0.0, + cmap: str = "tab20", + colors: list = None, # The colors to use for each label... + tissue_alpha: float = 1.0, + title: str = None, + spot_size: float | int = 10, + show_axis: bool = False, + show_legend: bool = True, + show_donut: bool = True, + cropped: bool = True, + margin: int = 100, + name: str = None, + dpi: int = 150, + output: str = None, + copy: bool = False, + figsize: tuple = (6.4, 4.8), + show=True, ) -> AnnData | None: """\ Clustering plot for sptial transcriptomics data. Also, it has a function to diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index 77b0878c..1172cbc2 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -4,7 +4,7 @@ from typing import ( Optional, # Special - ) +) import matplotlib from anndata import AnnData @@ -14,34 +14,34 @@ # @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def feat_plot( - adata: AnnData, - feature: str = None, - threshold: float | None = None, - contour: bool = False, - step_size: int | None = None, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + feature: str = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + vmin: float | None = None, + vmax: float | None = None, ) -> AnnData | None: """\ Allows the visualization of a continuous features stored in adata.obs diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index 860c0b0a..d4ebcdff 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,6 +1,6 @@ from typing import ( # Special Optional, # Classes - ) +) import matplotlib from anndata import AnnData @@ -15,35 +15,35 @@ @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def gene_plot( - adata: AnnData, - gene_symbols: str | list = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + gene_symbols: str | list = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + vmin: float | None = None, + vmax: float | None = None, ) -> AnnData | None: """\ Allows the visualization of a single gene or multiple genes as the values diff --git a/stlearn/plotting/mask_plot.py b/stlearn/plotting/mask_plot.py index 6224b8c2..e3e13ed4 100644 --- a/stlearn/plotting/mask_plot.py +++ b/stlearn/plotting/mask_plot.py @@ -1,24 +1,23 @@ - import matplotlib from anndata import AnnData from matplotlib import pyplot as plt def plot_mask( - adata: AnnData, - library_id: str = None, - show_spot: bool = True, - spot_alpha: float = 1.0, - cmap: str = "vega_20_scanpy", - tissue_alpha: float = 1.0, - mask_alpha: float = 0.5, - spot_size: float | int = 6.5, - show_legend: bool = True, - name: str = "mask_plot", - dpi: int = 150, - output: str = None, - show_axis: bool = False, - show_plot: bool = True, + adata: AnnData, + library_id: str = None, + show_spot: bool = True, + spot_alpha: float = 1.0, + cmap: str = "vega_20_scanpy", + tissue_alpha: float = 1.0, + mask_alpha: float = 0.5, + spot_size: float | int = 6.5, + show_legend: bool = True, + name: str = "mask_plot", + dpi: int = 150, + output: str = None, + show_axis: bool = False, + show_plot: bool = True, ) -> AnnData | None: """\ mask plot for sptial transcriptomics data. diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/plotting/non_spatial_plot.py index e196b8f2..600f7897 100644 --- a/stlearn/plotting/non_spatial_plot.py +++ b/stlearn/plotting/non_spatial_plot.py @@ -1,12 +1,11 @@ - # from .utils import get_img_from_fig, checkType import scanpy from anndata import AnnData def non_spatial_plot( - adata: AnnData, - use_label: str = "louvain", + adata: AnnData, + use_label: str = "louvain", ) -> AnnData | None: """\ A wrap function to plot all the non-spatial plot from scanpy. diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index 17ee34ff..e13bba29 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -1,17 +1,16 @@ - import pandas as pd from anndata import AnnData def stack_3d_plot( - adata: AnnData, - slides, - height, - width, - cmap="viridis", - slide_col="sample_id", - use_label=None, - gene_symbol=None, + adata: AnnData, + slides, + height, + width, + cmap="viridis", + slide_col="sample_id", + use_label=None, + gene_symbol=None, ) -> AnnData | None: """\ Clustering plot for spatial transcriptomics data. Also, it has a function to @@ -45,7 +44,7 @@ def stack_3d_plot( raise ModuleNotFoundError("Please install plotly by `pip install plotly`") assert ( - slide_col in adata.obs.columns + slide_col in adata.obs.columns ), "Please provide the right column for slide_id!" list_df = [] diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index bdca7f54..af603e13 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -1,6 +1,6 @@ from typing import ( Optional, # Special - ) +) from anndata import AnnData @@ -13,30 +13,30 @@ spatial_base_plot=doc_spatial_base_plot, subcluster_plot=doc_subcluster_plot ) def subcluster_plot( - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "jet", - use_label: str | None = None, - list_clusters: list | None = None, - ax: _AxesSubplot | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - margin: bool | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # subcluster plot param - cluster: int | None = 0, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - bbox_to_anchor: tuple[float, float] | None = (1, 1), + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: _AxesSubplot | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + crop: bool | None = True, + margin: bool | None = 100, + size: float | None = 5, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 1.0, + fname: str | None = None, + dpi: int | None = 120, + # subcluster plot param + cluster: int | None = 0, + threshold_spots: int | None = 5, + text_box_size: float | None = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), ) -> AnnData | None: """\ Allows the visualization of a subclustering results as the discretes values @@ -59,7 +59,7 @@ def subcluster_plot( assert use_label is not None, "Please select `use_label` parameter" assert ( - use_label in adata.obs.columns + use_label in adata.obs.columns ), "Please run `stlearn.spatial.cluster.localization` function!" SubClusterPlot( diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/plotting/trajectory/DE_transition_plot.py index c70bfd6e..55275f6b 100644 --- a/stlearn/plotting/trajectory/DE_transition_plot.py +++ b/stlearn/plotting/trajectory/DE_transition_plot.py @@ -5,12 +5,12 @@ def DE_transition_plot( - adata: AnnData, - top_genes: int = 10, - font_size: int = 6, - name: str = None, - dpi: int = 150, - output: str = None, + adata: AnnData, + top_genes: int = 10, + font_size: int = 6, + name: str = None, + dpi: int = 150, + output: str = None, ) -> AnnData | None: """\ Differential expression between transition markers. diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/plotting/trajectory/check_trajectory.py index 5cab6744..d9e84f12 100644 --- a/stlearn/plotting/trajectory/check_trajectory.py +++ b/stlearn/plotting/trajectory/check_trajectory.py @@ -1,4 +1,3 @@ - import matplotlib.pyplot as plt import numpy as np import scanpy as sc @@ -6,30 +5,30 @@ def check_trajectory( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - basis: str = "umap", - pseudotime_key: str = "dpt_pseudotime", - trajectory: list = None, - figsize=(10, 4), - size_umap: int = 50, - size_spatial: int = 1.5, - img_key: str = "hires", + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + basis: str = "umap", + pseudotime_key: str = "dpt_pseudotime", + trajectory: list = None, + figsize=(10, 4), + size_umap: int = 50, + size_spatial: int = 1.5, + img_key: str = "hires", ) -> AnnData | None: trajectory = np.array(trajectory).astype(int) assert ( - trajectory in adata.uns["available_paths"].values() + trajectory in adata.uns["available_paths"].values() ), "Please choose the right path!" trajectory = trajectory.astype(str) assert ( - pseudotime_key in adata.obs.columns + pseudotime_key in adata.obs.columns ), "Please run the pseudotime or choose the right one!" assert ( - use_label in adata.obs.columns + use_label in adata.obs.columns ), "Please run the cluster or choose the right label!" assert basis in adata.obsm, ( - "Please run the " + basis + "before you check the trajectory!" + "Please run the " + basis + "before you check the trajectory!" ) if library_id is None: library_id = list(adata.uns["spatial"].keys())[0] @@ -66,9 +65,7 @@ def check_trajectory( show=False, ) - ax2.imshow( - adata.uns["spatial"][library_id]["images"][img_key], alpha=0, zorder=-1 - ) + ax2.imshow(adata.uns["spatial"][library_id]["images"][img_key], alpha=0, zorder=-1) plt.show() diff --git a/stlearn/plotting/trajectory/local_plot.py b/stlearn/plotting/trajectory/local_plot.py index f1c0c7a6..de9a9bca 100644 --- a/stlearn/plotting/trajectory/local_plot.py +++ b/stlearn/plotting/trajectory/local_plot.py @@ -1,4 +1,3 @@ - import matplotlib.pyplot as plt import numpy as np from anndata import AnnData @@ -7,22 +6,22 @@ def local_plot( - adata: AnnData, - use_label: str = "louvain", - use_cluster: int = None, - reverse: bool = False, - cluster: int = 0, - data_alpha: float = 1.0, - arrow_alpha: float = 1.0, - branch_alpha: float = 0.2, - spot_size: float | int = 1, - show_color_bar: bool = True, - show_axis: bool = False, - show_plot: bool = True, - name: str = None, - dpi: int = 150, - output: str = None, - copy: bool = False, + adata: AnnData, + use_label: str = "louvain", + use_cluster: int = None, + reverse: bool = False, + cluster: int = 0, + data_alpha: float = 1.0, + arrow_alpha: float = 1.0, + branch_alpha: float = 0.2, + spot_size: float | int = 1, + show_color_bar: bool = True, + show_axis: bool = False, + show_plot: bool = True, + name: str = None, + dpi: int = 150, + output: str = None, + copy: bool = False, ) -> AnnData | None: """\ Local spatial trajectory inference plot. @@ -76,8 +75,8 @@ def local_plot( order = 0 for i in ref_cluster.obs["sub_cluster_labels"].unique(): if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > adata.uns["threshold_spots"] + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > adata.uns["threshold_spots"] ): classes_.append(i) centroid_dict = adata.uns["centroid_dict"] diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 6a92528f..bbe1b098 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -1,4 +1,3 @@ - import matplotlib import networkx as nx import numpy as np @@ -9,31 +8,31 @@ def pseudotime_plot( - adata: AnnData, - library_id: str = None, - use_label: str = "louvain", - pseudotime_key: str = "pseudotime_key", - list_clusters: str | list = None, - cell_alpha: float = 1.0, - image_alpha: float = 1.0, - edge_alpha: float = 0.8, - node_alpha: float = 1.0, - spot_size: float | int = 6.5, - node_size: float = 5, - show_color_bar: bool = True, - show_axis: bool = False, - show_graph: bool = True, - show_trajectories: bool = False, - reverse: bool = False, - show_node: bool = True, - show_plot: bool = True, - cropped: bool = True, - margin: int = 100, - dpi: int = 150, - output: str = None, - name: str = None, - copy: bool = False, - ax=None, + adata: AnnData, + library_id: str = None, + use_label: str = "louvain", + pseudotime_key: str = "pseudotime_key", + list_clusters: str | list = None, + cell_alpha: float = 1.0, + image_alpha: float = 1.0, + edge_alpha: float = 0.8, + node_alpha: float = 1.0, + spot_size: float | int = 6.5, + node_size: float = 5, + show_color_bar: bool = True, + show_axis: bool = False, + show_graph: bool = True, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = True, + show_plot: bool = True, + cropped: bool = True, + margin: int = 100, + dpi: int = 150, + output: str = None, + name: str = None, + copy: bool = False, + ax=None, ) -> AnnData | None: """\ Global trajectory inference plot (Only DPT). diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/plotting/trajectory/transition_markers_plot.py index 267a6e64..6d2bf260 100644 --- a/stlearn/plotting/trajectory/transition_markers_plot.py +++ b/stlearn/plotting/trajectory/transition_markers_plot.py @@ -5,12 +5,12 @@ def transition_markers_plot( - adata: AnnData, - top_genes: int = 10, - trajectory: str = None, - dpi: int = 150, - output: str = None, - name: str = None, + adata: AnnData, + top_genes: int = 10, + trajectory: str = None, + dpi: int = 150, + output: str = None, + name: str = None, ) -> AnnData | None: """\ Plot transition marker. diff --git a/stlearn/plotting/trajectory/tree_plot.py b/stlearn/plotting/trajectory/tree_plot.py index c7999321..71992608 100644 --- a/stlearn/plotting/trajectory/tree_plot.py +++ b/stlearn/plotting/trajectory/tree_plot.py @@ -9,22 +9,22 @@ def tree_plot( - adata: AnnData, - library_id: str = None, - figsize: float | int = (10, 4), - data_alpha: float = 1.0, - use_label: str = "louvain", - spot_size: float | int = 50, - fontsize: int = 6, - piesize: float = 0.15, - zoom: float = 0.1, - name: str = None, - output: str = None, - dpi: int = 180, - show_all: bool = False, - show_plot: bool = True, - ncols: int = 4, - copy: bool = False, + adata: AnnData, + library_id: str = None, + figsize: float | int = (10, 4), + data_alpha: float = 1.0, + use_label: str = "louvain", + spot_size: float | int = 50, + fontsize: int = 6, + piesize: float = 0.15, + zoom: float = 0.1, + name: str = None, + output: str = None, + dpi: int = 180, + show_all: bool = False, + show_plot: bool = True, + ncols: int = 4, + copy: bool = False, ) -> AnnData | None: """\ Hierarchical tree plot represent for the global spatial trajectory inference. @@ -144,8 +144,7 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 root = random.choice(list(G.nodes)) def _hierarchy_pos( - G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, - parent=None + G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, parent=None ): """ see hierarchy_pos docstring for most arguments diff --git a/stlearn/plotting/trajectory/tree_plot_simple.py b/stlearn/plotting/trajectory/tree_plot_simple.py index 84c0882f..92e0cb07 100644 --- a/stlearn/plotting/trajectory/tree_plot_simple.py +++ b/stlearn/plotting/trajectory/tree_plot_simple.py @@ -9,22 +9,22 @@ def tree_plot_simple( - adata: AnnData, - library_id: str = None, - figsize: float | int = (10, 4), - data_alpha: float = 1.0, - use_label: str = "louvain", - spot_size: float | int = 50, - fontsize: int = 6, - piesize: float = 0.15, - zoom: float = 0.1, - name: str = None, - output: str = None, - dpi: int = 180, - show_all: bool = False, - show_plot: bool = True, - ncols: int = 4, - copy: bool = False, + adata: AnnData, + library_id: str = None, + figsize: float | int = (10, 4), + data_alpha: float = 1.0, + use_label: str = "louvain", + spot_size: float | int = 50, + fontsize: int = 6, + piesize: float = 0.15, + zoom: float = 0.1, + name: str = None, + output: str = None, + dpi: int = 180, + show_all: bool = False, + show_plot: bool = True, + ncols: int = 4, + copy: bool = False, ) -> AnnData | None: """\ Hierarchical tree plot represent for the global spatial trajectory inference. @@ -144,8 +144,7 @@ def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5 root = random.choice(list(G.nodes)) def _hierarchy_pos( - G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, - parent=None + G, root, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5, pos=None, parent=None ): """ see hierarchy_pos docstring for most arguments diff --git a/stlearn/plotting/utils.py b/stlearn/plotting/utils.py index 9c5eecf0..6304d740 100644 --- a/stlearn/plotting/utils.py +++ b/stlearn/plotting/utils.py @@ -33,10 +33,10 @@ def centroidpython(x, y): def get_cluster(search, dictionary): for ( - cl, - sub, + cl, + sub, ) in ( - dictionary.items() + dictionary.items() ): # for name, age in dictionary.iteritems(): (for Python 2.x) if search in sub: return cl @@ -91,8 +91,8 @@ def check_cmap(cmap): stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) if cmap is str: assert cmap in cmap_available, error_msg diff --git a/stlearn/preprocessing/filter_genes.py b/stlearn/preprocessing/filter_genes.py index 260773bc..42cc3d24 100644 --- a/stlearn/preprocessing/filter_genes.py +++ b/stlearn/preprocessing/filter_genes.py @@ -1,16 +1,15 @@ - import numpy as np import scanpy from anndata import AnnData def filter_genes( - adata: AnnData, - min_counts: int | None = None, - min_cells: int | None = None, - max_counts: int | None = None, - max_cells: int | None = None, - inplace: bool = True, + adata: AnnData, + min_counts: int | None = None, + min_cells: int | None = None, + max_counts: int | None = None, + max_cells: int | None = None, + inplace: bool = True, ) -> AnnData | None | tuple[np.ndarray, np.ndarray]: """\ Wrap function scanpy.pp.filter_genes diff --git a/stlearn/preprocessing/graph.py b/stlearn/preprocessing/graph.py index 6d6334dd..4330abbd 100644 --- a/stlearn/preprocessing/graph.py +++ b/stlearn/preprocessing/graph.py @@ -38,16 +38,16 @@ def neighbors( - adata: AnnData, - n_neighbors: int = 15, - n_pcs: int | None = None, - use_rep: str | None = None, - knn: bool = True, - random_state: int | RandomState | None = 0, - method: _Method | None = "umap", - metric: _Metric | _MetricFn = "euclidean", - metric_kwds: Mapping[str, Any] = MappingProxyType({}), - copy: bool = False, + adata: AnnData, + n_neighbors: int = 15, + n_pcs: int | None = None, + use_rep: str | None = None, + knn: bool = True, + random_state: int | RandomState | None = 0, + method: _Method | None = "umap", + metric: _Metric | _MetricFn = "euclidean", + metric_kwds: Mapping[str, Any] = MappingProxyType({}), + copy: bool = False, ) -> AnnData | None: """\ Compute a neighborhood graph of observations [McInnes18]_. diff --git a/stlearn/preprocessing/log_scale.py b/stlearn/preprocessing/log_scale.py index 4fb216d8..9ebb63e0 100644 --- a/stlearn/preprocessing/log_scale.py +++ b/stlearn/preprocessing/log_scale.py @@ -1,4 +1,3 @@ - import numpy as np import scanpy from anndata import AnnData @@ -6,11 +5,11 @@ def log1p( - adata: AnnData | np.ndarray | spmatrix, - copy: bool = False, - chunked: bool = False, - chunk_size: int | None = None, - base: float | None = None, + adata: AnnData | np.ndarray | spmatrix, + copy: bool = False, + chunked: bool = False, + chunk_size: int | None = None, + base: float | None = None, ) -> AnnData | None: """\ Wrap function of scanpy.pp.log1p @@ -45,10 +44,10 @@ def log1p( def scale( - adata: AnnData | np.ndarray | spmatrix, - zero_center: bool = True, - max_value: float | None = None, - copy: bool = False, + adata: AnnData | np.ndarray | spmatrix, + zero_center: bool = True, + max_value: float | None = None, + copy: bool = False, ) -> AnnData | None: """\ Wrap function of scanpy.pp.scale diff --git a/stlearn/preprocessing/normalize.py b/stlearn/preprocessing/normalize.py index 11fd6528..f604e4fe 100644 --- a/stlearn/preprocessing/normalize.py +++ b/stlearn/preprocessing/normalize.py @@ -8,14 +8,14 @@ def normalize_total( - adata: AnnData, - target_sum: float | None = None, - exclude_highly_expressed: bool = False, - max_fraction: float = 0.05, - key_added: str | None = None, - layers: Literal["all"] | Iterable[str] = None, - layer_norm: str | None = None, - inplace: bool = True, + adata: AnnData, + target_sum: float | None = None, + exclude_highly_expressed: bool = False, + max_fraction: float = 0.05, + key_added: str | None = None, + layers: Literal["all"] | Iterable[str] = None, + layer_norm: str | None = None, + inplace: bool = True, ) -> dict[str, np.ndarray] | None: """\ Wrap function from scanpy.pp.log1p diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatials/SME/_weighting_matrix.py index bd9c0bba..fcaa2f78 100644 --- a/stlearn/spatials/SME/_weighting_matrix.py +++ b/stlearn/spatials/SME/_weighting_matrix.py @@ -1,4 +1,3 @@ - import numpy as np from anndata import AnnData from sklearn.metrics import pairwise_distances diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatials/SME/impute.py index 109814bd..31f16467 100644 --- a/stlearn/spatials/SME/impute.py +++ b/stlearn/spatials/SME/impute.py @@ -18,11 +18,11 @@ def SME_impute0( - adata: AnnData, - use_data: str = "raw", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - platform: _PLATFORM = "Visium", - copy: bool = False, + adata: AnnData, + use_data: str = "raw", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + platform: _PLATFORM = "Visium", + copy: bool = False, ) -> AnnData | None: """\ using spatial location (S), tissue morphological feature (M) and gene @@ -87,13 +87,13 @@ def SME_impute0( def pseudo_spot( - adata: AnnData, - tile_path: Path | str = Path("/tmp/tiles"), - use_data: str = "raw", - crop_size: int = "auto", - platform: _PLATFORM = "Visium", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - copy: _COPY = "pseudo_spot_adata", + adata: AnnData, + tile_path: Path | str = Path("/tmp/tiles"), + use_data: str = "raw", + crop_size: int = "auto", + platform: _PLATFORM = "Visium", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + copy: _COPY = "pseudo_spot_adata", ) -> AnnData | None: """\ using spatial location (S), tissue morphological feature (M) and gene @@ -249,10 +249,10 @@ def pseudo_spot( reg_col = LinearRegression().fit(array_col.values.reshape(-1, 1), img_col) obs_df.loc[:, "imagerow"] = ( - obs_df.loc[:, "array_row"] * reg_row.coef_ + reg_row.intercept_ + obs_df.loc[:, "array_row"] * reg_row.coef_ + reg_row.intercept_ ) obs_df.loc[:, "imagecol"] = ( - obs_df.loc[:, "array_col"] * reg_col.coef_ + reg_col.intercept_ + obs_df.loc[:, "array_col"] * reg_col.coef_ + reg_col.intercept_ ) impute_coor = obs_df[["imagecol", "imagerow"]] @@ -260,7 +260,7 @@ def pseudo_spot( point_tree = scipy.spatial.cKDTree(coor) n_neighbour = [] - unit = math.sqrt(reg_row.coef_ ** 2 + reg_col.coef_ ** 2) + unit = math.sqrt(reg_row.coef_**2 + reg_col.coef_**2) for i in range(len(impute_coor)): current_neighbour = point_tree.query_ball_point( impute_coor.values[i], round(unit) @@ -319,9 +319,9 @@ def pseudo_spot( def _merge( - adata1: AnnData, - adata2: AnnData, - copy: bool = True, + adata1: AnnData, + adata2: AnnData, + copy: bool = True, ) -> AnnData | None: merged_df = adata1.to_df().append(adata2.to_df()) merged_df_obs = adata1.obs.append(adata2.obs) diff --git a/stlearn/spatials/SME/normalize.py b/stlearn/spatials/SME/normalize.py index 38849d37..04ef41e4 100644 --- a/stlearn/spatials/SME/normalize.py +++ b/stlearn/spatials/SME/normalize.py @@ -1,4 +1,3 @@ - import numpy as np import pandas as pd from anndata import AnnData @@ -13,11 +12,11 @@ def SME_normalize( - adata: AnnData, - use_data: str = "raw", - weights: _WEIGHTING_MATRIX = "weights_matrix_all", - platform: _PLATFORM = "Visium", - copy: bool = False, + adata: AnnData, + use_data: str = "raw", + weights: _WEIGHTING_MATRIX = "weights_matrix_all", + platform: _PLATFORM = "Visium", + copy: bool = False, ) -> AnnData | None: """\ using spatial location (S), tissue morphological feature (M) and gene diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index aaba5d66..1a9c2e3e 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -1,4 +1,3 @@ - import numpy as np import pandas as pd from anndata import AnnData @@ -7,11 +6,11 @@ def localization( - adata: AnnData, - use_label: str = "louvain", - eps: int = 20, - min_samples: int = 0, - copy: bool = False, + adata: AnnData, + use_label: str = "louvain", + eps: int = 20, + min_samples: int = 0, + copy: bool = False, ) -> AnnData | None: """\ Perform local cluster by using DBSCAN. diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 039ce5f9..1305a3bb 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -1,4 +1,3 @@ - import numpy as np import scipy.spatial as spatial from anndata import AnnData diff --git a/stlearn/spatials/smooth/disk.py b/stlearn/spatials/smooth/disk.py index dabf0450..970d1843 100644 --- a/stlearn/spatials/smooth/disk.py +++ b/stlearn/spatials/smooth/disk.py @@ -1,16 +1,15 @@ - import numpy as np import scipy.spatial as spatial from anndata import AnnData def disk( - adata: AnnData, - use_data: str = "X_umap", - radius: float = 10.0, - rates: int = 1, - method: str = "mean", - copy: bool = False, + adata: AnnData, + use_data: str = "X_umap", + radius: float = 10.0, + rates: int = 1, + method: str = "mean", + copy: bool = False, ) -> AnnData | None: coor = adata.obs[["imagecol", "imagerow"]] count_embed = adata.obsm[use_data] @@ -46,8 +45,8 @@ def disk( adata.obsm[new_embed] = np.array(lag_coor) print( - 'Disk smoothing function is applied! The new data are stored in ' + - 'adata.obsm["X_diffmap_disk"]' + "Disk smoothing function is applied! The new data are stored in " + + 'adata.obsm["X_diffmap_disk"]' ) return adata if copy else None diff --git a/stlearn/spatials/trajectory/detect_transition_markers.py b/stlearn/spatials/trajectory/detect_transition_markers.py index d703894b..b538d147 100644 --- a/stlearn/spatials/trajectory/detect_transition_markers.py +++ b/stlearn/spatials/trajectory/detect_transition_markers.py @@ -10,12 +10,12 @@ def detect_transition_markers_clades( - adata, - clade, - cutoff_spearman=0.4, - cutoff_pvalue=0.05, - screening_genes=None, - use_raw_count=False, + adata, + clade, + cutoff_spearman=0.4, + cutoff_pvalue=0.05, + screening_genes=None, + use_raw_count=False, ): """\ Transition markers detection of a clade. @@ -69,7 +69,7 @@ def detect_transition_markers_clades( ) negative = spearman_result[ spearman_result["score"] <= cutoff_spearman * (-1) - ].sort_values("score") + ].sort_values("score") result = pd.concat([positive, negative]) @@ -81,12 +81,12 @@ def detect_transition_markers_clades( def detect_transition_markers_branches( - adata, - branch, - cutoff_spearman=0.4, - cutoff_pvalue=0.05, - screening_genes=None, - use_raw_count=False, + adata, + branch, + cutoff_spearman=0.4, + cutoff_pvalue=0.05, + screening_genes=None, + use_raw_count=False, ): """\ Transition markers detection of a branch. @@ -126,7 +126,7 @@ def detect_transition_markers_branches( ) negative = spearman_result[ spearman_result["score"] <= cutoff_spearman * (-1) - ].sort_values("score") + ].sort_values("score") result = pd.concat([positive, negative]) diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index 8d7b4493..0cf96464 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -1,4 +1,3 @@ - import networkx as nx import numpy as np from anndata import AnnData @@ -8,15 +7,15 @@ def global_level( - adata: AnnData, - use_label: str = "louvain", - use_rep: str = "X_pca", - n_dims: int = 40, - list_clusters: list = [], - return_graph: bool = False, - w: float = None, - verbose: bool = True, - copy: bool = False, + adata: AnnData, + use_label: str = "louvain", + use_rep: str = "X_pca", + n_dims: int = 40, + list_clusters: list = [], + return_graph: bool = False, + w: float = None, + verbose: bool = True, + copy: bool = False, ) -> AnnData | None: """\ Perform global sptial trajectory inference. @@ -114,8 +113,8 @@ def global_level( H_sub = H.edge_subgraph(edge_list) if not nx.is_connected(H_sub.to_undirected()): raise ValueError( - "The chosen clusters are not available to construct the spatial " + - "trajectory! Please choose other path." + "The chosen clusters are not available to construct the spatial " + + "trajectory! Please choose other path." ) H_sub = nx.DiGraph(H_sub) prepare_root = [] @@ -268,6 +267,7 @@ def ge_distance_matrix(adata, cluster1, cluster2, use_label, use_rep, n_dims): return scale_sdm + # def _density_normalize(other: Union[np.ndarray, spmatrix] # ) -> Union[np.ndarray, spmatrix]: # """ diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index a5dacb76..bd791312 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -1,17 +1,16 @@ - import numpy as np from anndata import AnnData from scipy.spatial.distance import cdist def local_level( - adata: AnnData, - use_label: str = "louvain", - cluster: int = 9, - w: float = 0.5, - return_matrix: bool = False, - verbose: bool = True, - copy: bool = False, + adata: AnnData, + use_label: str = "louvain", + cluster: int = 9, + w: float = 0.5, + return_matrix: bool = False, + verbose: bool = True, + copy: bool = False, ) -> AnnData | None: """\ Perform local sptial trajectory inference (required run pseudotime first). @@ -49,8 +48,8 @@ def local_level( centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} for i in list_cluster: if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > adata.uns["threshold_spots"] + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > adata.uns["threshold_spots"] ): dpt.append( cluster_data.obs[cluster_data.obs["sub_cluster_labels"] == i][ diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 8ae61b74..035556b4 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -1,4 +1,3 @@ - import networkx as nx import numpy as np import pandas as pd @@ -7,21 +6,21 @@ def pseudotime( - adata: AnnData, - use_label: str = None, - eps: float = 20, - n_neighbors: int = 25, - use_rep: str = "X_pca", - threshold: float = 0.01, - radius: int = 50, - method: str = "mean", - threshold_spots: int = 5, - use_sme: bool = False, - reverse: bool = False, - pseudotime_key: str = "dpt_pseudotime", - max_nodes: int = 4, - run_knn: bool = False, - copy: bool = False, + adata: AnnData, + use_label: str = None, + eps: float = 20, + n_neighbors: int = 25, + use_rep: str = "X_pca", + threshold: float = 0.01, + radius: int = 50, + method: str = "mean", + threshold_spots: int = 5, + use_sme: bool = False, + reverse: bool = False, + pseudotime_key: str = "dpt_pseudotime", + max_nodes: int = 4, + run_knn: bool = False, + copy: bool = False, ) -> AnnData | None: """\ Perform pseudotime analysis. @@ -113,8 +112,8 @@ def pseudotime( "sub_cluster_labels" ].unique(): if ( - len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) - > threshold_spots + len(adata.obs[adata.obs["sub_cluster_labels"] == str(i)]) + > threshold_spots ): meaningful_sub.append(i) @@ -243,6 +242,8 @@ def store_available_paths(adata, threshold, use_label, max_nodes, pseudotime_key adata.uns["available_paths"] = all_paths print( - "All available trajectory paths are stored in adata.uns['available_paths'] " + - "with length < " + str(max_nodes) + " nodes" + "All available trajectory paths are stored in adata.uns['available_paths'] " + + "with length < " + + str(max_nodes) + + " nodes" ) diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index 7f362c1d..2214c401 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -1,4 +1,3 @@ - from anndata import AnnData from .global_level import global_level @@ -7,14 +6,14 @@ def pseudotimespace_global( - adata: AnnData, - use_label: str = "louvain", - use_rep: str = "X_pca", - n_dims: int = 40, - list_clusters=None, - model: str = "spatial", - step=0.01, - k=10, + adata: AnnData, + use_label: str = "louvain", + use_rep: str = "X_pca", + n_dims: int = 40, + list_clusters=None, + model: str = "spatial", + step=0.01, + k=10, ) -> AnnData | None: """\ Perform pseudo-time-space analysis with global level. @@ -56,8 +55,8 @@ def pseudotimespace_global( w = 1 else: raise ValueError( - "Please choose the right model! Available models: 'mixed', 'spatial' " + - "and 'gene_expression' " + "Please choose the right model! Available models: 'mixed', 'spatial' " + + "and 'gene_expression' " ) global_level( @@ -71,10 +70,10 @@ def pseudotimespace_global( def pseudotimespace_local( - adata: AnnData, - use_label: str = "louvain", - cluster=None, - w: float = None, + adata: AnnData, + use_label: str = "louvain", + cluster=None, + w: float = None, ) -> AnnData | None: """\ Perform pseudo-time-space analysis with local level. diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index 88f8251e..6d232589 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -28,8 +28,8 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False # Subset the data based on the chosen cluster tmp_adata = tmp_adata[ - tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : - ] + tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : + ] if use_raw: tmp_adata = tmp_adata.raw.to_adata() diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index 8381cc74..1c06cc51 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -61,7 +61,7 @@ def lambda_dist(A1, A2, k=None, p=2, kind="laplacian"): def resistance_distance( - A1, A2, p=2, renormalized=False, attributed=False, check_connected=True, beta=1 + A1, A2, p=2, renormalized=False, attributed=False, check_connected=True, beta=1 ): """Compare two graphs using resistance distance (possibly renormalized). Parameters @@ -316,6 +316,7 @@ class UndefinedException(Exception): # Resistance matrix. Renormalized version, as well as conductance and commute matrices. # """ + def resistance_matrix(A, check_connected=True): """Return the resistance matrix of G. Parameters @@ -513,8 +514,8 @@ def conductance_matrix(A): # CytoTrace wrapper def _mat_mat_corr_sparse( - X: csr_matrix, - Y: np.ndarray, + X: csr_matrix, + Y: np.ndarray, ) -> np.ndarray: """\ This function is borrow from cellrank @@ -522,8 +523,7 @@ def _mat_mat_corr_sparse( n = X.shape[1] X_bar = np.reshape(np.array(X.mean(axis=1)), (-1, 1)) - X_std = np.reshape(np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar ** 2)), - (-1, 1)) + X_std = np.reshape(np.sqrt(np.array(X.power(2).mean(axis=1)) - (X_bar**2)), (-1, 1)) y_bar = np.reshape(np.mean(Y, axis=0), (1, -1)) y_std = np.reshape(np.std(Y, axis=0), (1, -1)) @@ -536,12 +536,12 @@ def _mat_mat_corr_sparse( def _correlation_test_helper( - X: np.ndarray | spmatrix, - Y: np.ndarray, - n_perms: int | None = None, - seed: int | None = None, - confidence_level: float = 0.95, - **kwargs, + X: np.ndarray | spmatrix, + Y: np.ndarray, + n_perms: int | None = None, + seed: int | None = None, + confidence_level: float = 0.95, + **kwargs, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ This function is borrow from cellrank. @@ -569,7 +569,7 @@ def _correlation_test_helper( """ def perm_test_extractor( - res: Sequence[tuple[np.ndarray, np.ndarray]], + res: Sequence[tuple[np.ndarray, np.ndarray]], ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: pvals, corr_bs = zip(*res) pvals = np.sum(pvals, axis=0) / float(n_perms) @@ -583,8 +583,8 @@ def perm_test_extractor( if not (0 <= confidence_level <= 1): raise ValueError( - "Expected `confidence_level` to be in interval `[0, 1]`, " + - f"found `{confidence_level}`." + "Expected `confidence_level` to be in interval `[0, 1]`, " + + f"found `{confidence_level}`." ) n = X.shape[1] # genes x cells diff --git a/stlearn/spatials/trajectory/weight_optimization.py b/stlearn/spatials/trajectory/weight_optimization.py index 11e09e9d..9787d628 100644 --- a/stlearn/spatials/trajectory/weight_optimization.py +++ b/stlearn/spatials/trajectory/weight_optimization.py @@ -9,13 +9,13 @@ def weight_optimizing_global( - adata, - use_label=None, - list_clusters=None, - step=0.01, - k=10, - use_rep="X_pca", - n_dims=40, + adata, + use_label=None, + list_clusters=None, + step=0.01, + k=10, + use_rep="X_pca", + n_dims=40, ): # Screening PTS graph print("Screening PTS global graph...") @@ -23,9 +23,9 @@ def weight_optimizing_global( j = 0 with tqdm( - total=int(1 / step + 1), - desc="Screening", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step + 1), + desc="Screening", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): Gs.append( @@ -59,9 +59,9 @@ def weight_optimizing_global( ].unique() ) with tqdm( - total=int(1 / step - 1), - desc="Calculating", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step - 1), + desc="Calculating", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(1, int(1 / step)): w += step @@ -100,9 +100,9 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): Gs = [] j = 0 with tqdm( - total=int(1 / step + 1), - desc="Screening", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step + 1), + desc="Screening", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): Gs.append( @@ -128,9 +128,9 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): w = 0 with tqdm( - total=int(1 / step - 1), - desc="Calculating", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=int(1 / step - 1), + desc="Calculating", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(1, int(1 / step)): w += step diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tools/clustering/kmeans.py index c436e3c2..398e4184 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tools/clustering/kmeans.py @@ -1,4 +1,3 @@ - import numpy as np import pandas as pd from anndata import AnnData @@ -7,18 +6,18 @@ def kmeans( - adata: AnnData, - n_clusters: int = 20, - use_data: str = "X_pca", - init: str = "k-means++", - n_init: int = 10, - max_iter: int = 300, - tol: float = 0.0001, - random_state: str = None, - copy_x: bool = True, - algorithm: str = "auto", - key_added: str = "kmeans", - copy: bool = False, + adata: AnnData, + n_clusters: int = 20, + use_data: str = "X_pca", + init: str = "k-means++", + n_init: int = 10, + max_iter: int = 300, + tol: float = 0.0001, + random_state: str = None, + copy_x: bool = True, + algorithm: str = "auto", + key_added: str = "kmeans", + copy: bool = False, ) -> AnnData | None: """\ Perform kmeans cluster for spatial transcriptomics data diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 87d7700d..ba52ae47 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -20,18 +20,18 @@ class MutableVertexPartition: def louvain( - adata: AnnData, - resolution: float | None = None, - random_state: int | RandomState | None = 0, - restrict_to: tuple[str, Sequence[str]] | None = None, - key_added: str = "louvain", - adjacency: spmatrix | None = None, - flavor: Literal["vtraag", "igraph", "rapids"] = "vtraag", # noqa: F821 - directed: bool = True, - use_weights: bool = False, - partition_type: type[MutableVertexPartition] | None = None, - partition_kwargs: Mapping[str, Any] = MappingProxyType({}), - copy: bool = False, + adata: AnnData, + resolution: float | None = None, + random_state: int | RandomState | None = 0, + restrict_to: tuple[str, Sequence[str]] | None = None, + key_added: str = "louvain", + adjacency: spmatrix | None = None, + flavor: Literal["vtraag", "igraph", "rapids"] = "vtraag", # noqa: F821 + directed: bool = True, + use_weights: bool = False, + partition_type: type[MutableVertexPartition] | None = None, + partition_kwargs: Mapping[str, Any] = MappingProxyType({}), + copy: bool = False, ) -> AnnData | None: """\ Wrap function scanpy.tl.louvain diff --git a/stlearn/tools/label/label.py b/stlearn/tools/label/label.py index 9dea9a20..8e95039a 100644 --- a/stlearn/tools/label/label.py +++ b/stlearn/tools/label/label.py @@ -11,8 +11,7 @@ def run_label_transfer( - st_data, sc_data, sc_label_col, r_path, st_label_col=None, - n_highly_variable=2000 + st_data, sc_data, sc_label_col, r_path, st_label_col=None, n_highly_variable=2000 ): """Runs Seurat label transfer.""" st_label_col = sc_label_col if st_label_col is None else st_label_col @@ -96,15 +95,15 @@ def get_counts(data): elif data.X is np.ndarray and np.all(np.mod(data.X[0, :], 1) == 0): counts = data.to_df().transpose() elif ( - data.X is not np.ndarray - and hasattr(data, "raw") - and np.all(np.mod(data.raw.X[0, :].todense(), 1) == 0) + data.X is not np.ndarray + and hasattr(data, "raw") + and np.all(np.mod(data.raw.X[0, :].todense(), 1) == 0) ): counts = data.raw.to_adata()[data.obs_names, data.var_names].to_df().transpose() elif ( - data.X is np.ndarray - and hasattr(data, "raw") - and np.all(np.mod(data.raw.X[0, :], 1) == 0) + data.X is np.ndarray + and hasattr(data, "raw") + and np.all(np.mod(data.raw.X[0, :], 1) == 0) ): counts = data.raw.to_adata()[data.obs_names, data.var_names].to_df().transpose() else: @@ -117,15 +116,15 @@ def get_counts(data): def run_rctd( - st_data, - sc_data, - sc_label_col, - r_path, - st_label_col=None, - n_highly_variable=5000, - min_cells=10, - doublet_mode="full", - n_cores=1, + st_data, + sc_data, + sc_label_col, + r_path, + st_label_col=None, + n_highly_variable=5000, + min_cells=10, + doublet_mode="full", + n_cores=1, ): """Runs RCTD for deconvolution.""" st_label_col = sc_label_col if st_label_col is None else st_label_col @@ -202,23 +201,23 @@ def run_rctd( st_data_orig.obs[st_label_col] = labels st_data_orig.obs[st_label_col] = st_data_orig.obs[st_label_col].astype("category") st_data_orig.uns[st_label_col] = rctd_proportions.loc[ - st_data_orig.obs_names.values, : - ] + st_data_orig.obs_names.values, : + ] print(f"Spot labels added to st_data.obs[{st_label_col}].") print(f"Spot label scores added to st_data.uns[{st_label_col}].") def run_singleR( - st_data, - sc_data, - sc_label_col, - r_path, - st_label_col=None, - n_highly_variable=5000, - n_centers=3, - de_n=200, - de_method="t", + st_data, + sc_data, + sc_label_col, + r_path, + st_label_col=None, + n_highly_variable=5000, + n_centers=3, + de_n=200, + de_method="t", ): """Runs SingleR spot annotation.""" st_label_col = sc_label_col if st_label_col is None else st_label_col diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 81ec454d..16d21cad 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -686,8 +686,8 @@ def run_cci( lr_n_cci_sig = np.zeros(lr_summary.shape[0]) with tqdm( total=len(best_lrs), - desc="Counting celltype-celltype interactions per LR and permuting " + - f"{n_perms} times.", + desc="Counting celltype-celltype interactions per LR and permuting " + + f"{n_perms} times.", bar_format="{l_bar}{bar} [ time left: {remaining} ]", disable=verbose is False, ) as pbar: diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index 6f88750e..edbc5c66 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -10,12 +10,12 @@ def lr( - adata: AnnData, - use_lr: str = "cci_lr", - distance: float = None, - verbose: bool = True, - neighbours: list = None, - fast: bool = True, + adata: AnnData, + use_lr: str = "cci_lr", + distance: float = None, + verbose: bool = True, + neighbours: list = None, + fast: bool = True, ) -> AnnData: """Calculate the proportion of known ligand-receptor co-expression among the neighbouring spots or within spots @@ -92,24 +92,23 @@ def calc_distance(adata: AnnData, distance: float): scalefactors = next(iter(adata.uns["spatial"].values()))["scalefactors"] library_id = list(adata.uns["spatial"].keys())[0] distance = ( - scalefactors["spot_diameter_fullres"] - * scalefactors[ - "tissue_" + adata.uns["spatial"][library_id][ - "use_quality"] + "_scalef" - ] - * 2 + scalefactors["spot_diameter_fullres"] + * scalefactors[ + "tissue_" + adata.uns["spatial"][library_id]["use_quality"] + "_scalef" + ] + * 2 ) return distance def get_lrs_scores( - adata: AnnData, - lrs: np.array, - neighbours: np.array, - het_vals: np.array, - min_expr: float, - filter_pairs: bool = True, - spot_indices: np.array = None, + adata: AnnData, + lrs: np.array, + neighbours: np.array, + het_vals: np.array, + min_expr: float, + filter_pairs: bool = True, + spot_indices: np.array = None, ): """Gets the scores for the indicated set of LR pairs & the heterogeneity values. Parameters @@ -145,7 +144,7 @@ def get_lrs_scores( if filter_pairs: lrs = np.array( [ - "_".join(spot_lr1s.columns.values[i: i + 2]) + "_".join(spot_lr1s.columns.values[i : i + 2]) for i in range(0, spot_lr1s.shape[1], 2) ] ) @@ -162,10 +161,10 @@ def get_lrs_scores( def get_spot_lrs( - adata: AnnData, - lr_pairs: list, - lr_order: bool, - filter_pairs: bool = True, + adata: AnnData, + lr_pairs: list, + lr_order: bool, + filter_pairs: bool = True, ): """ Parameters @@ -204,10 +203,10 @@ def get_spot_lrs( def calc_neighbours( - adata: AnnData, - distance: float = None, - index: bool = True, - verbose: bool = True, + adata: AnnData, + distance: float = None, + index: bool = True, + verbose: bool = True, ) -> List: """Calculate the proportion of known ligand-receptor co-expression among the neighbouring spots or within spots @@ -273,11 +272,11 @@ def calc_neighbours( @njit def lr_core( - spot_lr1: np.ndarray, - spot_lr2: np.ndarray, - neighbours: List, - min_expr: float, - spot_indices: np.array, + spot_lr1: np.ndarray, + spot_lr2: np.ndarray, + neighbours: List, + min_expr: float, + spot_indices: np.array, ) -> np.ndarray: """Calculate the lr scores for each spot. Parameters @@ -307,17 +306,17 @@ def lr_core( nb_lr2[i, :] = nb_expr_mean scores = ( - spot_lr1[spot_indices, :] * (nb_lr2 > min_expr) - + (spot_lr1[spot_indices, :] > min_expr) * nb_lr2 + spot_lr1[spot_indices, :] * (nb_lr2 > min_expr) + + (spot_lr1[spot_indices, :] > min_expr) * nb_lr2 ) spot_lr = scores.sum(axis=1) return spot_lr / 2 def lr_pandas( - spot_lr1: np.ndarray, - spot_lr2: np.ndarray, - neighbours: list, + spot_lr1: np.ndarray, + spot_lr2: np.ndarray, + neighbours: list, ) -> np.ndarray: """Calculate the lr scores for each spot. Parameters @@ -363,12 +362,12 @@ def mean_lr2(x): @njit(parallel=True) def get_scores( - spot_lr1s: np.ndarray, - spot_lr2s: np.ndarray, - neighbours: List, - het_vals: np.array, - min_expr: float, - spot_indices: np.array, + spot_lr1s: np.ndarray, + spot_lr2s: np.ndarray, + neighbours: List, + het_vals: np.array, + min_expr: float, + spot_indices: np.array, ) -> np.array: """Calculates the scores. Parameters @@ -391,7 +390,7 @@ def get_scores( spot_scores = np.zeros((len(spot_indices), spot_lr1s.shape[1] // 2), np.float64) for i in prange(0, spot_lr1s.shape[1] // 2): i_ = i * 2 # equivalent to range(0, spot_lr1s.shape[1], 2) - spot_lr1, spot_lr2 = spot_lr1s[:, i_: (i_ + 2)], spot_lr2s[:, i_: (i_ + 2)] + spot_lr1, spot_lr2 = spot_lr1s[:, i_ : (i_ + 2)], spot_lr2s[:, i_ : (i_ + 2)] lr_scores = lr_core(spot_lr1, spot_lr2, neighbours, min_expr, spot_indices) # The merge scores # lr_scores = np.multiply(het_vals[spot_indices], lr_scores) @@ -400,12 +399,12 @@ def get_scores( def lr_grid( - adata: AnnData, - num_row: int = 10, - num_col: int = 10, - use_lr: str = "cci_lr_grid", - radius: int = 1, - verbose: bool = True, + adata: AnnData, + num_row: int = 10, + num_col: int = 10, + use_lr: str = "cci_lr_grid", + radius: int = 1, + verbose: bool = True, ) -> AnnData: """Calculate the proportion of known ligand-receptor co-expression among the neighbouring grids or within each grid @@ -451,7 +450,7 @@ def lr_grid( & (coor["imagecol"] < grid[0] + width) & (coor["imagerow"] < grid[1]) & (coor["imagerow"] > grid[1] - height) - ] + ] df_grid.loc[n] = df.loc[spots.index].sum() # expand the LR pairs list by swapping ligand-receptor positions diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index b8a05e7a..24201a71 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -14,14 +14,14 @@ def get_hotspots( - adata: AnnData, - lr_scores: np.ndarray, - lrs: np.array, - eps: float, - quantile=0.05, - verbose=True, - plot_diagnostics: bool = False, - show_plot: bool = False, + adata: AnnData, + lr_scores: np.ndarray, + lrs: np.array, + eps: float, + quantile=0.05, + verbose=True, + plot_diagnostics: bool = False, + show_plot: bool = False, ): """Determines the hotspots for the inputted scores by progressively setting more stringent cutoffs & cluster in space, chooses point which maximises number @@ -120,23 +120,23 @@ def get_hotspots( if verbose: print("\tSummary values of lrs in adata.uns['lr_summary'].") print( - "\tMatrix of lr scores in same order as the summary in " + - "adata.obsm['lr_scores']." + "\tMatrix of lr scores in same order as the summary in " + + "adata.obsm['lr_scores']." ) print("\tMatrix of the hotspot scores in adata.obsm['lr_hot_scores'].") print("\tMatrix of the mean LR cluster scores in adata.obsm['cluster_scores'].") def hotspot_core( - lr_scores, - lrs, - coors, - eps, - quantile, - plot_diagnostics=False, - adata=None, - verbose=True, - max_score=False, + lr_scores, + lrs, + coors, + eps, + quantile, + plot_diagnostics=False, + adata=None, + verbose=True, + max_score=False, ): """Made code for getting the hotspot information.""" score_copy = lr_scores.copy() @@ -159,10 +159,10 @@ def hotspot_core( # Determining the cutoffs for hotspots # with tqdm( - total=len(lrs), - desc="Removing background lr scores...", - bar_format="{l_bar}{bar}", - disable=verbose is False, + total=len(lrs), + desc="Removing background lr scores...", + bar_format="{l_bar}{bar}", + disable=verbose is False, ) as pbar: for i, lr_ in enumerate(lrs): lr_score_ = score_copy[i, :] @@ -221,17 +221,17 @@ def non_zero_mean(vals): def add_diagnostic_plots( - adata, - i, - lr_, - quant_lrs, - lr_quantiles, - lr_scores, - lr_hot_scores, - axes, - cutoffs, - n_clusters, - best_cutoff, + adata, + i, + lr_, + quant_lrs, + lr_quantiles, + lr_scores, + lr_hot_scores, + axes, + cutoffs, + n_clusters, + best_cutoff, ): """Adds diagnostic plots for the quantile LR pair to a figure to illustrate \ how the cutoff is functioning. diff --git a/stlearn/tools/microenv/cci/het_helpers.py b/stlearn/tools/microenv/cci/het_helpers.py index c1b76f9b..67386fc2 100644 --- a/stlearn/tools/microenv/cci/het_helpers.py +++ b/stlearn/tools/microenv/cci/het_helpers.py @@ -9,13 +9,13 @@ @njit def edge_core( - cell_data: np.ndarray, - cell_type_index: int, - neighbourhood_bcs: List, - neighbourhood_indices: List, - spot_indices: np.array = None, - neigh_bool: np.array = None, - cutoff: float = 0.2, + cell_data: np.ndarray, + cell_type_index: int, + neighbourhood_bcs: List, + neighbourhood_indices: List, + spot_indices: np.array = None, + neigh_bool: np.array = None, + cutoff: float = 0.2, ) -> np.array: """Gets the edges which connect inputted spots to neighbours of a given cell type. @@ -128,12 +128,12 @@ def init_edge_list(neighbourhood_bcs): @njit def get_between_spot_edge_array( - edge_list: List, - neighbourhood_bcs: List, - neighbourhood_indices: List, - neigh_bool: np.array, - cell_data: np.array, - cutoff: float = 0, + edge_list: List, + neighbourhood_bcs: List, + neighbourhood_indices: List, + neigh_bool: np.array, + cell_data: np.array, + cutoff: float = 0, ): """ Populates edge_list with edges linking spots with a valid neighbour \ of a given cell type. Validity of neighbour determined by neigh_bool, \ @@ -184,7 +184,7 @@ def add_unique_edges(edge_list, edge_starts, edge_ends): edge_startj, edge_endj = edge_starts[j], edge_ends[j] # Direction doesn't matter # if (edge_start == edge_startj and edge_end == edge_endj) or ( - edge_end == edge_startj and edge_start == edge_endj + edge_end == edge_startj and edge_start == edge_endj ): edge_added[j] = True @@ -261,12 +261,12 @@ def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): # @njit def get_neighbourhoods_FAST( - spot_bcs: np.array, - spot_neigh_bcs: np.ndarray, - n_spots: int, - str_dtype: str, - neigh_indices: np.array, - neigh_bcs: np.array, + spot_bcs: np.array, + spot_neigh_bcs: np.ndarray, + n_spots: int, + str_dtype: str, + neigh_indices: np.array, + neigh_bcs: np.array, ): """Gets the neighbourhood information, njit compiled.""" @@ -347,12 +347,12 @@ def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): def get_neighbourhoods_FAST( - spot_bcs: np.array, - spot_neigh_bcs: np.ndarray, - n_spots: int, - str_dtype: str, - neigh_indices: np.array, - neigh_bcs: np.array, + spot_bcs: np.array, + spot_neigh_bcs: np.ndarray, + n_spots: int, + str_dtype: str, + neigh_indices: np.array, + neigh_bcs: np.array, ): """Gets the neighbourhood information, njit compiled.""" diff --git a/stlearn/tools/microenv/cci/merge.py b/stlearn/tools/microenv/cci/merge.py index 15017fe1..4eb91dc4 100644 --- a/stlearn/tools/microenv/cci/merge.py +++ b/stlearn/tools/microenv/cci/merge.py @@ -3,10 +3,10 @@ def merge( - adata: AnnData, - use_lr: str = "cci_lr", - use_het: str = "cci_het", - verbose: bool = True, + adata: AnnData, + use_lr: str = "cci_lr", + use_het: str = "cci_het", + verbose: bool = True, ) -> AnnData: """Merge results from cell type heterogeneity and L-R cluster Parameters @@ -24,8 +24,8 @@ def merge( if verbose: print( - "Results of spatial interaction analysis has been written to " + - "adata.uns['merged']" + "Results of spatial interaction analysis has been written to " + + "adata.uns['merged']" ) return adata diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 6abebe31..79b118c3 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -19,20 +19,19 @@ # Newest method # def perform_spot_testing( - adata: AnnData, - lr_scores: np.ndarray, - lrs: np.array, - n_pairs: int, - neighbours: List, - het_vals: np.array, - min_expr: float, - adj_method: str = "fdr_bh", - pval_adj_cutoff: float = 0.05, - verbose: bool = True, - save_bg=False, - neg_binom=False, - quantiles=( - 0.5, 0.75, 0.85, 0.9, 0.95, 0.97, 0.98, 0.99, 0.995, 0.9975, 0.999, 1), + adata: AnnData, + lr_scores: np.ndarray, + lrs: np.array, + n_pairs: int, + neighbours: List, + het_vals: np.array, + min_expr: float, + adj_method: str = "fdr_bh", + pval_adj_cutoff: float = 0.05, + verbose: bool = True, + save_bg=False, + neg_binom=False, + quantiles=(0.5, 0.75, 0.85, 0.9, 0.95, 0.97, 0.98, 0.99, 0.995, 0.9975, 0.999, 1), ): """Calls significant spots by creating random gene pairs with similar expression to given LR pair; only generate background for spots @@ -91,10 +90,10 @@ def perform_spot_testing( adata.uns["lr_spot_indices"] = {} with tqdm( - total=lr_scores.shape[1], - desc="Generating backgrounds & testing each LR pair...", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - disable=verbose is False, + total=lr_scores.shape[1], + desc="Generating backgrounds & testing each LR pair...", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + disable=verbose is False, ) as pbar: gene_bg_genes = {} # Keep track of genes which can be used to gen. rand-pairs. @@ -143,8 +142,8 @@ def perform_spot_testing( # First multiple to get minimum value to be one before rounding # bg_1 = bg_wScore * (1 / min(bg_wScore[bg_wScore != 0])) bg_1 = np.round(bg_1) - lr_j_scores_1 = bg_1[0: len(lr_j_scores)] - bg_1 = bg_1[len(lr_j_scores): len(bg_1)] + lr_j_scores_1 = bg_1[0 : len(lr_j_scores)] + bg_1 = bg_1[len(lr_j_scores) : len(bg_1)] # Getting the pvalue from negative binomial approach round_pvals, _, _, _ = get_stats( @@ -213,18 +212,18 @@ def perform_spot_testing( # Version 2, no longer in use, see above for newest method # def perform_perm_testing( - adata: AnnData, - lr_scores: np.ndarray, - n_pairs: int, - lrs: np.array, - lr_mid_dist: int, - verbose: float, - neighbours: List, - het_vals: np.array, - min_expr: float, - neg_binom: bool, - adj_method: str, - pval_adj_cutoff: float, + adata: AnnData, + lr_scores: np.ndarray, + n_pairs: int, + lrs: np.array, + lr_mid_dist: int, + verbose: float, + neighbours: List, + het_vals: np.array, + min_expr: float, + neg_binom: bool, + adj_method: str, + pval_adj_cutoff: float, ): """Performs the grouped permutation testing when taking the stats approach.""" if n_pairs != 0: # Perform permutation testing @@ -263,9 +262,9 @@ def perform_perm_testing( n_, n_sigs = np.array([0] * len(lrs)), np.array([0] * len(lrs)) per_lr_results = {} with tqdm( - total=len(lr_group_set), - desc="Generating background distributions for the LR pair groups..", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=len(lr_group_set), + desc="Generating background distributions for the LR pair groups..", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for group in lr_group_set: # Determining common mid-point for each group # @@ -335,25 +334,25 @@ def perform_perm_testing( "Summary of significant spots for each lr pair in adata.uns['lr_summary']." ) print( - "Spot enrichment statistics of LR interactions in " + - "adata.uns['per_lr_results']" + "Spot enrichment statistics of LR interactions in " + + "adata.uns['per_lr_results']" ) # No longer in use # def permutation( - adata: AnnData, - n_pairs: int = 200, - distance: int = None, - use_lr: str = "cci_lr", - use_het: str = None, - neg_binom: bool = False, - adj_method: str = "fdr", - neighbours: list = None, - run_fast: bool = True, - bg_pairs: list = None, - background: np.array = None, - **kwargs, + adata: AnnData, + n_pairs: int = 200, + distance: int = None, + use_lr: str = "cci_lr", + use_het: str = None, + neg_binom: bool = False, + adj_method: str = "fdr", + neighbours: list = None, + run_fast: bool = True, + bg_pairs: list = None, + background: np.array = None, + **kwargs, ) -> AnnData: """Permutation test for merged result Parameters @@ -476,13 +475,13 @@ def permutation( def get_stats( - scores: np.array, - background: np.array, - total_bg: int, - neg_binom: bool = False, - adj_method: str = "fdr_bh", - pval_adj_cutoff: float = 0.01, - return_negbinom_params: bool = False, + scores: np.array, + background: np.array, + total_bg: int, + neg_binom: bool = False, + adj_method: str = "fdr_bh", + pval_adj_cutoff: float = 0.01, + return_negbinom_params: bool = False, ): """Retrieves valid candidate genes to be used for random gene pairs. Parameters @@ -514,7 +513,7 @@ def get_stats( mu = np.exp(res.params[0]) alpha = res.params[1] Q = 0 - size = 1.0 / alpha * mu ** Q + size = 1.0 / alpha * mu**Q prob = size / (size + mu) if return_negbinom_params: # For testing purposes # @@ -575,11 +574,11 @@ def get_valid_genes(adata: AnnData, n_pairs: int) -> np.array: def get_rand_pairs( - adata: AnnData, - genes: np.array, - n_pairs: int, - lrs: list = None, - im: int = None, + adata: AnnData, + genes: np.array, + n_pairs: int, + lrs: list = None, + im: int = None, ): """Gets equivalent random gene pairs for the inputted lr pair. Parameters @@ -610,7 +609,7 @@ def get_rand_pairs( .drop(lr_genes)[: n_pairs * 2] .index.tolist() ) - selected = selected[0: n_pairs * 2] + selected = selected[0 : n_pairs * 2] adata.uns["selected"] = selected # form gene pairs from selected randomly random.shuffle(selected) @@ -626,7 +625,7 @@ def get_ordered(adata, genes): def get_median_index(ligand, receptor, means_ordered, genes_ordered): - """ Retrieves the index of the gene with a mean expression between the two genes + """Retrieves the index of the gene with a mean expression between the two genes in the lr pair. Parameters ---------- diff --git a/stlearn/utils.py b/stlearn/utils.py index b658fe26..0a76aec7 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -20,9 +20,7 @@ class _AxesSubplot(Axes, axes.SubplotBase): """Intersection between Axes and SubplotBase: Has methods of both""" -def _check_spot_size( - spatial_data: Mapping | None, spot_size: float | None -) -> float: +def _check_spot_size(spatial_data: Mapping | None, spot_size: float | None) -> float: """ Resolve spot_size value. This is a required argument for spatial plots. diff --git a/stlearn/wrapper/convert_scanpy.py b/stlearn/wrapper/convert_scanpy.py index 2f11e047..94a74ded 100644 --- a/stlearn/wrapper/convert_scanpy.py +++ b/stlearn/wrapper/convert_scanpy.py @@ -1,4 +1,3 @@ - from anndata import AnnData From cc5a7384f108ae52be0f0aa251c191b415a753e9 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 09:53:21 +1000 Subject: [PATCH 009/123] Fix types. --- stlearn/plotting/cci_plot.py | 125 ++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 4cc3467f..3355d548 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -2,7 +2,7 @@ import math import sys from typing import ( - Optional, # Special + Optional, Any, # Special ) import matplotlib @@ -10,12 +10,14 @@ import networkx as nx import numpy as np import pandas as pd +import matplotlib as plt +import matplotlib.axes as plt_axis +import matplotlib.figure as plt_figure from anndata import AnnData from bokeh.io import output_notebook from bokeh.plotting import show -from matplotlib import pyplot as plt -from matplotlib.axes import Axes -from matplotlib.figure import Figure +from numpy.typing import NDArray + from scipy.stats import gaussian_kde import stlearn.plotting.cci_plot_helpers as cci_hs @@ -59,18 +61,18 @@ def lr_diagnostics( Parameters ---------- - adata: AnnData + adata (AnnData): The data object on which st.tl.cci.run has been applied. - highlight_lrs: list + highlight_lrs (list): List of LRs to highlight, will add text and change point color for these LR pairs. - n_top: int + n_top (int): The number of LRs to display. If None shows all. - color0: str + color0 (str): The color of the nonzero-median scatter plot. - lr_text_fp: dict + lr_text_fp (dict): Font dict for the LR text if highlight_lrs not None. - axis_text_fp: dict + axis_text_fp (dict): Font dict for the axis text labels. Returns ------- @@ -79,7 +81,7 @@ def lr_diagnostics( """ if n_top is None: n_top = adata.uns["lr_summary"].shape[0] - fig, axes = plt.subplots(ncols=2, figsize=figsize) + fig, axes = plt.pyplot.subplots(ncols=2, figsize=figsize) cci_hs.lr_scatter( adata, "nonzero-median", @@ -101,7 +103,7 @@ def lr_diagnostics( show=False, ) if show: - plt.show() + plt.pyplot.show() else: return fig, axes @@ -116,7 +118,7 @@ def lr_summary( highlight_color: str = "red", max_text: int = 50, lr_text_fp: dict = None, - ax: Axes = None, + ax: plt_axis.Axes = None, show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -230,7 +232,7 @@ def lr_n_spots( n_sig = adata.uns["lr_summary"].loc[:, "n_spots_sig"].values n_non_sig = adata.uns["lr_summary"].loc[:, "n_spots"].values - n_sig rank = list(range(len(n_sig))) - fig, ax = plt.subplots(figsize=figsize) + fig, ax = plt.pyplot.subplots(figsize=figsize) ax.bar(rank[0:n_top], n_non_sig[0:n_top], bar_width, color=non_sig_color) ax.bar( rank[0:n_top], @@ -251,7 +253,7 @@ def lr_n_spots( ax.spines["right"].set_visible(False) if show: - plt.show() + plt.pyplot.show() else: return fig, ax @@ -387,7 +389,7 @@ def cci_check( label_set = label_set[order] # Plotting bar plot # - fig, ax = plt.subplots(figsize=figsize) + fig, ax = plt.pyplot.subplots(figsize=figsize) ax.bar(xs, cell_counts, color=colors) text_dist = max(cell_counts) * 0.015 fontdict = {"fontweight": "bold", "fontsize": cell_label_size} @@ -415,7 +417,7 @@ def cci_check( fig.tight_layout() if show: - plt.show() + plt.pyplot.show() else: return fig, ax, ax2 @@ -429,7 +431,7 @@ def lr_result_plot( title: Optional["str"] = None, figsize: tuple[float, float] | None = None, cmap: str | None = "Spectral_r", - ax: matplotlib.axes.Axes | None = None, + ax: plt_axis.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool | None = True, show_axis: bool | None = False, @@ -555,8 +557,8 @@ def lr_plot( title="", show_image: bool = True, show_arrows: bool = False, - fig: Figure = None, - ax: Axes = None, + fig: plt_figure.Figure = None, + ax: plt_axis.Axes = None, arrow_head_width: float = 4, arrow_width: float = 0.001, arrow_cmap: str = None, @@ -909,7 +911,7 @@ def het_plot( cmap: str | None = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, + ax: plt_axis.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool | None = True, show_axis: bool | None = False, @@ -988,8 +990,8 @@ def het_plot( def ccinet_plot( adata: AnnData, use_label: str, - lr: str = None, - pos: dict = None, + lr: str | None = None, + pos: dict | None = None, return_pos: bool = False, cmap: str = "default", font_size: int = 12, @@ -997,10 +999,10 @@ def ccinet_plot( node_size_scaler: int = 1, min_counts: int = 0, sig_interactions: bool = True, - fig: matplotlib.figure.Figure = None, - ax: matplotlib.axes.Axes = None, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, pad=0.25, - title: str = None, + title_or_none: str | None = None, figsize: tuple = (10, 10), ): """Circular celltype-celltype interaction network based on LR-CCI analysis. @@ -1059,7 +1061,7 @@ def ccinet_plot( ) # Either plotting overall interactions, or just for a particular LR # - int_df, title = get_int_df(adata, lr, use_label, sig_interactions, title) + int_df, title = get_int_df(adata, lr, use_label, sig_interactions, title_or_none) # Creating the interaction graph # all_set = int_df.index.values int_matrix = int_df.values @@ -1113,8 +1115,10 @@ def ccinet_plot( node_colors = np.array(node_colors)[nodes_indices] #### Drawing the graph ##### - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize, facecolor=[0.7, 0.7, 0.7, 0.4]) + ax: plt_axis.Axes + fig: plt_figure.Figure + if fig_or_none is None or ax_or_none is None: + fig, ax = plt.pyplot.subplots(figsize=figsize, facecolor=[0.7, 0.7, 0.7, 0.4]) # Adding in the self-loops # z = 55 @@ -1131,7 +1135,7 @@ def ccinet_plot( width=0.3, height=0.025, lw=5, - ec=plt.cm.get_cmap("Blues")(edge_weights[i]), + ec=plt.colormaps.get_cmap("Blues")(edge_weights[i]), angle=angle, theta1=z, theta2=360 - z, @@ -1139,7 +1143,7 @@ def ccinet_plot( ax.add_patch(arc) # Drawing the main components of the graph # - edges = nx.draw_networkx( + nx.draw_networkx( graph, pos, node_size=node_sizes, @@ -1150,11 +1154,11 @@ def ccinet_plot( font_size=font_size, font_weight="bold", edge_color=edge_weights, - edge_cmap=plt.cm.Blues, + edge_cmap=plt.colormaps.get_cmap("Blues"), ax=ax, ) fig.suptitle(title, fontsize=30) - plt.tight_layout() + plt.pyplot.tight_layout() # Adding padding # xlims = ax.get_xlim() @@ -1169,10 +1173,10 @@ def ccinet_plot( def cci_map( adata: AnnData, use_label: str, - lr: str = None, - ax: matplotlib.figure.Axes = None, + lr: str | None = None, + ax: plt_axis.Axes | None = None, show: bool = False, - figsize: tuple = None, + figsize: tuple | None = None, cmap: str = "Spectral_r", sig_interactions: bool = True, title=None, @@ -1226,7 +1230,7 @@ def cci_map( # Reformat the interaction df # flat_df = create_flat_df(int_df) - ax = _box_map( + new_ax: plt_axis.Axes = _box_map( flat_df["x"], flat_df["y"], flat_df["value"].astype(int), @@ -1235,24 +1239,24 @@ def cci_map( cmap=cmap, ) - ax.set_ylabel("Sender") - ax.set_xlabel("Receiver") - plt.suptitle(title) + new_ax.set_ylabel("Sender") + new_ax.set_xlabel("Receiver") + plt.pyplot.suptitle(title) if show: - plt.show() + plt.pyplot.show() else: - return ax + return new_ax def lr_cci_map( adata: AnnData, use_label: str, - lrs: list or np.array = None, + lrs: Optional[list | np.ndarray] = None, n_top_lrs: int = 5, n_top_ccis: int = 15, min_total: int = 0, - ax: matplotlib.figure.Axes = None, + ax: plt_axis.Axes | None = None, figsize: tuple = (6.48, 4.8), show: bool = False, cmap: str = "Spectral_r", @@ -1270,8 +1274,8 @@ def lr_cci_map( Indicates the cell type labels or deconvolution results used for the cell-cell interaction counting by LR pairs. lrs: list-like - LR pairs to show in the heatmap, if None then top 5 lrs with highest no. - of interactions used from adata.uns['lr_summary']. + LR pairs to show in the heatmap, if None then top 5 lrs with the highest no. of interactions used from + adata.uns['lr_summary']. n_top_lrs: int Indicates how many top lrs to show; is ignored if lrs is not None. n_top_ccis: int @@ -1344,7 +1348,7 @@ def lr_cci_map( if flat_df.shape[0] == 0 or flat_df.shape[1] == 0: raise Exception(f"No interactions greater than min: {min_total}") - ax = _box_map( + new_ax: plt_axis.Axes = _box_map( flat_df["x"], flat_df["y"], flat_df["value"].astype(int), @@ -1354,26 +1358,26 @@ def lr_cci_map( square_scaler=square_scaler, ) - ax.set_ylabel("LR-pair") - ax.set_xlabel("Cell-cell interaction") + new_ax.set_ylabel("LR-pair") + new_ax.set_xlabel("Cell-cell interaction") if show: - plt.show() + plt.pyplot.show() else: - return ax + return new_ax def lr_chord_plot( adata: AnnData, use_label: str, - lr: str = None, + lr: str | None = None, min_ints: int = 2, n_top_ccis: int = 10, cmap: str = "default", sig_interactions: bool = True, label_size: int = 10, label_rotation: float = 0, - title: str = None, + title: str = "", figsize: tuple = (8, 8), show: bool = True, ): @@ -1435,7 +1439,7 @@ def lr_chord_plot( int_df, title = get_int_df(adata, lr, use_label, sig_interactions, title) int_df = int_df.transpose() - fig = plt.figure(figsize=figsize) + fig = plt.pyplot.figure(figsize=figsize) flux = int_df.values total_ints = flux.sum(axis=1) + flux.sum(axis=0) - flux.diagonal() @@ -1473,10 +1477,10 @@ def lr_chord_plot( # Retrieving colors of cell types # colors = get_colors(adata, use_label, cmap=cmap, label_set=cell_names) - ax = plt.axes([0, 0, 1, 1]) + ax = plt.pyplot.axes((0, 0, 1, 1)) nodePos = chordDiagram(flux, ax, lim=1.25, colors=colors) ax.axis("off") - prop = dict(fontsize=label_size, ha="center", va="center") + prop: dict[str, Any] = dict(fontsize=label_size, ha="center", va="center") label_rotation_ = label_rotation for i in range(len(cell_names)): x, y = nodePos[i][0:2] @@ -1493,14 +1497,14 @@ def lr_chord_plot( ) # size=10, fig.suptitle(title, fontsize=12, fontweight="bold") if show: - plt.show() + plt.pyplot.show() else: return fig, ax def grid_plot( adata, - use_label: str = None, + use_label: str | None = None, n_row: int = 10, n_col: int = 10, size: int = 1, @@ -1534,7 +1538,7 @@ def grid_plot( xmin, xmax = min(xedges), max(xedges) ymin, ymax = min(yedges), max(yedges) - fig, ax = plt.subplots(figsize=figsize) + fig, ax = plt.pyplot.subplots(figsize=figsize) # Plotting the points # if use_label is not None: @@ -1552,7 +1556,7 @@ def grid_plot( ax.hlines(-yedges, xmin, xmax, color="#36454F") if show: - plt.show() + plt.pyplot.show() else: return fig, ax @@ -1583,7 +1587,6 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) - # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() From 03fb048e9b4a3de21027e4ed8e89d044dad8b98a Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 11:03:01 +1000 Subject: [PATCH 010/123] Fix names. --- stlearn/plotting/cci_plot.py | 41 ++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 3355d548..3a2a8bb7 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -1173,10 +1173,10 @@ def ccinet_plot( def cci_map( adata: AnnData, use_label: str, - lr: str | None = None, - ax: plt_axis.Axes | None = None, + lr_or_none: str | None = None, + ax_or_none: plt_axis.Axes | None = None, show: bool = False, - figsize: tuple | None = None, + figsize_or_none: tuple | None = None, cmap: str = "Spectral_r", sig_interactions: bool = True, title=None, @@ -1190,14 +1190,14 @@ def cci_map( use_label: str Indicates the cell type labels or deconvolution results used for cell-cell interaction counting by LR pairs. - lr: str + lr_or_none: str The LR pair to visualise the sender->receiver interactions for. If None, will use all pairs via adata.uns[f'lr_cci_{use_label}']. - ax: Axes + ax_or_none: Axes Axes on which to plot the heatmap, if None then generates own. show: bool Whether to show the plot or not; if not, then returns ax. - figsize: tuple + figsize_or_none: tuple (width, height), specifies the dimensions of the figure. Only relevant if ax=None. cmap: str @@ -1215,9 +1215,10 @@ def cci_map( """ # Either plotting overall interactions, or just for a particular LR # - int_df, title = get_int_df(adata, lr, use_label, sig_interactions, title) + int_df, title = get_int_df(adata, lr_or_none, use_label, sig_interactions, title) - if figsize is None: # Adjust size depending on no. cell types + figsize: tuple = figsize_or_none + if figsize_or_none is None: # Adjust size depending on no. cell types add = np.array([int_df.shape[0] * 0.1, int_df.shape[0] * 0.05]) figsize = tuple(np.array([6.4, 4.8]) + add) @@ -1230,23 +1231,23 @@ def cci_map( # Reformat the interaction df # flat_df = create_flat_df(int_df) - new_ax: plt_axis.Axes = _box_map( + ax: plt_axis.Axes = _box_map( flat_df["x"], flat_df["y"], flat_df["value"].astype(int), - ax=ax, + ax=ax_or_none, figsize=figsize, cmap=cmap, ) - new_ax.set_ylabel("Sender") - new_ax.set_xlabel("Receiver") + ax.set_ylabel("Sender") + ax.set_xlabel("Receiver") plt.pyplot.suptitle(title) if show: plt.pyplot.show() else: - return new_ax + return ax def lr_cci_map( @@ -1256,7 +1257,7 @@ def lr_cci_map( n_top_lrs: int = 5, n_top_ccis: int = 15, min_total: int = 0, - ax: plt_axis.Axes | None = None, + ax_or_none: plt_axis.Axes | None = None, figsize: tuple = (6.48, 4.8), show: bool = False, cmap: str = "Spectral_r", @@ -1282,7 +1283,7 @@ def lr_cci_map( Indicates maximum no. of CCIs to show. min_total: int Minimum no. of totals interaction celltypes must have to be shown. - ax: Axes + ax_or_none: Axes Axes on which to draw the heatmap, is generated internally if None. figsize: tuple (width, height), only relevant if ax=None. @@ -1348,23 +1349,23 @@ def lr_cci_map( if flat_df.shape[0] == 0 or flat_df.shape[1] == 0: raise Exception(f"No interactions greater than min: {min_total}") - new_ax: plt_axis.Axes = _box_map( + ax: plt_axis.Axes = _box_map( flat_df["x"], flat_df["y"], flat_df["value"].astype(int), - ax=ax, + ax=ax_or_none, cmap=cmap, figsize=figsize, square_scaler=square_scaler, ) - new_ax.set_ylabel("LR-pair") - new_ax.set_xlabel("Cell-cell interaction") + ax.set_ylabel("LR-pair") + ax.set_xlabel("Cell-cell interaction") if show: plt.pyplot.show() else: - return new_ax + return ax def lr_chord_plot( From 7ae45805327881046309640b6bdb9b8ef11dc22b Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 14:51:14 +1000 Subject: [PATCH 011/123] Fix types. --- stlearn/add.py | 23 +++++++ stlearn/adds/add_image.py | 2 +- stlearn/adds/parsing.py | 2 +- stlearn/plotting/cci_plot.py | 87 +++++++++++--------------- stlearn/plotting/cci_plot_helpers.py | 6 +- stlearn/plotting/classes.py | 16 ++--- stlearn/spatials/SME/impute.py | 12 ++-- stlearn/wrapper/read.py | 91 ++++++++++++++-------------- 8 files changed, 127 insertions(+), 112 deletions(-) diff --git a/stlearn/add.py b/stlearn/add.py index e69de29b..6fd5653d 100644 --- a/stlearn/add.py +++ b/stlearn/add.py @@ -0,0 +1,23 @@ +from .adds.add_image import image +from .adds.add_positions import positions +from .adds.parsing import parsing +from .adds.add_lr import lr +from .adds.annotation import annotation +from .adds.add_labels import labels +from .adds.add_deconvolution import add_deconvolution +from .adds.add_mask import add_mask +from .adds.add_mask import apply_mask +from .adds.add_loupe_clusters import add_loupe_clusters + +__all__ = [ + "image", + "positions", + "parsing", + "lr", + "annotation", + "labels", + "add_deconvolution", + "add_mask", + "apply_mask", + "add_loupe_clusters", +] \ No newline at end of file diff --git a/stlearn/adds/add_image.py b/stlearn/adds/add_image.py index 15a4953b..0d07b3d3 100644 --- a/stlearn/adds/add_image.py +++ b/stlearn/adds/add_image.py @@ -10,7 +10,7 @@ def image( adata: AnnData, - imgpath: Path | str, + imgpath: Path | str | None, library_id: str, quality: str = "hires", scale: float = 1.0, diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index e0b2daa5..282ed38d 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -6,7 +6,7 @@ def parsing( adata: AnnData, - coordinates_file: Path | str, + coordinates_file: Path | str | None, copy: bool = True, ) -> AnnData | None: """\ diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 3a2a8bb7..f9150fee 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -45,12 +45,12 @@ def lr_diagnostics( adata, - highlight_lrs: list = None, - n_top: int = None, + highlight_lrs: list | None = None, + n_top: int | None = None, color0: str = "turquoise", color1: str = "plum", figsize: tuple = (10, 4), - lr_text_fp: dict = None, + lr_text_fp: dict | None = None, show: bool = True, ): """Diagnostic plot looking at relationship between technical features of lrs and @@ -111,14 +111,14 @@ def lr_diagnostics( def lr_summary( adata, n_top: int = 50, - highlight_lrs: list = None, + highlight_lrs: list | None = None, y: str = "n_spots_sig", color: str = "gold", - figsize: tuple = None, + figsize: tuple | None = None, highlight_color: str = "red", max_text: int = 50, - lr_text_fp: dict = None, - ax: plt_axis.Axes = None, + lr_text_fp: dict | None = None, + ax: plt_axis.Axes | None = None, show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -179,8 +179,8 @@ def lr_summary( def lr_n_spots( adata, n_top: int = 100, - font_dict: dict = None, - xtick_dict: dict = None, + font_dict: dict | None = None, + xtick_dict: dict | None = None, bar_width: float = 1, max_text: int = 50, non_sig_color: str = "dodgerblue", @@ -261,10 +261,10 @@ def lr_n_spots( def lr_go( adata, n_top: int = 20, - highlight_go: list = None, + highlight_go: list | None = None, figsize=(6, 4), rot: float = 50, - lr_text_fp: dict = None, + lr_text_fp: dict | None = None, highlight_color: str = "yellow", max_text: int = 50, show: bool = True, @@ -361,12 +361,11 @@ def cci_check( xs = np.array(list(range(len(label_set)))) int_dfs = adata.uns[f"per_lr_cci_{use_label}"] - # Counting!!! # - cell_counts = [] # Cell type frequencies - cell_sigs = [] # Cell type significant interactions + cell_counts: np.ndarray = np.zeros(len(label_set), dtype=int) + cell_sigs: np.ndarray = np.zeros(len(label_set), dtype=int) for j, label in enumerate(label_set): counts = sum(labels == label) - cell_counts.append(counts) + cell_counts[j] = counts int_count = 0 for lr in int_dfs: @@ -378,11 +377,9 @@ def cci_check( # prevent double counts int_count -= int_bool[label_index, label_index] - cell_sigs.append(int_count) + cell_sigs[j] = int_count - cell_counts = np.array(cell_counts) - cell_sigs = np.array(cell_sigs) - order = np.argsort(cell_counts) + order: np.ndarray = np.argsort(cell_counts) cell_counts = cell_counts[order] cell_sigs = cell_sigs[order] colors = np.array(colors)[order] @@ -448,8 +445,8 @@ def lr_result_plot( dpi: int | None = 120, contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, + vmin: float | None = None, + vmax: float | None = None, ): """Plots the per spot statistics for given LR. @@ -544,7 +541,7 @@ def lr_plot( lr: str, min_expr: float = 0, sig_spots=True, - use_label: str = None, + use_label: str | None = None, outer_mode: str = "continuous", l_cmap=None, r_cmap=None, @@ -557,19 +554,19 @@ def lr_plot( title="", show_image: bool = True, show_arrows: bool = False, - fig: plt_figure.Figure = None, - ax: plt_axis.Axes = None, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, arrow_head_width: float = 4, arrow_width: float = 0.001, - arrow_cmap: str = None, - arrow_vmax: float = None, + arrow_cmap: str | None = None, + arrow_vmax: float | None = None, sig_cci: bool = False, - lr_colors: dict = None, + lr_colors: dict | None = None, figsize: tuple = (6.4, 4.8), - use_mix: bool = None, + use_mix: bool | None = None, # plotting params **kwargs, -) -> AnnData | None: +) -> None: """Creates different kinds of spatial visualisations for the LR analysis results. To see combinations of parameters refer to stLearn CCI tutorial. @@ -621,9 +618,9 @@ def lr_plot( Whether to show the background image. show_arrows: bool Whether to plot arrows indicating interactions between spots. - fig: Figure + fig_or_none: Figure Figure to draw on. - ax: Axes + ax_or_none: Axes Axes to draw on. arrow_head_width: float Width of arrow head; only if show_arrows is true. @@ -735,8 +732,8 @@ def lr_plot( adata_full = adata # Dealing with the axis # - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) + if fig_or_none is None or ax_or_none is None: + fig, ax = plt.pyplot.subplots(figsize=figsize) expr = adata.to_df() l_expr = expr.loc[:, ligand].values @@ -885,18 +882,6 @@ def lr_plot( arrow_cmap, arrow_vmax, ) - - # Cropping # - # if crop: - # x0, x1 = ax.get_xlim() - # y0, y1 = ax.get_ylim() - # x_margin, y_margin = (x1-x0)*margin_ratio, (y1-y0)*margin_ratio - # print(x_margin, y_margin) - # print(x0, x1, y0, y1) - # ax.set_xlim(x0 - x_margin, x1 + x_margin) - # ax.set_ylim(y0 - y_margin, y1 + y_margin) - # #ax.set_ylim(ax.get_ylim()[::-1]) - fig.suptitle(title) @@ -919,7 +904,7 @@ def het_plot( show_color_bar: bool | None = True, zoom_coord: float | None = None, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -930,9 +915,9 @@ def het_plot( use_het: str | None = "het", contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, -) -> AnnData | None: + vmin: float | None = None, + vmax: float | None = None, +) -> None: """\ Allows the visualization of significant cell-cell interaction as the values of dot points or contour in the Spatial @@ -1217,10 +1202,12 @@ def cci_map( # Either plotting overall interactions, or just for a particular LR # int_df, title = get_int_df(adata, lr_or_none, use_label, sig_interactions, title) - figsize: tuple = figsize_or_none + figsize: tuple if figsize_or_none is None: # Adjust size depending on no. cell types add = np.array([int_df.shape[0] * 0.1, int_df.shape[0] * 0.05]) figsize = tuple(np.array([6.4, 4.8]) + add) + else: + figsize = figsize_or_none # Rank by total interactions # int_vals = int_df.values diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 9fca0baa..f046e973 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -32,7 +32,7 @@ def lr_scatter( show=True, max_text=100, highlight_color="red", - figsize: tuple = None, + figsize: tuple | None = None, show_all: bool = False, ): """General plotting of the LR features.""" @@ -227,8 +227,8 @@ def add_arrows( sig_bool: np.array, fig, ax: Axes, - use_label: str, - int_df: pd.DataFrame, + use_label: str | None, + int_df: pd.DataFrame | None, head_width=4, width=0.001, arrow_cmap=None, diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 14efdcc2..59c38726 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -42,7 +42,7 @@ def __init__( color_bar_label: str | None = "", zoom_coord: float | None = None, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 0.7, @@ -243,7 +243,7 @@ def __init__( color_bar_label: str | None = "", crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -1124,7 +1124,7 @@ def __init__( show_color_bar: bool | None = True, crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -1135,8 +1135,8 @@ def __init__( use_het: str | None = "het", contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, + vmin: float | None = None, + vmax: float | None = None, **kwargs, ): super().__init__( @@ -1194,7 +1194,7 @@ def __init__( show_color_bar: bool | None = True, crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -1204,8 +1204,8 @@ def __init__( # cci_rank param contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, + vmin: float | None = None, + vmax: float | None = None, **kwargs, ): # Making sure cci_rank has been run first # diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatials/SME/impute.py index 31f16467..a365b192 100644 --- a/stlearn/spatials/SME/impute.py +++ b/stlearn/spatials/SME/impute.py @@ -90,7 +90,7 @@ def pseudo_spot( adata: AnnData, tile_path: Path | str = Path("/tmp/tiles"), use_data: str = "raw", - crop_size: int = "auto", + crop_size: str | int = "auto", platform: _PLATFORM = "Visium", weights: _WEIGHTING_MATRIX = "weights_matrix_all", copy: _COPY = "pseudo_spot_adata", @@ -279,10 +279,14 @@ def pseudo_spot( pseudo_spot_adata = AnnData(impute_df, obs=obs_df) pseudo_spot_adata.uns["spatial"] = adata.uns["spatial"] + actual_crop_size: int if crop_size == "auto": - crop_size = round(unit / 2) - - stlearn.pp.tiling(pseudo_spot_adata, tile_path, crop_size=crop_size) + actual_crop_size = round(unit / 2) + elif isinstance(crop_size, int): + actual_crop_size = crop_size + else: + raise ValueError(f"crop_size must be 'auto' or an integer, got {crop_size}") + stlearn.pp.tiling(pseudo_spot_adata, tile_path, crop_size=actual_crop_size) stlearn.pp.extract_feature(pseudo_spot_adata) diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 030e7d82..10118892 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -16,18 +16,18 @@ from .._compat import Literal -_QUALITY = Literal["fulres", "hires", "lowres"] -_background = ["black", "white"] +_Quality = Literal["fulres", "hires", "lowres"] +_Background = Literal["black", "white"] def Read10X( path: str | Path, genome: str | None = None, count_file: str = "filtered_feature_bc_matrix.h5", - library_id: str = None, - load_images: bool | None = True, - quality: _QUALITY = "hires", - image_path: str | Path = None, + library_id: str | None = None, + load_images: bool = True, + quality: _Quality = "hires", + image_path: str | Path | None = None, ) -> AnnData: """\ Read Visium data from 10X (wrap read_visium from scanpy) @@ -96,9 +96,9 @@ def Read10X( with File(path / count_file, mode="r") as f: attrs = dict(f.attrs) + if library_id is None: library_id = str(attrs.pop("library_ids")[0], "utf-8") - adata.uns["spatial"][library_id] = dict() tissue_positions_file = ( @@ -170,32 +170,33 @@ def Read10X( inplace=True, ) - # put image path in uns - if image_path is not None: - # get an absolute path - image_path = str(Path(image_path).resolve()) - adata.uns["spatial"][library_id]["metadata"]["source_image_path"] = str( - image_path - ) - - adata.var_names_make_unique() + if quality == "fulres": + # put image path in uns + if image_path is not None: + # get an absolute path + image_path = str(Path(image_path).resolve()) + adata.uns["spatial"][library_id]["metadata"]["source_image_path"] = str( + image_path + ) + else: + raise ValueError( + "Trying to load fulres but no image_path set." + ) - if library_id is None: - library_id = list(adata.uns["spatial"].keys())[0] + image_coor = adata.obsm["spatial"] + img = plt.imread(image_path, None) + adata.uns["spatial"][library_id]["images"]["fulres"] = img + else: + scale = adata.uns["spatial"][library_id]["scalefactors"][ + "tissue_" + quality + "_scalef" + ] + image_coor = adata.obsm["spatial"] * scale - if quality == "fulres": - image_coor = adata.obsm["spatial"] - img = plt.imread(image_path, 0) - adata.uns["spatial"][library_id]["images"]["fulres"] = img - else: - scale = adata.uns["spatial"][library_id]["scalefactors"][ - "tissue_" + quality + "_scalef" - ] - image_coor = adata.obsm["spatial"] * scale + adata.obs["imagecol"] = image_coor[:, 0] + adata.obs["imagerow"] = image_coor[:, 1] + adata.uns["spatial"][library_id]["use_quality"] = quality - adata.obs["imagecol"] = image_coor[:, 0] - adata.obs["imagerow"] = image_coor[:, 1] - adata.uns["spatial"][library_id]["use_quality"] = quality + adata.var_names_make_unique() adata.obs["array_row"] = adata.obs["array_row"].astype(int) adata.obs["array_col"] = adata.obs["array_col"].astype(int) @@ -205,9 +206,9 @@ def Read10X( def ReadOldST( - count_matrix_file: str | Path = None, - spatial_file: str | Path = None, - image_file: str | Path = None, + count_matrix_file: str | Path | None = None, + spatial_file: str | Path | None = None, + image_file: str | Path | None = None, library_id: str = "OldST", scale: float = 1.0, quality: str = "hires", @@ -257,11 +258,11 @@ def ReadOldST( def ReadSlideSeq( count_matrix_file: str | Path, spatial_file: str | Path, - library_id: str = None, - scale: float = None, + library_id: str| None = None, + scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _background = "white", + background_color: _Background = "white", ) -> AnnData: """\ Read Slide-seq data @@ -340,11 +341,11 @@ def ReadSlideSeq( def ReadMERFISH( count_matrix_file: str | Path, spatial_file: str | Path, - library_id: str = None, - scale: float = None, + library_id: str | None = None, + scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _background = "white", + background_color: _Background = "white", ) -> AnnData: """\ Read MERFISH data @@ -423,12 +424,12 @@ def ReadMERFISH( def ReadSeqFish( count_matrix_file: str | Path, spatial_file: str | Path, - library_id: str = None, + library_id: str | None = None, scale: float = 1.0, quality: str = "hires", field: int = 0, spot_diameter_fullres: float = 50, - background_color: _background = "white", + background_color: _Background = "white", ) -> AnnData: """\ Read SeqFish data @@ -512,11 +513,11 @@ def ReadXenium( feature_cell_matrix_file: str | Path, cell_summary_file: str | Path, image_path: Path | None = None, - library_id: str = None, + library_id: str | None = None, scale: float = 1.0, quality: str = "hires", spot_diameter_fullres: float = 15, - background_color: _background = "white", + background_color: _Background = "white", ) -> AnnData: """\ Read Xenium data @@ -606,10 +607,10 @@ def create_stlearn( spatial: pd.DataFrame, library_id: str, image_path: Path | None = None, - scale: float = None, + scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _background = "white", + background_color: _Background = "white", ): """\ Create AnnData object for stLearn From 13f0074edf3b16bb60b03551dc26f3f451238800 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 14:53:03 +1000 Subject: [PATCH 012/123] Use float instead of int for reading in pxl coordinates. --- stlearn/wrapper/read.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 10118892..ee44242d 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -163,7 +163,7 @@ def Read10X( adata.obsm["spatial"] = ( adata.obs[["pxl_row_in_fullres", "pxl_col_in_fullres"]] .to_numpy() - .astype(int) + .astype(float) ) adata.obs.drop( columns=["barcode", "pxl_row_in_fullres", "pxl_col_in_fullres"], From d9f7d8dffcb5a59ad1b2bd6039a06b23aea233ea Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 4 Jun 2025 15:42:49 +1000 Subject: [PATCH 013/123] Fix more types. --- stlearn/tools/microenv/cci/analysis.py | 41 ++++++++-------- stlearn/tools/microenv/cci/base.py | 60 +++++++++++------------ stlearn/tools/microenv/cci/het.py | 4 +- stlearn/tools/microenv/cci/perm_utils.py | 2 +- stlearn/tools/microenv/cci/permutation.py | 2 +- 5 files changed, 53 insertions(+), 56 deletions(-) diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 16d21cad..6a22672d 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -25,7 +25,7 @@ # Functions related to Ligand-Receptor interactions -def load_lrs(names: str | list | None = None, species: str = "human") -> np.array: +def load_lrs(names: str | list | None = None, species: str = "human") -> np.ndarray: """Loads inputted LR database, & concatenates into consistent database set of pairs without duplicates. If None loads 'connectomeDB2020_lit'. @@ -53,12 +53,12 @@ def load_lrs(names: str | list | None = None, species: str = "human") -> np.arra for db in dbs: lrs = [f"{db.values[i,0]}_{db.values[i,1]}" for i in range(db.shape[0])] lrs_full.extend(lrs) - lrs_full = np.unique(lrs_full) + lrs_full_arr = np.unique(np.array(lrs_full)) # If dealing with mouse, need to reformat # if species == "mouse": genes1 = [lr_.split("_")[0] for lr_ in lrs_full] genes2 = [lr_.split("_")[1] for lr_ in lrs_full] - lrs_full = np.array( + lrs_full_arr = np.array( [ genes1[i][0] + genes1[i][1:].lower() @@ -69,14 +69,14 @@ def load_lrs(names: str | list | None = None, species: str = "human") -> np.arra ] ) - return lrs_full + return lrs_full_arr def grid( adata, n_row: int = 10, n_col: int = 10, - use_label: str = None, + use_label: str | None = None, n_cpus: int = 1, verbose: bool = True, ): @@ -165,7 +165,7 @@ def grid( grid_data.obsm["spatial"] = grid_coords grid_data.uns["spatial"] = adata.uns["spatial"] - if use_label is not None: + if use_label is not None and cell_info is not None and cell_set is not None: grid_data.uns[use_label] = pd.DataFrame( cell_info, index=grid_data.obs_names.values.astype(str), columns=cell_set ) @@ -192,12 +192,12 @@ def grid( def run( adata: AnnData, - lrs: np.array, + lrs: np.ndarray, min_spots: int = 10, - distance: int = None, + distance: int | None = None, n_pairs: int = 1000, - n_cpus: int = None, - use_label: str = None, + n_cpus: int | None = None, + use_label: str | None = None, adj_method: str = "fdr_bh", pval_adj_cutoff: float = 0.05, min_expr: float = 0, @@ -211,7 +211,7 @@ def run( ----------- adata: AnnData The data object. - lrs: np.array + lrs: np.ndarray The LR pairs to score/test for enrichment (in format 'L1_R1'). min_spots: int Minimum number of spots with an LR score for an LR to be considered for @@ -261,7 +261,7 @@ def run( referring to the LRs listed in adata.uns['lr_summary']. 'lr_scores' is the raw scores, while 'lr_sig_scores' is the same except only for significant scores; non-significant scores are set to zero. - adata.obsm['het'] + adata.obsm['cci_het'] Only if use_label specified; contains the counts of the cell types found per spot. """ @@ -317,7 +317,7 @@ def run( print("Calculating cell heterogeneity...") # Calculating cell heterogeneity # - count(adata, distance=distance, use_label=use_label, use_het=use_label) + count(adata, distance=distance, use_label=use_label) het_vals = ( np.array([1] * len(adata)) @@ -328,13 +328,13 @@ def run( """ 1. Filter any LRs without stored expression. """ # Calculating the lr_scores across spots for the inputted lrs # - lr_scores, lrs = get_lrs_scores(adata, lrs, neighbours, het_vals, min_expr) + lr_scores, new_lrs = get_lrs_scores(adata, lrs, neighbours, het_vals, min_expr) lr_bool = (lr_scores > 0).sum(axis=0) > min_spots - lrs = lrs[lr_bool] + new_lrs = new_lrs[lr_bool] lr_scores = lr_scores[:, lr_bool] if verbose: - print("Altogether " + str(len(lrs)) + " valid L-R pairs") - if len(lrs) == 0: + print("Altogether " + str(len(new_lrs)) + " valid L-R pairs") + if len(new_lrs) == 0: print("Exiting due to lack of valid LR pairs.") return @@ -343,7 +343,7 @@ def run( perform_spot_testing( adata, lr_scores, - lrs, + new_lrs, n_pairs, neighbours, het_vals, @@ -442,7 +442,7 @@ def run_lr_go( adata: AnnData, r_path: str, n_top: int = 100, - bg_genes: np.array = None, + bg_genes: np.ndarray | None = None, min_sig_spots: int = 1, species: str = "human", p_cutoff: float = 0.01, @@ -497,7 +497,8 @@ def run_lr_go( # Determining the background genes if not inputted if bg_genes is None: all_lrs = load_lrs("connectomeDB2020_put") - bg_genes = np.unique([lr_.split("_") for lr_ in all_lrs]) + all_genes = [lr_.split("_") for lr_ in all_lrs] + bg_genes = np.unique(all_genes) # Running the GO analysis go_results = run_GO( diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index edbc5c66..2e852d3a 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -71,7 +71,7 @@ def lr( # return adata -def calc_distance(adata: AnnData, distance: float): +def calc_distance(adata: AnnData, distance: float | None): """Automatically calculate distance if not given, won't overwrite \ distance=0 which is within-spot. Parameters @@ -95,7 +95,7 @@ def calc_distance(adata: AnnData, distance: float): scalefactors["spot_diameter_fullres"] * scalefactors[ "tissue_" + adata.uns["spatial"][library_id]["use_quality"] + "_scalef" - ] + ] * 2 ) return distance @@ -103,34 +103,34 @@ def calc_distance(adata: AnnData, distance: float): def get_lrs_scores( adata: AnnData, - lrs: np.array, - neighbours: np.array, - het_vals: np.array, + lrs: np.ndarray, + neighbours: np.ndarray, + het_vals: np.ndarray, min_expr: float, filter_pairs: bool = True, - spot_indices: np.array = None, + spot_indices: np.ndarray | None = None, ): """Gets the scores for the indicated set of LR pairs & the heterogeneity values. Parameters ---------- adata: AnnData See run() doc-string. - lrs: np.array + lrs: np.ndarray See run() doc-string. - neighbours: np.array + neighbours: np.ndarray Array of arrays with indices specifying neighbours of each spot. - het_vals: np.array + het_vals: np.ndarray Cell heterogeneity counts per spot. min_expr: float Minimum gene expression of either L or R for spot to be considered to have reasonable score. filter_pairs: bool Whether to filter to valid pairs or not. - spot_indices: np.array + spot_indices: np.ndarray Array of integers speci Returns ------- - lrs: np.array lr pairs from the database in format ['L1_R1', 'LN_RN'] + lrs: np.ndarray lr pairs from the database in format ['L1_R1', 'LN_RN'] """ if spot_indices is None: spot_indices = np.array(list(range(len(adata))), dtype=np.int32) @@ -141,28 +141,24 @@ def get_lrs_scores( spot_lr2s = get_spot_lrs( adata, lr_pairs=lrs, lr_order=False, filter_pairs=filter_pairs ) - if filter_pairs: - lrs = np.array( - [ - "_".join(spot_lr1s.columns.values[i : i + 2]) - for i in range(0, spot_lr1s.shape[1], 2) - ] - ) - # Calculating the lr_scores across spots for the inputted lrs # lr_scores = get_scores( spot_lr1s.values, spot_lr2s.values, neighbours, het_vals, min_expr, spot_indices ) - if filter_pairs: - return lr_scores, lrs - else: - return lr_scores + new_lrs = np.array( + [ + "_".join(spot_lr1s.columns.values[i: i + 2]) + for i in range(0, spot_lr1s.shape[1], 2) + ] + ) + + return lr_scores, new_lrs def get_spot_lrs( adata: AnnData, - lr_pairs: list, + lr_pairs: np.ndarray, lr_order: bool, filter_pairs: bool = True, ): @@ -171,8 +167,8 @@ def get_spot_lrs( ---------- adata (AnnData): The adata object to scan - lr_pairs (list): - List of the lr pairs (e.g. ['L1_R1', 'L2_R2',...] + lr_pairs (np.ndarray): + np.ndarray of the lr pairs (e.g. ['L1_R1', 'L2_R2',...] lr_order (bool): Forward version of the spot lr pairs (L1_R1), False indicates reverse (R1_L1) filter_pairs (bool): @@ -204,7 +200,7 @@ def get_spot_lrs( def calc_neighbours( adata: AnnData, - distance: float = None, + distance: float | None = None, index: bool = True, verbose: bool = True, ) -> List: @@ -365,10 +361,10 @@ def get_scores( spot_lr1s: np.ndarray, spot_lr2s: np.ndarray, neighbours: List, - het_vals: np.array, + het_vals: np.ndarray, min_expr: float, - spot_indices: np.array, -) -> np.array: + spot_indices: np.ndarray, +) -> np.ndarray: """Calculates the scores. Parameters ---------- @@ -390,7 +386,7 @@ def get_scores( spot_scores = np.zeros((len(spot_indices), spot_lr1s.shape[1] // 2), np.float64) for i in prange(0, spot_lr1s.shape[1] // 2): i_ = i * 2 # equivalent to range(0, spot_lr1s.shape[1], 2) - spot_lr1, spot_lr2 = spot_lr1s[:, i_ : (i_ + 2)], spot_lr2s[:, i_ : (i_ + 2)] + spot_lr1, spot_lr2 = spot_lr1s[:, i_: (i_ + 2)], spot_lr2s[:, i_: (i_ + 2)] lr_scores = lr_core(spot_lr1, spot_lr2, neighbours, min_expr, spot_indices) # The merge scores # lr_scores = np.multiply(het_vals[spot_indices], lr_scores) @@ -450,7 +446,7 @@ def lr_grid( & (coor["imagecol"] < grid[0] + width) & (coor["imagerow"] < grid[1]) & (coor["imagerow"] > grid[1] - height) - ] + ] df_grid.loc[n] = df.loc[spots.index].sum() # expand the LR pairs list by swapping ligand-receptor positions diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index 7b3a7bdf..54232564 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -17,10 +17,10 @@ def count( adata: AnnData, - use_label: str = None, + use_label: str | None = None, use_het: str = "cci_het", verbose: bool = True, - distance: float = None, + distance: float | None = None, ) -> AnnData: """Count the cell type densities Parameters diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index b7d9ab61..4e147b07 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -372,7 +372,7 @@ def get_lr_bg( rand_pairs = gen_rand_pairs(l_genes, r_genes, n_pairs) spot_indices = np.where(lr_score > 0)[0] - background = get_lrs_scores( + background, _ = get_lrs_scores( adata, rand_pairs, neighbours, diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 79b118c3..267b224c 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -273,7 +273,7 @@ def perform_perm_testing( # Calculating the background # rand_pairs = get_rand_pairs(adata, genes, n_pairs, lrs=lrs, im=group_im) - background = get_lrs_scores( + background, _ = get_lrs_scores( adata, rand_pairs, neighbours, From 353a6686088ab532d84a67807dcbcdd1bc4d7a84 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 08:27:39 +1000 Subject: [PATCH 014/123] Fixing small mistakes in formatting and doco. --- CONTRIBUTING.rst | 2 +- stlearn/add.py | 15 +++++++-------- stlearn/plotting/cci_plot.py | 17 ++++++++--------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 40cc4228..f6f1d45e 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -87,7 +87,7 @@ Ready to contribute? Here's how to set up `stlearn` for local development. 5. When you're done making changes, check that your changes pass linters and tests:: $ black stlearn tests - $ flake8 stlearn tests + $ ruff check stlearn tests $ mypy stlearn tests $ pytest diff --git a/stlearn/add.py b/stlearn/add.py index 6fd5653d..025a232a 100644 --- a/stlearn/add.py +++ b/stlearn/add.py @@ -1,13 +1,12 @@ +from .adds.add_deconvolution import add_deconvolution from .adds.add_image import image -from .adds.add_positions import positions -from .adds.parsing import parsing -from .adds.add_lr import lr -from .adds.annotation import annotation from .adds.add_labels import labels -from .adds.add_deconvolution import add_deconvolution -from .adds.add_mask import add_mask -from .adds.add_mask import apply_mask from .adds.add_loupe_clusters import add_loupe_clusters +from .adds.add_lr import lr +from .adds.add_mask import add_mask, apply_mask +from .adds.add_positions import positions +from .adds.annotation import annotation +from .adds.parsing import parsing __all__ = [ "image", @@ -20,4 +19,4 @@ "add_mask", "apply_mask", "add_loupe_clusters", -] \ No newline at end of file +] diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index f9150fee..82723338 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -2,22 +2,21 @@ import math import sys from typing import ( - Optional, Any, # Special + Any, + Optional, # Special ) import matplotlib +import matplotlib as plt +import matplotlib.axes as plt_axis +import matplotlib.figure as plt_figure import matplotlib.patches as patches import networkx as nx import numpy as np import pandas as pd -import matplotlib as plt -import matplotlib.axes as plt_axis -import matplotlib.figure as plt_figure from anndata import AnnData from bokeh.io import output_notebook from bokeh.plotting import show -from numpy.typing import NDArray - from scipy.stats import gaussian_kde import stlearn.plotting.cci_plot_helpers as cci_hs @@ -1240,7 +1239,7 @@ def cci_map( def lr_cci_map( adata: AnnData, use_label: str, - lrs: Optional[list | np.ndarray] = None, + lrs: list | np.ndarray | None = None, n_top_lrs: int = 5, n_top_ccis: int = 15, min_total: int = 0, @@ -1262,8 +1261,8 @@ def lr_cci_map( Indicates the cell type labels or deconvolution results used for the cell-cell interaction counting by LR pairs. lrs: list-like - LR pairs to show in the heatmap, if None then top 5 lrs with the highest no. of interactions used from - adata.uns['lr_summary']. + LR pairs to show in the heatmap, if None then top 5 lrs with the highest + no. of interactions used from adata.uns['lr_summary']. n_top_lrs: int Indicates how many top lrs to show; is ignored if lrs is not None. n_top_ccis: int From b68174cba30587706da5d20428bd9978593cd6ef Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 08:56:18 +1000 Subject: [PATCH 015/123] Misc fixes to typing and documentation. --- LICENSE | 4 +--- pyproject.toml | 10 +++++----- stlearn/adds/add_lr.py | 2 +- stlearn/logging.py | 2 +- stlearn/plotting/classes.py | 14 +++++++------- stlearn/plotting/cluster_plot.py | 2 +- stlearn/plotting/feat_plot.py | 2 +- stlearn/plotting/gene_plot.py | 2 +- stlearn/plotting/subcluster_plot.py | 2 +- 9 files changed, 19 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index 626beb6e..fafffeca 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,6 @@ - - BSD License -Copyright (c) 2020, Genomics and Machine Learning lab +Copyright (c) 2020-2025, Genomics and Machine Learning lab All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/pyproject.toml b/pyproject.toml index fda624e5..f97a190c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,11 +27,11 @@ dynamic = ["dependencies"] [project.optional-dependencies] dev = [ - "black", - "ruff", - "mypy", - "pytest", - "tox", + "black>=23.0", + "ruff>=0.1.0", + "mypy>=1.10", + "pytest>=7.0", + "tox>=4.0", ] test = [ "pytest", diff --git a/stlearn/adds/add_lr.py b/stlearn/adds/add_lr.py index d979a2ac..78df7424 100644 --- a/stlearn/adds/add_lr.py +++ b/stlearn/adds/add_lr.py @@ -4,7 +4,7 @@ def lr( adata: AnnData, - db_filepath: str = None, + db_filepath: str, sep: str = "\t", source: str = "connectomedb", copy: bool = False, diff --git a/stlearn/logging.py b/stlearn/logging.py index e23e2786..24421cc2 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -177,7 +177,7 @@ def _copy_docs_and_signature(fn): def error( msg: str, *, - time: datetime = None, + time: datetime | None = None, deep: str | None = None, extra: dict | None = None, ) -> datetime: diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 59c38726..4eb24266 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -256,8 +256,8 @@ def __init__( method: str = "CumSum", contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, + vmin: float | None = None, + vmax: float | None = None, **kwargs, ): super().__init__( @@ -455,7 +455,7 @@ def __init__( color_bar_label: str | None = "", crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -467,8 +467,8 @@ def __init__( threshold: float | None = None, contour: bool = False, step_size: int | None = None, - vmin: float = None, - vmax: float = None, + vmin: float | None = None, + vmax: float | None = None, **kwargs, ): super().__init__( @@ -618,7 +618,7 @@ def __init__( show_color_bar: bool | None = True, crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 5, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, @@ -970,7 +970,7 @@ def __init__( show_color_bar: bool | None = True, crop: bool | None = True, zoom_coord: float | None = None, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 5, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index c5d1b08e..e2c2e0c1 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -30,7 +30,7 @@ def cluster_plot( show_color_bar: bool | None = True, zoom_coord: float | None = None, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 5, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index 1172cbc2..d469ab8c 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -33,7 +33,7 @@ def feat_plot( color_bar_label: str | None = "", zoom_coord: float | None = None, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 0.7, diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index d4ebcdff..290d33e9 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -35,7 +35,7 @@ def gene_plot( color_bar_label: str | None = "", zoom_coord: float | None = None, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 7, image_alpha: float | None = 1.0, cell_alpha: float | None = 0.7, diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index af603e13..4dcef013 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -26,7 +26,7 @@ def subcluster_plot( show_image: bool | None = True, show_color_bar: bool | None = True, crop: bool | None = True, - margin: bool | None = 100, + margin: float | None = 100, size: float | None = 5, image_alpha: float | None = 1.0, cell_alpha: float | None = 1.0, From 7be6dd6e76c5721701d3c8e4e6e13de66c5d99c9 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 09:17:15 +1000 Subject: [PATCH 016/123] Update numpy, tensorflow and numba. --- pyproject.toml | 2 +- requirements.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f97a190c..d5a137fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dynamic = ["dependencies"] dev = [ "black>=23.0", "ruff>=0.1.0", - "mypy>=1.10", + "mypy>=1.16", "pytest>=7.0", "tox>=4.0", ] diff --git a/requirements.txt b/requirements.txt index 616bcd91..7b8e4ed0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,11 @@ bokeh==3.7.3 click==8.2.1 leidenalg==0.10.2 louvain==0.8.2 -numba==0.55.2 -numpy==1.22.4 +numba==0.56.4 +numpy==1.23.5 pillow==11.2.1 -scanpy==1.9.8 +scanpy==1.10.4 scikit-image==0.22.0 -tensorflow==2.13.1 +tensorflow==2.14.1 imageio==2.37.0 scipy==1.11.4 \ No newline at end of file From 9736e3447b4c48b43ee3f3f72b67254c2ee90e89 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 11:00:20 +1000 Subject: [PATCH 017/123] More fixes. --- mypy.ini | 2 + pyproject.toml | 14 ++ requirements.txt | 2 + stlearn/app/app.py | 6 +- stlearn/app/cli.py | 8 +- stlearn/app/source/forms/views.py | 6 +- stlearn/image_preprocessing/model_zoo.py | 12 +- stlearn/image_preprocessing/segmentation.py | 191 -------------------- stlearn/plotting/cci_plot.py | 1 + stlearn/tools/microenv/cci/base.py | 8 +- stlearn/wrapper/read.py | 6 +- 11 files changed, 41 insertions(+), 215 deletions(-) create mode 100644 mypy.ini delete mode 100644 stlearn/image_preprocessing/segmentation.py diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..01f6bb35 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +follow_untyped_imports = True \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d5a137fa..84795255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,20 @@ test = [ "pytest", "pytest-cov", ] +webapp = [ + "flask>=2.0.0", + "flask-wtf>=1.0.0", + "wtforms>=3.0.0", + "markupsafe>2.1.0", +] +jupyter = [ + "jupyter>=1.0.0", + "jupyterlab>=3.0.0", + "ipywidgets>=7.6.0", + "plotly>=5.0.0", + "bokeh>=2.4.0", + "rpy2>=3.4.0", +] [project.urls] Homepage = "https://github.com/BiomedicalMachineLearning/stLearn" diff --git a/requirements.txt b/requirements.txt index 7b8e4ed0..a1f23831 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,7 @@ pillow==11.2.1 scanpy==1.10.4 scikit-image==0.22.0 tensorflow==2.14.1 +keras==2.14.0 +types-tensorflow>=2.8.0 imageio==2.37.0 scipy==1.11.4 \ No newline at end of file diff --git a/stlearn/app/app.py b/stlearn/app/app.py index 8f6ccd74..7343ceeb 100644 --- a/stlearn/app/app.py +++ b/stlearn/app/app.py @@ -6,7 +6,7 @@ sys.path.append(os.path.dirname(__file__)) try: - import flask # noqa: F401 + import flask except ImportError: subprocess.call( "pip install -r " + os.path.dirname(__file__) + "//requirements.txt", shell=True @@ -34,7 +34,7 @@ ) # Functions related to processing the forms. -from source.forms import views # for changing data in response to input +from stlearn.app.source.forms import views # for changing data in response to input from tornado.ioloop import IOLoop from werkzeug.utils import secure_filename @@ -482,7 +482,7 @@ def bk_worker(): "/bokeh_annotate_plot": bkapp4, }, io_loop=IOLoop(), - allow_websocket_origin=["127.0.0.1:5000", "localhost:5000"], + allow_websocket_origin=["127.0.0.1:3000", "localhost:3000"], ) server.start() server.io_loop.start() diff --git a/stlearn/app/cli.py b/stlearn/app/cli.py index ff45c24a..42e92779 100644 --- a/stlearn/app/cli.py +++ b/stlearn/app/cli.py @@ -1,6 +1,4 @@ import errno -import os - import click from .. import __version__ @@ -20,7 +18,6 @@ help="Show the software version and exit.", ) def main(): - os._exit click.echo("Please run `stlearn launch` to start the web app") @@ -29,10 +26,13 @@ def launch(): from .app import app try: - app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False) + app.run(host="0.0.0.0", port=3000, debug=True, use_reloader=False) except OSError as e: if e.errno == errno.EADDRINUSE: raise click.ClickException( "Port is in use, please specify an open port using the --port flag." ) from e raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/stlearn/app/source/forms/views.py b/stlearn/app/source/forms/views.py index 09dc3888..30a2c672 100644 --- a/stlearn/app/source/forms/views.py +++ b/stlearn/app/source/forms/views.py @@ -9,10 +9,10 @@ import numpy import numpy as np import scanpy as sc -import source.forms.view_helpers as vhs from flask import flash, render_template -from source.forms import forms -from source.forms.utils import flash_errors +import stlearn.app.source.forms.view_helpers as vhs +from stlearn.app.source.forms import forms +from stlearn.app.source.forms.utils import flash_errors import stlearn as st diff --git a/stlearn/image_preprocessing/model_zoo.py b/stlearn/image_preprocessing/model_zoo.py index 7faf2673..c21af134 100644 --- a/stlearn/image_preprocessing/model_zoo.py +++ b/stlearn/image_preprocessing/model_zoo.py @@ -8,7 +8,7 @@ class Model: __name__ = "CNN base model" def __init__(self, base, batch_size=1): - from tensorflow.keras import backend as keras + from keras import backend as keras self.base = base self.model, self.preprocess = self.load_model() @@ -17,7 +17,7 @@ def __init__(self, base, batch_size=1): def load_model(self): if self.base == "resnet50": - from tensorflow.keras.applications.resnet50 import ( + from keras.applications.resnet50 import ( ResNet50, preprocess_input, ) @@ -26,11 +26,11 @@ def load_model(self): include_top=False, weights="imagenet", pooling="avg" ) elif self.base == "vgg16": - from tensorflow.keras.applications.vgg16 import VGG16, preprocess_input + from keras.applications.vgg16 import VGG16, preprocess_input cnn_base_model = VGG16(include_top=False, weights="imagenet", pooling="avg") elif self.base == "inception_v3": - from tensorflow.keras.applications.inception_v3 import ( + from keras.applications.inception_v3 import ( InceptionV3, preprocess_input, ) @@ -39,7 +39,7 @@ def load_model(self): include_top=False, weights="imagenet", pooling="avg" ) elif self.base == "xception": - from tensorflow.keras.applications.xception import ( + from keras.applications.xception import ( Xception, preprocess_input, ) @@ -52,7 +52,7 @@ def load_model(self): return cnn_base_model, preprocess_input def predict(self, x): - from tensorflow.keras import backend as keras + from keras import backend as keras if self.data_format == "channels_first": x = x.transpose(0, 3, 1, 2) diff --git a/stlearn/image_preprocessing/segmentation.py b/stlearn/image_preprocessing/segmentation.py deleted file mode 100644 index 6d975aab..00000000 --- a/stlearn/image_preprocessing/segmentation.py +++ /dev/null @@ -1,191 +0,0 @@ -import histomicstk as htk -import numpy as np -import scipy as sp -import skimage.color -import skimage.io -import skimage.measure -from anndata import AnnData -from scipy import ndimage as ndi -from skimage.feature import peak_local_max -from skimage.segmentation import watershed -from tqdm import tqdm - - -def morph_watershed( - adata: AnnData, - library_id: str = None, - verbose: bool = False, - copy: bool = False, -) -> AnnData | None: - """\ - Watershed method to segment nuclei and calculate morphological statistics - - Parameters - ---------- - adata - Annotated data matrix. - library_id - Library id stored in AnnData. - copy - Return a copy instead of writing to adata. - Returns - ------- - Depending on `copy`, returns or updates `adata` with the following fields. - **n_nuclei** : `adata.obs` field - saved number of nuclei of each spot image tiles - **nuclei_total_area** : `adata.obs` field - saved of total area of nuclei of each spot image tiles - **nuclei_mean_area** : `adata.obs` field - saved mean area of nuclei of each spot image tiles - **nuclei_std_area** : `adata.obs` field - saved stand deviation of nuclei area of each spot image tiles - **eccentricity** : `adata.obs` field - saved eccentricity of each spot image tiles - **mean_pix_r** : `adata.obs` field - saved mean pixel value of red channel of of each spot image tiles - **std_pix_r** : `adata.obs` field - saved stand deviation of red channel of each spot image tiles - **mean_pix_g** : `adata.obs` field - saved mean pixel value of green channel of each spot image tiles - **std_pix_g** : `adata.obs` field - saved stand deviation of green channel of each spot image tiles - **mean_pix_b** : `adata.obs` field - saved mean pixel value of blue channel of each spot image tiles - **std_pix_b** : `adata.obs` field - saved stand deviation of blue channel of each spot image tiles - **nuclei_total_area_per_tile** : `adata.obs` field - saved total nuclei area per tile of each spot image tiles - """ - - if library_id is None: - library_id = list(adata.uns["spatial"].keys())[0] - - n_nuclei_list = [] - nuclei_total_area_list = [] - nuclei_mean_area_list = [] - nuclei_std_area_list = [] - eccentricity_list = [] - mean_pix_list_r = [] - std_pix_list_r = [] - mean_pix_list_g = [] - std_pix_list_g = [] - mean_pix_list_b = [] - std_pix_list_b = [] - with tqdm( - total=len(adata), - desc="calculate morphological stats", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - ) as pbar: - for tile in adata.obs["tile_path"]: - ( - n_nuclei, - nuclei_total_area, - nuclei_mean_area, - nuclei_std_area, - eccentricity, - solidity, - mean_pix_r, - std_pix_r, - mean_pix_g, - std_pix_g, - mean_pix_b, - std_pix_b, - ) = _calculate_morph_stats(tile) - n_nuclei_list.append(n_nuclei) - nuclei_total_area_list.append(nuclei_total_area) - nuclei_mean_area_list.append(nuclei_mean_area) - nuclei_std_area_list.append(nuclei_std_area) - eccentricity_list.append(eccentricity) - mean_pix_list_r.append(mean_pix_r) - std_pix_list_r.append(std_pix_r) - mean_pix_list_g.append(mean_pix_g) - std_pix_list_g.append(std_pix_g) - mean_pix_list_b.append(mean_pix_b) - std_pix_list_b.append(std_pix_b) - pbar.update(1) - - adata.obs["n_nuclei"] = n_nuclei_list - adata.obs["nuclei_total_area"] = nuclei_total_area_list - adata.obs["nuclei_mean_area"] = nuclei_mean_area_list - adata.obs["nuclei_std_area"] = nuclei_std_area_list - adata.obs["eccentricity"] = eccentricity_list - adata.obs["mean_pix_r"] = mean_pix_list_r - adata.obs["std_pix_r"] = std_pix_list_r - adata.obs["mean_pix_g"] = mean_pix_list_g - adata.obs["std_pix_g"] = std_pix_list_g - adata.obs["mean_pix_b"] = mean_pix_list_b - adata.obs["std_pix_b"] = std_pix_list_b - adata.obs["nuclei_total_area_per_tile"] = adata.obs["nuclei_total_area"] / 299 / 299 - return adata if copy else None - - -def _calculate_morph_stats(tile_path): - imInput = skimage.io.imread(tile_path) - stain_color_map = htk.preprocessing.color_deconvolution.stain_color_map - stains = [ - "hematoxylin", # nuclei stain - "eosin", # cytoplasm stain - "null", - ] # set to null if input contains only two stains - w_est = htk.preprocessing.color_deconvolution.rgb_separate_stains_macenko_pca( - imInput, 255 - ) - - # Perform color deconvolution - deconv_result = htk.preprocessing.color_deconvolution.color_deconvolution( - imInput, w_est, 255 - ) - - channel = htk.preprocessing.color_deconvolution.find_stain_index( - stain_color_map[stains[0]], w_est - ) - im_nuclei_stain = deconv_result.Stains[:, :, channel] - - thresh = skimage.filters.threshold_otsu(im_nuclei_stain) - # im_fgnd_mask = im_nuclei_stain < thresh - im_fgnd_mask = sp.ndimage.morphology.binary_fill_holes( - im_nuclei_stain < 0.8 * thresh - ) - - distance = ndi.distance_transform_edt(im_fgnd_mask) - coords = peak_local_max(distance, footprint=np.ones((3, 3)), labels=im_fgnd_mask) - mask = np.zeros(distance.shape, dtype=bool) - mask[tuple(coords.T)] = True - markers, _ = ndi.label(mask) - - labels = watershed(im_nuclei_stain, markers, mask=im_fgnd_mask) - min_nucleus_area = 60 - im_nuclei_seg_mask = htk.segmentation.label.area_open( - labels, min_nucleus_area - ).astype(np.int64) - - # compute nuclei properties - objProps = skimage.measure.regionprops(im_nuclei_seg_mask) - - n_nuclei = len(objProps) - - nuclei_total_area = sum(map(lambda x: x.area, objProps)) - nuclei_mean_area = np.mean(list(map(lambda x: x.area, objProps))) - nuclei_std_area = np.std(list(map(lambda x: x.area, objProps))) - - mean_pix = imInput.reshape(3, -1).mean(1) - std_pix = imInput.reshape(3, -1).std(1) - - eccentricity = np.mean(list(map(lambda x: x.eccentricity, objProps))) - - solidity = np.mean(list(map(lambda x: x.solidity, objProps))) - - return ( - n_nuclei, - nuclei_total_area, - nuclei_mean_area, - nuclei_std_area, - eccentricity, - solidity, - mean_pix[0], - std_pix[0], - mean_pix[1], - std_pix[1], - mean_pix[2], - std_pix[2], - ) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 82723338..880e590b 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -1574,6 +1574,7 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) + # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index 2e852d3a..6a5b5259 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -95,7 +95,7 @@ def calc_distance(adata: AnnData, distance: float | None): scalefactors["spot_diameter_fullres"] * scalefactors[ "tissue_" + adata.uns["spatial"][library_id]["use_quality"] + "_scalef" - ] + ] * 2 ) return distance @@ -148,7 +148,7 @@ def get_lrs_scores( new_lrs = np.array( [ - "_".join(spot_lr1s.columns.values[i: i + 2]) + "_".join(spot_lr1s.columns.values[i : i + 2]) for i in range(0, spot_lr1s.shape[1], 2) ] ) @@ -386,7 +386,7 @@ def get_scores( spot_scores = np.zeros((len(spot_indices), spot_lr1s.shape[1] // 2), np.float64) for i in prange(0, spot_lr1s.shape[1] // 2): i_ = i * 2 # equivalent to range(0, spot_lr1s.shape[1], 2) - spot_lr1, spot_lr2 = spot_lr1s[:, i_: (i_ + 2)], spot_lr2s[:, i_: (i_ + 2)] + spot_lr1, spot_lr2 = spot_lr1s[:, i_ : (i_ + 2)], spot_lr2s[:, i_ : (i_ + 2)] lr_scores = lr_core(spot_lr1, spot_lr2, neighbours, min_expr, spot_indices) # The merge scores # lr_scores = np.multiply(het_vals[spot_indices], lr_scores) @@ -446,7 +446,7 @@ def lr_grid( & (coor["imagecol"] < grid[0] + width) & (coor["imagerow"] < grid[1]) & (coor["imagerow"] > grid[1] - height) - ] + ] df_grid.loc[n] = df.loc[spots.index].sum() # expand the LR pairs list by swapping ligand-receptor positions diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index ee44242d..be44ee8d 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -179,9 +179,7 @@ def Read10X( image_path ) else: - raise ValueError( - "Trying to load fulres but no image_path set." - ) + raise ValueError("Trying to load fulres but no image_path set.") image_coor = adata.obsm["spatial"] img = plt.imread(image_path, None) @@ -258,7 +256,7 @@ def ReadOldST( def ReadSlideSeq( count_matrix_file: str | Path, spatial_file: str | Path, - library_id: str| None = None, + library_id: str | None = None, scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, From d8fff79f93ba1993e2d400c5949babfbe035db60 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 11:05:23 +1000 Subject: [PATCH 018/123] Ignore site packages. --- mypy.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index 01f6bb35..d40e59ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,3 @@ [mypy] -follow_untyped_imports = True \ No newline at end of file +follow_untyped_imports = True +no_site_packages = True From 0f32bfabadc8af6c1cf6acdcc91439417341f835 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 13:45:30 +1000 Subject: [PATCH 019/123] Fixing types - especially in logging. --- mypy.ini | 1 + stlearn/_settings.py | 50 ++-- stlearn/logging.py | 213 +++++++++++++----- stlearn/plotting/classes.py | 2 +- stlearn/plotting/cluster_plot.py | 2 + stlearn/plotting/gene_plot.py | 59 ++--- stlearn/plotting/trajectory/tree_plot.py | 11 +- .../plotting/trajectory/tree_plot_simple.py | 11 +- 8 files changed, 234 insertions(+), 115 deletions(-) diff --git a/mypy.ini b/mypy.ini index d40e59ee..5d8b6f99 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,4 @@ [mypy] follow_untyped_imports = True no_site_packages = True +ignore_missing_imports = True diff --git a/stlearn/_settings.py b/stlearn/_settings.py index b3c06afa..97276dbd 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -6,7 +6,7 @@ from logging import getLevelName from pathlib import Path from time import time -from typing import Any, TextIO +from typing import Any, TextIO, Iterator from . import logging from ._compat import Literal @@ -15,7 +15,7 @@ # All the code here migrated from scanpy # It help to work with scanpy package -_VERBOSITY_TO_LOGLEVEL = { +_VERBOSITY_TO_LOGLEVEL: dict[str | int, str] = { "error": "ERROR", "warning": "WARNING", "info": "INFO", @@ -40,7 +40,7 @@ def level(self) -> int: return getLevelName(_VERBOSITY_TO_LOGLEVEL[self]) @contextmanager - def override(self, verbosity: "Verbosity") -> AbstractContextManager["Verbosity"]: + def override(self, verbosity: "Verbosity") -> Iterator["Verbosity"]: """\ Temporarily override verbosity """ @@ -66,6 +66,9 @@ class stLearnConfig: # noqa N801 """\ Config manager for scanpy. """ + _logpath: Path | None + _logfile: TextIO + _verbosity: Verbosity def __init__( self, @@ -144,9 +147,9 @@ def verbosity(self, verbosity: Verbosity | int | str): v for v in _VERBOSITY_TO_LOGLEVEL if isinstance(v, str) ] if isinstance(verbosity, Verbosity): - self._verbosity = verbosity + new_verbosity = verbosity elif isinstance(verbosity, int): - self._verbosity = Verbosity(verbosity) + new_verbosity = Verbosity(verbosity) elif isinstance(verbosity, str): verbosity = verbosity.lower() if verbosity not in verbosity_str_options: @@ -155,10 +158,9 @@ def verbosity(self, verbosity: Verbosity | int | str): f"Accepted string values are: {verbosity_str_options}" ) else: - self._verbosity = Verbosity(verbosity_str_options.index(verbosity)) - else: - _type_check(verbosity, "verbosity", (str, int)) - _set_log_level(self, _VERBOSITY_TO_LOGLEVEL[self._verbosity]) + new_verbosity = Verbosity(verbosity_str_options.index(verbosity)) + self._verbosity = new_verbosity + _set_log_level(self, self._verbosity) @property def plot_suffix(self) -> str: @@ -334,10 +336,13 @@ def logpath(self) -> Path | None: @logpath.setter def logpath(self, logpath: str | Path | None): - _type_check(logpath, "logfile", (str, Path)) - # set via “file object” branch of logfile.setter - self.logfile = Path(logpath).open("a") - self._logpath = Path(logpath) + if logpath is None: + self._logpath = None + else: + _type_check(logpath, "logpath", (str, Path)) + # set via “file object” branch of logfile.setter + self.logfile = Path(logpath).open("a") + self._logpath = Path(logpath) @property def logfile(self) -> TextIO: @@ -355,14 +360,17 @@ def logfile(self) -> TextIO: @logfile.setter def logfile(self, logfile: str | Path | TextIO | None): - if not hasattr(logfile, "write") and logfile: - self.logpath = logfile - else: # file object - if not logfile: # None or '' - logfile = sys.stdout if self._is_run_from_ipython() else sys.stderr + if logfile is None or logfile == "": + self._logfile = sys.stdout if self._is_run_from_ipython() else sys.stderr + self._logpath = None + elif isinstance(logfile, (str, Path)): + path = Path(logfile) + self._logfile = path.open("a") + self._logpath = path + elif isinstance(logfile, TextIO): self._logfile = logfile self._logpath = None - _set_log_file(self) + _set_log_file(self) @property def categories_to_ignore(self) -> list[str]: @@ -443,9 +451,7 @@ def set_figure_params( try: import IPython - if isinstance(ipython_format, str): - ipython_format = [ipython_format] - IPython.display.set_matplotlib_formats(*ipython_format) + IPython.display.set_matplotlib_formats(*[ipython_format]) except Exception: pass from matplotlib import rcParams diff --git a/stlearn/logging.py b/stlearn/logging.py index 24421cc2..cfacdfad 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from functools import partial, update_wrapper from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING +from typing import Dict, Any, Optional, overload, Mapping, Union import anndata.logging @@ -11,25 +12,34 @@ logging.addLevelName(HINT, "HINT") +class CustomLogRecord(logging.LogRecord): + """Custom root logger that maintains compatibility with standard logging interface.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.time_passed: Optional[timedelta] = None + self.deep: Optional[str] = None + + class _RootLogger(logging.RootLogger): def __init__(self, level): super().__init__(level) self.propagate = False _RootLogger.manager = logging.Manager(self) - def log( - self, - level: int, - msg: str, - *, - extra: dict | None = None, - time: datetime = None, - deep: str | None = None, + def log_with_timing( + self, + level: int, + msg: str, + *, + extra: dict | None = None, + time: datetime | None = None, + deep: str | None = None, ) -> datetime: from . import settings now = datetime.now(timezone.utc) - time_passed: timedelta = None if time is None else now - time + time_passed: Optional[timedelta] = None if time is None else now - time extra = { **(extra or {}), "deep": deep if settings.verbosity.level < level else None, @@ -38,23 +48,101 @@ def log( super().log(level, msg, extra=extra) return now - def critical(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(CRITICAL, msg, time=time, deep=deep, extra=extra) - - def error(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(ERROR, msg, time=time, deep=deep, extra=extra) - - def warning(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(WARNING, msg, time=time, deep=deep, extra=extra) - - def info(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(INFO, msg, time=time, deep=deep, extra=extra) - - def hint(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(HINT, msg, time=time, deep=deep, extra=extra) - - def debug(self, msg, *, time=None, deep=None, extra=None) -> datetime: - return self.log(DEBUG, msg, time=time, deep=deep, extra=extra) + def _handle_enhanced_logging(self, level: int, msg, *args, **kwargs) -> Optional[ + datetime]: + """Handle logging with enhanced features (timing, deep info) or fall back to standard logging.""" + if 'time' in kwargs or 'deep' in kwargs or 'extra' in kwargs: + # Extract enhanced arguments + time_arg = kwargs.pop('time', None) + deep_arg = kwargs.pop('deep', None) + extra_arg = kwargs.pop('extra', None) + + # Format message if there are remaining args + if args or kwargs: + formatted_msg = msg % args if args else msg + else: + formatted_msg = msg + + return self.log_with_timing(level, formatted_msg, + time=time_arg, deep=deep_arg, extra=extra_arg) + else: + super().log(level, msg, *args, **kwargs) + return None + + def hint(self, msg, *, time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> datetime: + return self.log_with_timing(HINT, msg, time=time, deep=deep, extra=extra) + + @overload + def debug(self, msg: object, *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None) -> None: + ... + + @overload + def debug(self, msg, *args, **kwargs): + ... + + def debug(self, msg, *args, **kwargs) -> Optional[datetime]: + return self._handle_enhanced_logging(DEBUG, msg, *args, **kwargs) + + @overload + def info(self, msg: object, *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None) -> None: + ... + + @overload + def info(self, msg, *args, **kwargs): + ... + + def info(self, msg, *args, **kwargs) -> Optional[datetime]: + return self._handle_enhanced_logging(INFO, msg, *args, **kwargs) + + @overload + def warning(self, msg: object, *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None) -> None: + ... + + @overload + def warning(self, msg, *args, **kwargs): + ... + + def warning(self, msg, *args, **kwargs) -> Optional[datetime]: + return self._handle_enhanced_logging(WARNING, msg, *args, **kwargs) + + @overload + def error(self, msg: object, *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None) -> None: + ... + + @overload + def error(self, msg, *args, **kwargs): + ... + + def error(self, msg, *args, **kwargs) -> Optional[datetime]: + return self._handle_enhanced_logging(ERROR, msg, *args, **kwargs) + + @overload + def critical(self, msg: object, *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None) -> None: + ... + + @overload + def critical(self, msg, *args, **kwargs): + ... + + def critical(self, msg, *args, **kwargs) -> Optional[datetime]: + return self._handle_enhanced_logging(CRITICAL, msg, *args, **kwargs) def _set_log_file(settings): @@ -80,7 +168,7 @@ def _set_log_level(settings, level: int): class _LogFormatter(logging.Formatter): def __init__( - self, fmt="{levelname}: {message}", datefmt="%Y-%m-%d %H:%M", style="{" + self, fmt="{levelname}: {message}", datefmt="%Y-%m-%d %H:%M", style="{" ): super().__init__(fmt, datefmt, style) @@ -92,20 +180,28 @@ def format(self, record: logging.LogRecord): self._style._fmt = "--> {message}" elif record.levelno == DEBUG: self._style._fmt = " {message}" - if record.time_passed: - # strip microseconds - if record.time_passed.microseconds: - record.time_passed = timedelta( - seconds=int(record.time_passed.total_seconds()) + + # Handle time_passed if present (should be in extra) + time_passed = getattr(record, 'time_passed', None) + if time_passed: + # Strip microseconds + if time_passed.microseconds: + time_passed = timedelta( + seconds=int(time_passed.total_seconds()) ) if "{time_passed}" in record.msg: record.msg = record.msg.replace( - "{time_passed}", str(record.time_passed) + "{time_passed}", str(time_passed) ) else: self._style._fmt += " ({time_passed})" - if record.deep: - record.msg = f"{record.msg}: {record.deep}" + # Add time_passed to record for formatting + record.time_passed = time_passed + + deep = getattr(record, 'deep', None) + if deep: + record.msg = f"{record.msg}: {deep}" + result = logging.Formatter.format(self, record) self._style._fmt = format_orig return result @@ -114,7 +210,6 @@ def format(self, record: logging.LogRecord): print_memory_usage = anndata.logging.print_memory_usage get_memory_usage = anndata.logging.get_memory_usage - _DEPENDENCIES_NUMERICS = [ "anndata", # anndata actually shouldn't, but as long as it's in development "umap", @@ -127,7 +222,6 @@ def format(self, record: logging.LogRecord): "louvain", ] - _DEPENDENCIES_PLOTTING = ["matplotlib", "seaborn"] @@ -171,15 +265,16 @@ def print_version_and_date(): def _copy_docs_and_signature(fn): + """Copy documentation and signature from function.""" return partial(update_wrapper, wrapped=fn, assigned=["__doc__", "__annotations__"]) def error( - msg: str, - *, - time: datetime | None = None, - deep: str | None = None, - extra: dict | None = None, + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, ) -> datetime: """\ Log message with specific level and return current time. @@ -194,39 +289,47 @@ def error( If `msg` contains `{time_passed}`, the time difference is instead inserted at that position. deep - If the current verbosity is higher than the log function’s level, + If the current verbosity is higher than the log function's level, this gets displayed as well extra Additional values you can specify in `msg` like `{time_passed}`. """ from ._settings import settings - return settings._root_logger.error(msg, time=time, deep=deep, extra=extra) + result = settings._root_logger.error(msg, time=time, deep=deep, extra=extra) + return result or datetime.now(timezone.utc) @_copy_docs_and_signature(error) -def warning(msg, *, time=None, deep=None, extra=None) -> datetime: +def warning(msg: str, *, time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> datetime: from ._settings import settings - - return settings._root_logger.warning(msg, time=time, deep=deep, extra=extra) + result = settings._root_logger.warning(msg, time=time, deep=deep, extra=extra) + return result or datetime.now(timezone.utc) @_copy_docs_and_signature(error) -def info(msg, *, time=None, deep=None, extra=None) -> datetime: +def info(msg: str, *, time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> datetime: from ._settings import settings - - return settings._root_logger.info(msg, time=time, deep=deep, extra=extra) + result = settings._root_logger.info(msg, time=time, deep=deep, extra=extra) + return result or datetime.now(timezone.utc) @_copy_docs_and_signature(error) -def hint(msg, *, time=None, deep=None, extra=None) -> datetime: +def hint(msg: str, *, time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> datetime: from ._settings import settings - return settings._root_logger.hint(msg, time=time, deep=deep, extra=extra) @_copy_docs_and_signature(error) -def debug(msg, *, time=None, deep=None, extra=None) -> datetime: +def debug(msg: str, *, time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> datetime: from ._settings import settings - - return settings._root_logger.debug(msg, time=time, deep=deep, extra=extra) + result = settings._root_logger.debug(msg, time=time, deep=deep, extra=extra) + return result or datetime.now(timezone.utc) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 4eb24266..108012a9 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -251,7 +251,7 @@ def __init__( fname: str | None = None, dpi: int | None = 120, # gene plot param - gene_symbols: str | list = None, + gene_symbols: str | list | None = None, threshold: float | None = None, method: str = "CumSum", contour: bool = False, diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index e2c2e0c1..5ed28cb8 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -112,6 +112,8 @@ def cluster_plot( trajectory_arrowsize=trajectory_arrowsize, ) + return adata + def cluster_plot_interactive( adata: AnnData, diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index 290d33e9..419c4200 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -15,35 +15,35 @@ @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def gene_plot( - adata: AnnData, - gene_symbols: str | list = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + gene_symbols: str | list | None = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str | None = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool | None = True, + show_axis: bool | None = False, + show_image: bool | None = True, + show_color_bar: bool | None = True, + color_bar_label: str | None = "", + zoom_coord: float | None = None, + crop: bool | None = True, + margin: float | None = 100, + size: float | None = 7, + image_alpha: float | None = 1.0, + cell_alpha: float | None = 0.7, + use_raw: bool | None = False, + fname: str | None = None, + dpi: int | None = 120, + vmin: float | None = None, + vmax: float | None = None, ) -> AnnData | None: """\ Allows the visualization of a single gene or multiple genes as the values @@ -94,6 +94,7 @@ def gene_plot( vmin=vmin, vmax=vmax, ) + return adata def gene_plot_interactive(adata: AnnData): diff --git a/stlearn/plotting/trajectory/tree_plot.py b/stlearn/plotting/trajectory/tree_plot.py index 71992608..88583991 100644 --- a/stlearn/plotting/trajectory/tree_plot.py +++ b/stlearn/plotting/trajectory/tree_plot.py @@ -1,5 +1,6 @@ import math import random +from typing import Tuple import networkx as nx from anndata import AnnData @@ -10,16 +11,16 @@ def tree_plot( adata: AnnData, - library_id: str = None, - figsize: float | int = (10, 4), + library_id: str | None = None, + figsize: Tuple[float, float] = (10, 4), data_alpha: float = 1.0, use_label: str = "louvain", spot_size: float | int = 50, fontsize: int = 6, piesize: float = 0.15, zoom: float = 0.1, - name: str = None, - output: str = None, + name: str | None = None, + output: str | None = None, dpi: int = 180, show_all: bool = False, show_plot: bool = True, @@ -104,6 +105,8 @@ def tree_plot( if show_plot: plt.show() + return adata + def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5): """ diff --git a/stlearn/plotting/trajectory/tree_plot_simple.py b/stlearn/plotting/trajectory/tree_plot_simple.py index 92e0cb07..03f1b7bd 100644 --- a/stlearn/plotting/trajectory/tree_plot_simple.py +++ b/stlearn/plotting/trajectory/tree_plot_simple.py @@ -1,5 +1,6 @@ import math import random +from typing import Tuple import networkx as nx from anndata import AnnData @@ -10,16 +11,16 @@ def tree_plot_simple( adata: AnnData, - library_id: str = None, - figsize: float | int = (10, 4), + library_id: str | None = None, + figsize: Tuple[float, float] = (10, 4), data_alpha: float = 1.0, use_label: str = "louvain", spot_size: float | int = 50, fontsize: int = 6, piesize: float = 0.15, zoom: float = 0.1, - name: str = None, - output: str = None, + name: str | None = None, + output: str | None = None, dpi: int = 180, show_all: bool = False, show_plot: bool = True, @@ -104,6 +105,8 @@ def tree_plot_simple( if show_plot: plt.show() + return adata + def hierarchy_pos(G, root=None, width=1.0, vert_gap=0.2, vert_loc=0, xcenter=0.5): """ From eb94410424d07ef31212b67b9172dcde9b15c607 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 15:12:34 +1000 Subject: [PATCH 020/123] Fixing types. --- stlearn/classes.py | 2 + stlearn/plotting/cci_plot.py | 62 +-- stlearn/plotting/classes.py | 500 +++++++++--------- stlearn/plotting/classes_bokeh.py | 26 +- stlearn/plotting/cluster_plot.py | 52 +- stlearn/plotting/feat_plot.py | 34 +- stlearn/plotting/gene_plot.py | 34 +- stlearn/plotting/subcluster_plot.py | 30 +- .../spatials/trajectory/pseudotimespace.py | 6 +- stlearn/tools/microenv/cci/perm_utils.py | 2 +- stlearn/tools/microenv/cci/permutation.py | 25 +- stlearn/utils.py | 13 +- 12 files changed, 412 insertions(+), 374 deletions(-) diff --git a/stlearn/classes.py b/stlearn/classes.py index 12c25ede..2c9026e9 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -19,6 +19,8 @@ class Spatial: + img: np.ndarray | None + def __init__( self, adata: AnnData, diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 880e590b..5c053747 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -3,7 +3,7 @@ import sys from typing import ( Any, - Optional, # Special + Optional, Tuple, # Special ) import matplotlib @@ -424,24 +424,24 @@ def lr_result_plot( use_lr: Optional["str"] = None, use_result: Optional["str"] = "lr_sig_scores", # plotting param - title: Optional["str"] = None, + title: str | None = None, figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", + cmap: str = "Spectral_r", ax: plt_axis.Axes | None = None, fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, contour: bool = False, step_size: int | None = None, vmin: float | None = None, @@ -474,6 +474,8 @@ def lr_result_plot( Whether to show axis or not. show_image: bool Whether to plot the image. + zoom_coord: Tuple[float, float, float, float] + Bounding box of plot. show_color_bar: bool Whether to show the color bar. crop: bool @@ -890,28 +892,28 @@ def lr_plot( def het_plot( adata: AnnData, # plotting param - title: Optional["str"] = None, + title: str | None = None, figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", + cmap: str = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, ax: plt_axis.Axes | None = None, fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, # cci_rank param - use_het: str | None = "het", + use_het: str = "het", contour: bool = False, step_size: int | None = None, vmin: float | None = None, diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 108012a9..6a6db687 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -7,7 +7,7 @@ import numbers import warnings from typing import ( # Special - Optional, # Classes + Optional, Tuple, # Classes ) import matplotlib @@ -18,38 +18,38 @@ from anndata import AnnData from scipy.interpolate import griddata +from .utils import centroidpython, check_sublist, get_cluster, get_cmap, get_node from ..classes import Spatial from ..utils import Axes, _AxesSubplot, _read_graph -from .utils import centroidpython, check_sublist, get_cluster, get_cmap, get_node class SpatialBasePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - **kwds, + self, + # plotting param + adata: AnnData, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 0.7, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + **kwds, ): super().__init__( adata, @@ -73,7 +73,7 @@ def __init__( if use_label is not None: assert ( - use_label in self.adata[0].obs.columns + use_label in self.adata[0].obs.columns ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label @@ -103,8 +103,8 @@ def __init__( stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) if cmap is str: assert cmap in cmap_available, error_msg @@ -139,7 +139,7 @@ def create_query(list_cl, use_label): if self.list_clusters is not None: # IF not all clusters specified, subset, otherwise just copy. if len(self.list_clusters) != len( - self.adata[0].obs[self.use_label].cat.categories + self.adata[0].obs[self.use_label].cat.categories ): self.query_adata = self.query_adata[ self.query_adata.obs.query( @@ -178,12 +178,11 @@ def _remove_axis(self, main_ax: Axes): def _crop_image(self, main_ax: _AxesSubplot, margin: float): main_ax.set_xlim(self.imagecol.min() - margin, self.imagecol.max() + margin) - main_ax.set_ylim(self.imagerow.min() - margin, self.imagerow.max() + margin) - main_ax.set_ylim(main_ax.get_ylim()[::-1]) - def _zoom_image(self, main_ax: _AxesSubplot, zoom_coord: float | None): + def _zoom_image(self, main_ax: _AxesSubplot, + zoom_coord: Tuple[float, float, float, float]): main_ax.set_xlim(zoom_coord[0], zoom_coord[1]) main_ax.set_ylim(zoom_coord[3], zoom_coord[2]) @@ -225,40 +224,42 @@ def _save_output(self): class GenePlot(SpatialBasePlot): + gene_symbols: list[str] + def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # gene plot param - gene_symbols: str | list | None = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: str | None = None, + figsize: Tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # gene plot param + gene_symbols: str | list[str] | None = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -292,17 +293,18 @@ def __init__( self.step_size = step_size + if isinstance(gene_symbols, str): + self.gene_symbols = [gene_symbols] + elif gene_symbols is None: + self.gene_symbols = [] + else: + self.gene_symbols = gene_symbols + if self.title is None: - if gene_symbols is str: - self.title = str(gene_symbols) - gene_symbols = [gene_symbols] - else: - self.title = ", ".join(gene_symbols) + self.title = ", ".join(self.gene_symbols) self._add_title() - self.gene_symbols = gene_symbols - gene_values = self._get_gene_expression() self.available_ids = self._add_threshold(gene_values, threshold) @@ -438,38 +440,38 @@ def _add_threshold(self, gene_values, threshold): class FeaturePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # gene plot param - feature: str = None, - threshold: float | None = None, - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # gene plot param + feature: str | None = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -526,7 +528,7 @@ def _get_feature_values(self): self.feature + " is not in data.obs, please try another feature" ) elif not isinstance( - self.query_adata.obs[self.feature].values[0], numbers.Number + self.query_adata.obs[self.feature].values[0], numbers.Number ): raise ValueError( self.feature @@ -602,44 +604,44 @@ def _add_threshold(self, feature_values, threshold): # Cluster plot class class ClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "default", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # cluster plot param - show_subcluster: bool | None = False, - show_cluster_labels: bool | None = False, - show_trajectories: bool | None = False, - reverse: bool | None = False, - show_node: bool | None = False, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - color_bar_size: float | None = 10, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - # trajectory - trajectory_node_size: int | None = 10, - trajectory_alpha: float | None = 1.0, - trajectory_width: float | None = 2.5, - trajectory_edge_color: str | None = "#f4efd3", - trajectory_arrowsize: int | None = 17, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + fname: str | None = None, + dpi: int = 120, + # cluster plot param + show_subcluster: bool = False, + show_cluster_labels: bool = False, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = False, + threshold_spots: int = 5, + text_box_size: float = 5, + color_bar_size: float = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int = 10, + trajectory_alpha: float = 1.0, + trajectory_width: float = 2.5, + trajectory_edge_color: str = "#f4efd3", + trajectory_arrowsize: int = 17, ): super().__init__( adata=adata, @@ -764,7 +766,7 @@ def _add_cluster_labels(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -807,7 +809,7 @@ def _add_sub_clusters(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -817,18 +819,18 @@ def _add_sub_clusters(self): imgrow_new = subset_spatial[:, 1] * self.scale_factor if ( - len( - self.query_adata.obs[ - self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() - ) - < 2 + len( + self.query_adata.obs[ + self.query_adata.obs[self.use_label] == str(label) + ]["sub_cluster_labels"].unique() + ) + < 2 ): centroids = [centroidpython(imgcol_new, imgrow_new)] classes = np.array( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() + ]["sub_cluster_labels"].unique() ) else: @@ -839,7 +841,7 @@ def _add_sub_clusters(self): np.column_stack((imgcol_new, imgrow_new)), self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"], + ]["sub_cluster_labels"], ) centroids = clf.centroids_ @@ -847,12 +849,12 @@ def _add_sub_clusters(self): for j, label in enumerate(classes): if ( - len( - self.query_adata.obs[ - self.query_adata.obs["sub_cluster_labels"] == label - ] - ) - > self.threshold_spots + len( + self.query_adata.obs[ + self.query_adata.obs["sub_cluster_labels"] == label + ] + ) + > self.threshold_spots ): if centroids[j][0] < 1500: x = -100 @@ -954,34 +956,34 @@ def _add_trajectories(self): class SubClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "jet", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - fname: str | None = None, - dpi: int | None = 120, - # subcluster plot param - cluster: int | None = 0, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + fname: str | None = None, + dpi: int = 120, + # subcluster plot param + cluster: int = 0, + threshold_spots: int = 5, + text_box_size: float = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + **kwargs, ): super().__init__( adata=adata, @@ -1108,36 +1110,36 @@ def _add_subclusters_label(self, subset): class CciPlot(GenePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # cci_rank param - use_het: str | None = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + use_het: str = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -1177,36 +1179,36 @@ def _get_gene_expression(self): class LrResultPlot(GenePlot): def __init__( - self, - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - zoom_coord: float | None = None, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, - use_raw: bool | None = False, - fname: str | None = None, - dpi: int | None = 120, - # cci_rank param - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): # Making sure cci_rank has been run first # if "lr_summary" not in adata.uns: diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 8b5d6fd0..2aff9167 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -57,7 +57,10 @@ def __init__( adata, ) # Open image, and make sure it's RGB*A* - image = (self.img * 255).astype(np.uint8) + if self.img is None: + raise ValueError("self.img must be a numpy array") + else: + image = (self.img * 255).astype(np.uint8) img_pillow = Image.fromarray(image).convert("RGBA") @@ -328,7 +331,10 @@ def __init__( super().__init__(adata) # Open image, and make sure it's RGB*A* - image = (self.img * 255).astype(np.uint8) + if self.img is None: + raise ValueError("self.img must be a numpy array") + else: + image = (self.img * 255).astype(np.uint8) img_pillow = Image.fromarray(image).convert("RGBA") @@ -771,7 +777,11 @@ def __init__( adata, ) # Open image, and make sure it's RGB*A* - image = (self.img * 255).astype(np.uint8) + if self.img is None: + raise ValueError("self.img must be a numpy array") + else: + image = (self.img * 255).astype(np.uint8) + img_pillow = Image.fromarray(image).convert("RGBA") @@ -949,7 +959,10 @@ def __init__( adata, ) # Open image, and make sure it's RGB*A* - image = (self.img * 255).astype(np.uint8) + if self.img is None: + raise ValueError("self.img must be a numpy array") + else: + image = (self.img * 255).astype(np.uint8) img_pillow = Image.fromarray(image).convert("RGBA") @@ -1229,7 +1242,10 @@ def __init__( ): super().__init__(adata) # Open image, and make sure it's RGB*A* - image = (self.img * 255).astype(np.uint8) + if self.img is None: + raise ValueError("self.img must be a numpy array") + else: + image = (self.img * 255).astype(np.uint8) img_pillow = Image.fromarray(image).convert("RGBA") diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index 5ed28cb8..8c3194e0 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -1,5 +1,5 @@ from typing import ( - Optional, # Special + Optional, Tuple, # Special ) import matplotlib @@ -19,39 +19,39 @@ def cluster_plot( # plotting param title: Optional["str"] = None, figsize: tuple[float, float] | None = None, - cmap: str | None = "default", + cmap: str = "default", use_label: str | None = None, list_clusters: list | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, # cluster plot param - show_subcluster: bool | None = False, - show_cluster_labels: bool | None = False, - show_trajectories: bool | None = False, - reverse: bool | None = False, - show_node: bool | None = False, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, - color_bar_size: float | None = 10, + show_subcluster: bool = False, + show_cluster_labels: bool = False, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = False, + threshold_spots: int = 5, + text_box_size: float = 5, + color_bar_size: float= 10, bbox_to_anchor: tuple[float, float] | None = (1, 1), # trajectory - trajectory_node_size: int | None = 10, - trajectory_alpha: float | None = 1.0, - trajectory_width: float | None = 2.5, - trajectory_edge_color: str | None = "#f4efd3", - trajectory_arrowsize: int | None = 17, + trajectory_node_size: int = 10, + trajectory_alpha: float = 1.0, + trajectory_width: float = 2.5, + trajectory_edge_color: str = "#f4efd3", + trajectory_arrowsize: int = 17, ) -> AnnData | None: """\ Allows the visualization of a cluster results as the discretes values diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index d469ab8c..e4256bc9 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -3,7 +3,7 @@ """ from typing import ( - Optional, # Special + Optional, Tuple, # Special ) import matplotlib @@ -15,31 +15,31 @@ # @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def feat_plot( adata: AnnData, - feature: str = None, + feature: str | None = None, threshold: float | None = None, contour: bool = False, step_size: int | None = None, title: Optional["str"] = None, figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", + cmap: str = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 0.7, + use_raw: bool = False, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, vmin: float | None = None, vmax: float | None = None, ) -> AnnData | None: @@ -90,3 +90,5 @@ def feat_plot( vmin=vmin, vmax=vmax, ) + + return adata diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index 419c4200..7b5c3285 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,5 +1,5 @@ from typing import ( # Special - Optional, # Classes + Optional, Tuple, # Classes ) import matplotlib @@ -21,27 +21,27 @@ def gene_plot( method: str = "CumSum", contour: bool = False, step_size: int | None = None, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str | None = "Spectral_r", + title: str | None = None, + figsize: Tuple[float, float] | None = None, + cmap: str = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - color_bar_label: str | None = "", - zoom_coord: float | None = None, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 7, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 0.7, - use_raw: bool | None = False, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 0.7, + use_raw: bool = False, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, vmin: float | None = None, vmax: float | None = None, ) -> AnnData | None: diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index 4dcef013..c6f9c77c 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -17,25 +17,25 @@ def subcluster_plot( # plotting param title: Optional["str"] = None, figsize: tuple[float, float] | None = None, - cmap: str | None = "jet", + cmap: str = "jet", use_label: str | None = None, list_clusters: list | None = None, ax: _AxesSubplot | None = None, - show_plot: bool | None = True, - show_axis: bool | None = False, - show_image: bool | None = True, - show_color_bar: bool | None = True, - crop: bool | None = True, - margin: float | None = 100, - size: float | None = 5, - image_alpha: float | None = 1.0, - cell_alpha: float | None = 1.0, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, fname: str | None = None, - dpi: int | None = 120, + dpi: int = 120, # subcluster plot param - cluster: int | None = 0, - threshold_spots: int | None = 5, - text_box_size: float | None = 5, + cluster: int = 0, + threshold_spots: int = 5, + text_box_size: float= 5, bbox_to_anchor: tuple[float, float] | None = (1, 1), ) -> AnnData | None: """\ @@ -86,3 +86,5 @@ def subcluster_plot( cluster=cluster, threshold_spots=threshold_spots, ) + + return adata \ No newline at end of file diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index 2214c401..1dec4db9 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -68,12 +68,14 @@ def pseudotimespace_global( n_dims=n_dims, ) + return adata + def pseudotimespace_local( adata: AnnData, use_label: str = "louvain", cluster=None, - w: float = None, + w: float | None = None, ) -> AnnData | None: """\ Perform pseudo-time-space analysis with local level. @@ -99,3 +101,5 @@ def pseudotimespace_local( w = weight_optimizing_local(adata, use_label=use_label, cluster=cluster) local_level(adata, use_label=use_label, cluster=cluster, w=w) + + return adata \ No newline at end of file diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 4e147b07..63b048b8 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -226,7 +226,7 @@ def get_similar_genesFAST( ref_quants: np.array, n_genes: int, candidate_quants: np.ndarray, - candidate_genes: np.array, + candidate_genes: np.ndarray, ): """Fast version of the above with parallelisation.""" diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 267b224c..46491b0b 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -1,6 +1,7 @@ import os import random import sys +from typing import Any import numpy as np import pandas as pd @@ -95,9 +96,9 @@ def perform_spot_testing( bar_format="{l_bar}{bar} [ time left: {remaining} ]", disable=verbose is False, ) as pbar: - - gene_bg_genes = {} # Keep track of genes which can be used to gen. rand-pairs. - spot_lr_indices = [ + # Keep track of genes which can be used to gen. rand-pairs. + gene_bg_genes: dict[str, np.ndarray] = {} + spot_lr_indices: List[List[Any]] = [ [] for i in range(lr_scores.shape[0]) ] # tracks the lrs tested in a given spot for MHT !!!! for lr_j in range(lr_scores.shape[1]): @@ -215,11 +216,11 @@ def perform_perm_testing( adata: AnnData, lr_scores: np.ndarray, n_pairs: int, - lrs: np.array, + lrs: np.ndarray, lr_mid_dist: int, verbose: float, neighbours: List, - het_vals: np.array, + het_vals: np.ndarray, min_expr: float, neg_binom: bool, adj_method: str, @@ -343,14 +344,14 @@ def perform_perm_testing( def permutation( adata: AnnData, n_pairs: int = 200, - distance: int = None, + distance: int = 30, use_lr: str = "cci_lr", - use_het: str = None, + use_het: str | None = None, neg_binom: bool = False, adj_method: str = "fdr", - neighbours: list = None, + neighbours: list | None = None, run_fast: bool = True, - bg_pairs: list = None, + bg_pairs: list | None = None, background: np.array = None, **kwargs, ) -> AnnData: @@ -448,7 +449,7 @@ def permutation( # Negative Binomial fit pvals, pvals_adj, log10_pvals, lr_sign = get_stats( - scores, background, neg_binom, adj_method + scores, background, neg_binom, adj_method=adj_method ) if use_het is not None: @@ -577,8 +578,8 @@ def get_rand_pairs( adata: AnnData, genes: np.array, n_pairs: int, - lrs: list = None, - im: int = None, + lrs: list, + im: int | None = None, ): """Gets equivalent random gene pairs for the inputted lr pair. Parameters diff --git a/stlearn/utils.py b/stlearn/utils.py index 0a76aec7..581aec49 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -96,13 +96,20 @@ def _check_img( def _check_coords( - obsm: Mapping | None, scale_factor: float | None -) -> tuple[np.ndarray | None, np.ndarray | None]: + obsm: Mapping | None, scale_factor: float | None +) -> tuple[np.ndarray, np.ndarray]: + if obsm is None: + raise ValueError("obsm cannot be None") + if scale_factor is None: + raise ValueError("scale_factor cannot be None") + if "spatial" not in obsm: + raise ValueError("'spatial' key not found in obsm") + image_coor = obsm["spatial"] * scale_factor imagecol = image_coor[:, 0] imagerow = image_coor[:, 1] - return [imagecol, imagerow] + return (imagecol, imagerow) def _read_graph(adata: AnnData, graph_type: str | None): From 5b835ded6a27ebf6032a2d37631e07ec35e8a0c9 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 15:56:21 +1000 Subject: [PATCH 021/123] Fixing types. --- stlearn/adds/add_mask.py | 16 +++++------ stlearn/adds/add_positions.py | 4 +-- stlearn/classes.py | 10 +++---- stlearn/embedding/umap.py | 2 ++ stlearn/plotting/cci_plot_helpers.py | 27 ++++++++++--------- stlearn/plotting/mask_plot.py | 20 +++++++------- .../plotting/trajectory/DE_transition_plot.py | 6 +++-- stlearn/plotting/trajectory/local_plot.py | 8 +++--- .../plotting/trajectory/pseudotime_plot.py | 16 ++++++----- .../trajectory/transition_markers_plot.py | 18 ++++++------- stlearn/preprocessing/graph.py | 2 ++ stlearn/preprocessing/normalize.py | 6 +++-- stlearn/spatials/SME/_weighting_matrix.py | 4 ++- stlearn/spatials/clustering/localization.py | 2 +- stlearn/spatials/trajectory/global_level.py | 15 ++++++----- stlearn/spatials/trajectory/pseudotime.py | 2 +- stlearn/tools/clustering/louvain.py | 16 +++-------- stlearn/tools/microenv/cci/analysis.py | 2 +- stlearn/tools/microenv/cci/base.py | 10 +++---- 19 files changed, 101 insertions(+), 85 deletions(-) diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index ffc58f51..dd086217 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -77,9 +77,9 @@ def add_mask( def apply_mask( adata: AnnData, - masks: list | None = "all", + masks: list | str = "all", select: str = "black", - cmap: str = "default", + cmap_name: str = "default", copy: bool = False, ) -> AnnData | None: """\ @@ -108,17 +108,17 @@ def apply_mask( from stlearn.plotting import palettes_st - if cmap == "vega_10_scanpy": + if cmap_name == "vega_10_scanpy": cmap = palettes.vega_10_scanpy - elif cmap == "vega_20_scanpy": + elif cmap_name == "vega_20_scanpy": cmap = palettes.vega_20_scanpy - elif cmap == "default_102": + elif cmap_name == "default_102": cmap = palettes.default_102 - elif cmap == "default_28": + elif cmap_name == "default_28": cmap = palettes.default_28 - elif cmap == "jana_40": + elif cmap_name == "jana_40": cmap = palettes_st.jana_40 - elif cmap == "default": + elif cmap_name == "default": cmap = palettes_st.default else: raise ValueError( diff --git a/stlearn/adds/add_positions.py b/stlearn/adds/add_positions.py index b993a9d3..1ae3f9d6 100644 --- a/stlearn/adds/add_positions.py +++ b/stlearn/adds/add_positions.py @@ -6,8 +6,8 @@ def positions( adata: AnnData, - position_filepath: Path | str = None, - scale_filepath: Path | str = None, + position_filepath: Path | str, + scale_filepath: Path | str, quality: str = "low", copy: bool = False, ) -> AnnData | None: diff --git a/stlearn/classes.py b/stlearn/classes.py index 2c9026e9..3068a55b 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -26,13 +26,13 @@ def __init__( adata: AnnData, basis: str = "spatial", img: np.ndarray | None = None, - img_key: str | None | Empty = _empty, - library_id: str | None = _empty, - crop_coord: bool | None = True, - bw: bool | None = False, + img_key: str | Empty = _empty, + library_id: str | None = None, + crop_coord: bool = True, + bw: bool = False, scale_factor: float | None = None, spot_size: float | None = None, - use_raw: bool | None = False, + use_raw: bool = False, **kwargs, ): diff --git a/stlearn/embedding/umap.py b/stlearn/embedding/umap.py index 9b375a80..db59f5ad 100644 --- a/stlearn/embedding/umap.py +++ b/stlearn/embedding/umap.py @@ -74,3 +74,5 @@ def run_umap( ) print("UMAP is done! Generated in adata.obsm['X_umap'] nad adata.uns['umap']") + + return adata diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index f046e973..9007e83f 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,4 +1,5 @@ """Helper functions for cci_plot.py.""" +from typing import List, Tuple, Optional import matplotlib import matplotlib.cm as cm @@ -221,18 +222,18 @@ def rank_scatter( def add_arrows( adata: AnnData, - l_expr: np.array, - r_expr: np.array, + l_expr: np.ndarray, + r_expr: np.ndarray, min_expr: float, - sig_bool: np.array, + sig_bool: np.ndarray, fig, ax: Axes, use_label: str | None, int_df: pd.DataFrame | None, - head_width=4, - width=0.001, - arrow_cmap=None, - arrow_vmax=None, + head_width: float = 4, + width: float = 0.001, + arrow_cmap: str | None =None, + arrow_vmax: float | None =None, ): """ Adds arrows to the current plot for significant spots to neighbours \ which is interacting with. @@ -273,7 +274,7 @@ def add_arrows( interact_bool = int_df.values > 0 # Subsetting to only significant CCI # - edges_sub = [[], []] # forward, reverse + edges_sub: List[List[Tuple[str, str]]] = [[], []] # forward, reverse # ints_2 = np.zeros(int_df.shape) # Just for debugging make sure edge # list re-capitulates edge-counts. for i, edges in enumerate([forward_edges, reverse_edges]): @@ -299,7 +300,7 @@ def add_arrows( # If cmap specified, colour arrows by average LR expression on edge # if arrow_cmap is not None: - edges_means = [[], []] + edges_means: List[List[float]] = [[], []] all_means = [] for i, edges in enumerate([forward_edges, reverse_edges]): for j, edge in enumerate(edges): @@ -320,7 +321,7 @@ def add_arrows( scalar_map = cm.ScalarMappable(norm=c_norm, cmap=cmap) # Determining the edge colors # - edges_colors = [[], []] + edges_colors: List[List[Tuple[float, float, float, float]]] = [[], []] for i, edges in enumerate([forward_edges, reverse_edges]): for j, edge in enumerate(edges): color_val = scalar_map.to_rgba(edges_means[i][j]) @@ -336,7 +337,7 @@ def add_arrows( axc = fig.add_axes(cax) else: - edges_colors = [None, None] + edges_colors = [[], []] # Now performing the plotting # # The arrows # @@ -378,6 +379,8 @@ def add_arrows_by_edges( edge_colors=None, axc=None, ): + if edge_colors is None: + edge_colors = [] """Adds the arrows using an edge list.""" for i, edge in enumerate(edges): # cols = ["imagecol", "imagerow"] @@ -394,7 +397,7 @@ def add_arrows_by_edges( x1, y1 = adata.obsm["spatial"][edge0_index, :] * scale_factor x2, y2 = adata.obsm["spatial"][edge1_index, :] * scale_factor dx, dy = (x2 - x1) * 0.75, (y2 - y1) * 0.75 - arrow_color = "k" if edge_colors is None else edge_colors[i] + arrow_color = "k" if len(edge_colors) == 0 else edge_colors[i] ax.arrow( x1, diff --git a/stlearn/plotting/mask_plot.py b/stlearn/plotting/mask_plot.py index e3e13ed4..05ee3f9f 100644 --- a/stlearn/plotting/mask_plot.py +++ b/stlearn/plotting/mask_plot.py @@ -5,17 +5,17 @@ def plot_mask( adata: AnnData, - library_id: str = None, + library_id: str | None = None, show_spot: bool = True, spot_alpha: float = 1.0, - cmap: str = "vega_20_scanpy", + cmap_name: str = "vega_20_scanpy", tissue_alpha: float = 1.0, mask_alpha: float = 0.5, spot_size: float | int = 6.5, show_legend: bool = True, name: str = "mask_plot", dpi: int = 150, - output: str = None, + output: str | None = None, show_axis: bool = False, show_plot: bool = True, ) -> AnnData | None: @@ -60,17 +60,17 @@ def plot_mask( from stlearn.plotting import palettes_st - if cmap == "vega_10_scanpy": + if cmap_name == "vega_10_scanpy": cmap = palettes.vega_10_scanpy - elif cmap == "vega_20_scanpy": + elif cmap_name == "vega_20_scanpy": cmap = palettes.vega_20_scanpy - elif cmap == "default_102": + elif cmap_name == "default_102": cmap = palettes.default_102 - elif cmap == "default_28": + elif cmap_name == "default_28": cmap = palettes.default_28 - elif cmap == "jana_40": + elif cmap_name == "jana_40": cmap = palettes_st.jana_40 - elif cmap == "default": + elif cmap_name == "default": cmap = palettes_st.default else: raise ValueError( @@ -172,3 +172,5 @@ def plot_mask( if show_plot: plt.show() + + return adata diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/plotting/trajectory/DE_transition_plot.py index 55275f6b..5f7b5147 100644 --- a/stlearn/plotting/trajectory/DE_transition_plot.py +++ b/stlearn/plotting/trajectory/DE_transition_plot.py @@ -8,9 +8,9 @@ def DE_transition_plot( adata: AnnData, top_genes: int = 10, font_size: int = 6, - name: str = None, + name: str | None = None, dpi: int = 150, - output: str = None, + output: str | None = None, ) -> AnnData | None: """\ Differential expression between transition markers. @@ -239,3 +239,5 @@ def DE_transition_plot( if output is not None: if name is not None: plt.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) + + return adata \ No newline at end of file diff --git a/stlearn/plotting/trajectory/local_plot.py b/stlearn/plotting/trajectory/local_plot.py index de9a9bca..4fa1644b 100644 --- a/stlearn/plotting/trajectory/local_plot.py +++ b/stlearn/plotting/trajectory/local_plot.py @@ -7,8 +7,8 @@ def local_plot( adata: AnnData, + use_cluster: int, use_label: str = "louvain", - use_cluster: int = None, reverse: bool = False, cluster: int = 0, data_alpha: float = 1.0, @@ -18,9 +18,9 @@ def local_plot( show_color_bar: bool = True, show_axis: bool = False, show_plot: bool = True, - name: str = None, + name: str | None = None, dpi: int = 150, - output: str = None, + output: str | None = None, copy: bool = False, ) -> AnnData | None: """\ @@ -185,6 +185,8 @@ def local_plot( name = use_label fig.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) + return adata + def calculate_y(m): import math diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index bbe1b098..b6d3a167 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -9,10 +9,10 @@ def pseudotime_plot( adata: AnnData, - library_id: str = None, + library_id: str | None = None, use_label: str = "louvain", pseudotime_key: str = "pseudotime_key", - list_clusters: str | list = None, + list_clusters: str | list | None = None, cell_alpha: float = 1.0, image_alpha: float = 1.0, edge_alpha: float = 0.8, @@ -29,8 +29,8 @@ def pseudotime_plot( cropped: bool = True, margin: int = 100, dpi: int = 150, - output: str = None, - name: str = None, + output: str | None = None, + name: str | None = None, copy: bool = False, ax=None, ) -> AnnData | None: @@ -74,7 +74,9 @@ def pseudotime_plot( dpi DPI of the output figure. output - Save the figure as file or not. + The output folder of the plot. + name + The filename of the plot. copy Return a copy instead of writing to adata. Returns @@ -263,11 +265,13 @@ def pseudotime_plot( a.set_ylim(a.get_ylim()[::-1]) # plt.gca().invert_yaxis() - if output is not None: + if output is not None and name is not None: fig.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) if show_plot: plt.show() + return adata + # get name of cluster by subcluster def get_cluster(search, dictionary): diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/plotting/trajectory/transition_markers_plot.py index 6d2bf260..cf93e93d 100644 --- a/stlearn/plotting/trajectory/transition_markers_plot.py +++ b/stlearn/plotting/trajectory/transition_markers_plot.py @@ -6,11 +6,11 @@ def transition_markers_plot( adata: AnnData, + trajectory: str, top_genes: int = 10, - trajectory: str = None, dpi: int = 150, - output: str = None, - name: str = None, + output: str | None = None, + name: str | None = None, ) -> AnnData | None: """\ Plot transition marker. @@ -19,10 +19,10 @@ def transition_markers_plot( ---------- adata Annotated data matrix. - top_genes - Top genes users want to display in the plot. trajectory Name of a clade/branch user wants to plot transition markers. + top_genes + Top genes users want to display in the plot. dpi The resolution of the plot. output @@ -34,10 +34,8 @@ def transition_markers_plot( Anndata """ - if trajectory is None: - raise ValueError("Please input the trajectory name!") if trajectory not in adata.uns: - raise ValueError("Please input the right trajectory name!") + raise ValueError("Please input the right trajectory name - not found in adata.uns!") pos = ( adata.uns[trajectory][adata.uns[trajectory]["score"] >= 0] @@ -146,7 +144,9 @@ def transition_markers_plot( if name is None: name = trajectory - if output is not None: + if output is not None and name is not None: fig.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) plt.show() + + return adata diff --git a/stlearn/preprocessing/graph.py b/stlearn/preprocessing/graph.py index 4330abbd..1cfb2d05 100644 --- a/stlearn/preprocessing/graph.py +++ b/stlearn/preprocessing/graph.py @@ -113,3 +113,5 @@ def neighbors( ) print("Created k-Nearest-Neighbor graph in adata.uns['neighbors'] ") + + return adata diff --git a/stlearn/preprocessing/normalize.py b/stlearn/preprocessing/normalize.py index f604e4fe..35638614 100644 --- a/stlearn/preprocessing/normalize.py +++ b/stlearn/preprocessing/normalize.py @@ -13,7 +13,7 @@ def normalize_total( exclude_highly_expressed: bool = False, max_fraction: float = 0.05, key_added: str | None = None, - layers: Literal["all"] | Iterable[str] = None, + layers: Literal["all"] | Iterable[str] | None = None, layer_norm: str | None = None, inplace: bool = True, ) -> dict[str, np.ndarray] | None: @@ -71,7 +71,7 @@ def normalize_total( `adata.X` and `adata.layers`, depending on `inplace`. """ - scanpy.pp.normalize_total( + t = scanpy.pp.normalize_total( adata, target_sum=target_sum, exclude_highly_expressed=exclude_highly_expressed, @@ -83,3 +83,5 @@ def normalize_total( ) print("Normalization step is finished in adata.X") + + return t diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatials/SME/_weighting_matrix.py index fcaa2f78..dfc10727 100644 --- a/stlearn/spatials/SME/_weighting_matrix.py +++ b/stlearn/spatials/SME/_weighting_matrix.py @@ -27,6 +27,7 @@ def calculate_weight_matrix( from sklearn.linear_model import LinearRegression + rate: float if platform == "Visium": img_row = adata.obs["imagerow"] img_col = adata.obs["imagecol"] @@ -102,11 +103,12 @@ def calculate_weight_matrix( adata.uns["gene_expression_correlation"] * adata.uns["morphological_distance"] ) + return adata def impute_neighbour( adata: AnnData, - count_embed: np.ndarray | None = None, + count_embed: np.ndarray, weights: _WEIGHTING_MATRIX = "weights_matrix_all", copy: bool = False, ) -> AnnData | None: diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index 1a9c2e3e..ce15c836 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -8,7 +8,7 @@ def localization( adata: AnnData, use_label: str = "louvain", - eps: int = 20, + eps: float = 20, min_samples: int = 0, copy: bool = False, ) -> AnnData | None: diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index 0cf96464..faf91c92 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -1,3 +1,4 @@ +import networkx import networkx as nx import numpy as np from anndata import AnnData @@ -8,15 +9,15 @@ def global_level( adata: AnnData, + list_clusters: list[str], + w: float, use_label: str = "louvain", use_rep: str = "X_pca", n_dims: int = 40, - list_clusters: list = [], return_graph: bool = False, - w: float = None, verbose: bool = True, copy: bool = False, -) -> AnnData | None: +) -> networkx.Graph | None: """\ Perform global sptial trajectory inference. @@ -26,12 +27,12 @@ def global_level( Annotated data matrix. list_clusters Setup a list of cluster to perform pseudo-space-time + w + Pseudo-spatio-temporal distance weight (balance between spatial effect and DPT) use_label Use label result of cluster method. return_graph Return PTS graph - w - Pseudo-spatio-temporal distance weight (balance between spatial effect and DPT) copy Return a copy instead of writing to adata. Returns @@ -110,7 +111,7 @@ def global_level( centroid_dict = adata.uns["centroid_dict"] centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} - H_sub = H.edge_subgraph(edge_list) + H_sub: networkx.Graph = H.edge_subgraph(edge_list) if not nx.is_connected(H_sub.to_undirected()): raise ValueError( "The chosen clusters are not available to construct the spatial " @@ -176,6 +177,8 @@ def global_level( if return_graph: return H_sub + else: + return None # Global level PTS diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 035556b4..6eb3f3c2 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -7,7 +7,7 @@ def pseudotime( adata: AnnData, - use_label: str = None, + use_label: str | None = None, eps: float = 20, n_neighbors: int = 25, use_rep: str = "X_pca", diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index ba52ae47..89a74a3c 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -5,19 +5,9 @@ from anndata import AnnData from numpy.random.mtrand import RandomState from scipy.sparse import spmatrix - -from stlearn._compat import Literal - -try: - from louvain.VertexPartition import MutableVertexPartition -except ImportError: - - class MutableVertexPartition: - pass - - MutableVertexPartition.__module__ = "louvain.VertexPartition" import scanpy - +from stlearn._compat import Literal +from louvain.VertexPartition import MutableVertexPartition def louvain( adata: AnnData, @@ -106,3 +96,5 @@ def louvain( print( "Louvain cluster is done! The labels are stored in adata.obs['%s']" % key_added ) + + return adata diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 6a22672d..1758f154 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -194,7 +194,7 @@ def run( adata: AnnData, lrs: np.ndarray, min_spots: int = 10, - distance: int | None = None, + distance: float | None = None, n_pairs: int = 1000, n_cpus: int | None = None, use_label: str | None = None, diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index 6a5b5259..c72b2db3 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -12,9 +12,9 @@ def lr( adata: AnnData, use_lr: str = "cci_lr", - distance: float = None, + distance: float | None = None, verbose: bool = True, - neighbours: list = None, + neighbours: list | None = None, fast: bool = True, ) -> AnnData: """Calculate the proportion of known ligand-receptor co-expression among the @@ -27,7 +27,7 @@ def lr( object to keep the result (default: adata.uns['cci_lr']) distance: float Distance to determine the neighbours (default: closest), distance=0 means - within spot + within spot. If distance is None gets it from adata.uns["spatial"] neighbours: list List of the neighbours for each spot, if None then computed. Useful for speeding up function. @@ -71,7 +71,7 @@ def lr( # return adata -def calc_distance(adata: AnnData, distance: float | None): +def calc_distance(adata: AnnData, distance: float | None) -> float: """Automatically calculate distance if not given, won't overwrite \ distance=0 which is within-spot. Parameters @@ -85,7 +85,7 @@ def calc_distance(adata: AnnData, distance: float | None): Returns ------- distance: float - The automatically calcualted distance (or inputted distance) + The automatically calculate distance (or inputted distance) """ if not distance and distance != 0: # for arranged-spots From 7e1407a8fa6b6ac84ed165ea96743b00e515b77e Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 16:06:10 +1000 Subject: [PATCH 022/123] Fix up missing exports. --- stlearn/datasets.py | 5 ++ stlearn/em.py | 15 ++++++ stlearn/pl.py | 55 +++++++++++++++++++++ stlearn/tools/microenv/cci/base_grouping.py | 2 - stlearn/tools/microenv/cci/het.py | 2 +- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/stlearn/datasets.py b/stlearn/datasets.py index e69de29b..a8c0721e 100644 --- a/stlearn/datasets.py +++ b/stlearn/datasets.py @@ -0,0 +1,5 @@ +from ._datasets._datasets import example_bcba + +__all__ = [ + "example_bcba", +] diff --git a/stlearn/em.py b/stlearn/em.py index d16c7bec..bfac0db9 100644 --- a/stlearn/em.py +++ b/stlearn/em.py @@ -1 +1,16 @@ # from .embedding.scvi import run_ldvae +from .embedding.pca import run_pca +from .embedding.umap import run_umap +from .embedding.ica import run_ica + +# from .embedding.scvi import run_ldvae +from .embedding.fa import run_fa +from .embedding.diffmap import run_diffmap + +__all__ = [ + "run_pca", + "run_umap", + "run_ica", + "run_fa", + "run_diffmap", +] \ No newline at end of file diff --git a/stlearn/pl.py b/stlearn/pl.py index a41cb8d6..56baff5c 100644 --- a/stlearn/pl.py +++ b/stlearn/pl.py @@ -1 +1,56 @@ # from .plotting.cci_plot import het_plot_interactive +from .plotting import trajectory +from .plotting.QC_plot import QC_plot +from .plotting.cci_plot import ( + ccinet_plot, + cci_map, + lr_cci_map, + lr_chord_plot, + cci_check, +) +from .plotting.cci_plot import grid_plot +from .plotting.cci_plot import het_plot +from .plotting.cci_plot import lr_diagnostics, lr_n_spots, lr_summary, lr_go +from .plotting.cci_plot import lr_plot, lr_result_plot +# from .plotting.cci_plot import het_plot_interactive +from .plotting.cci_plot import lr_plot_interactive, spatialcci_plot_interactive +from .plotting.cluster_plot import cluster_plot +from .plotting.cluster_plot import cluster_plot_interactive +from .plotting.deconvolution_plot import deconvolution_plot +from .plotting.feat_plot import feat_plot +from .plotting.gene_plot import gene_plot +from .plotting.gene_plot import gene_plot_interactive +from .plotting.mask_plot import plot_mask +from .plotting.non_spatial_plot import non_spatial_plot +from .plotting.stack_3d_plot import stack_3d_plot +from .plotting.subcluster_plot import subcluster_plot + +__all__ = [ + "gene_plot", + "gene_plot_interactive", + "feat_plot", + "cluster_plot", + "cluster_plot_interactive", + "subcluster_plot", + "non_spatial_plot", + "deconvolution_plot", + "stack_3d_plot", + "trajectory", + "QC_plot", + "het_plot", + "lr_plot_interactive", + "spatialcci_plot_interactive", + "grid_plot", + "lr_diagnostics", + "lr_n_spots", + "lr_summary", + "lr_go", + "lr_plot", + "lr_result_plot", + "ccinet_plot", + "cci_map", + "lr_cci_map", + "lr_chord_plot", + "cci_check", + "plot_mask", +] diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index 24201a71..a980ae9d 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -9,10 +9,8 @@ from anndata import AnnData from sklearn.cluster import DBSCAN, AgglomerativeClustering from tqdm import tqdm - from stlearn.pl import het_plot - def get_hotspots( adata: AnnData, lr_scores: np.ndarray, diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index 54232564..1d70c4e0 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -448,7 +448,7 @@ def count_grid( adata: AnnData, num_row: int = 30, num_col: int = 30, - use_label: str = None, + use_label: str | None = None, use_het: str = "cci_het_grid", radius: int = 1, verbose: bool = True, From 127d022b4aa7abdbc39dceb3ddf46a00fbe60cec Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 5 Jun 2025 16:16:04 +1000 Subject: [PATCH 023/123] Fix up types. --- stlearn/__main__.py | 4 ++-- stlearn/adds/add_labels.py | 2 +- stlearn/tools/microenv/cci/het.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/stlearn/__main__.py b/stlearn/__main__.py index 4687bf58..3802ae27 100644 --- a/stlearn/__main__.py +++ b/stlearn/__main__.py @@ -3,7 +3,7 @@ """Package entry point.""" -from stlearn.app import main +from stlearn.app import cli if __name__ == "__main__": # pragma: no cover - main() + cli.main() diff --git a/stlearn/adds/add_labels.py b/stlearn/adds/add_labels.py index 5b0875f7..76d69c7c 100644 --- a/stlearn/adds/add_labels.py +++ b/stlearn/adds/add_labels.py @@ -6,7 +6,7 @@ def labels( adata: AnnData, - label_filepath: str = None, + label_filepath: str, index_col: int = 0, use_label: str = None, sep: str = "\t", diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index 1d70c4e0..60e6e1bb 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -1,3 +1,5 @@ +from typing import Iterable + import numpy as np import pandas as pd import scipy.spatial as spatial @@ -414,7 +416,7 @@ def create_grids(adata: AnnData, num_row: int, num_col: int, radius: int = 1): grids, neighbours = [], [] # generate grids from top to bottom and left to right for n in range(num_row * num_col): - neighbour = [] + neighbour: Iterable[float] x = min_x + n // num_row * width # left side y = min_y + n % num_row * height # upper side grids.append([x, y]) From f3d70d6365f527350a244f0a097d2e98b79ac198 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 13:48:18 +1000 Subject: [PATCH 024/123] Fixing types. --- stlearn/adds/add_deconvolution.py | 4 ++ stlearn/adds/add_image.py | 3 +- stlearn/adds/add_labels.py | 4 +- stlearn/adds/add_loupe_clusters.py | 4 ++ stlearn/adds/add_lr.py | 1 + stlearn/adds/add_mask.py | 8 +-- stlearn/adds/add_positions.py | 2 + stlearn/adds/annotation.py | 3 +- stlearn/adds/parsing.py | 6 +- stlearn/classes.py | 2 +- stlearn/embedding/diffmap.py | 4 +- stlearn/embedding/fa.py | 4 +- stlearn/embedding/ica.py | 8 +-- stlearn/embedding/pca.py | 6 +- stlearn/embedding/umap.py | 2 +- .../image_preprocessing/feature_extractor.py | 3 + stlearn/image_preprocessing/image_tiling.py | 2 + stlearn/plotting/QC_plot.py | 8 +-- stlearn/plotting/deconvolution_plot.py | 42 +++++------- stlearn/plotting/non_spatial_plot.py | 2 +- stlearn/plotting/stack_3d_plot.py | 2 +- .../plotting/trajectory/check_trajectory.py | 14 ++-- stlearn/preprocessing/filter_genes.py | 4 +- stlearn/preprocessing/log_scale.py | 8 +-- stlearn/spatials/SME/impute.py | 4 ++ stlearn/spatials/SME/normalize.py | 2 + stlearn/spatials/clustering/localization.py | 2 + stlearn/spatials/morphology/adjust.py | 2 + stlearn/spatials/smooth/disk.py | 3 + stlearn/spatials/trajectory/global_level.py | 8 +-- stlearn/spatials/trajectory/local_level.py | 17 +++-- stlearn/spatials/trajectory/utils.py | 13 ---- stlearn/tools/clustering/kmeans.py | 2 +- stlearn/utils.py | 67 ++++++++++++++----- stlearn/wrapper/read.py | 6 +- 35 files changed, 165 insertions(+), 107 deletions(-) diff --git a/stlearn/adds/add_deconvolution.py b/stlearn/adds/add_deconvolution.py index d169b8ed..5d892dda 100644 --- a/stlearn/adds/add_deconvolution.py +++ b/stlearn/adds/add_deconvolution.py @@ -27,7 +27,11 @@ def add_deconvolution( The annotation of cluster results. """ + adata = adata.copy() if copy else adata + label = pd.read_csv(annotation_path, index_col=0) label = label[adata.obs_names] adata.obsm["deconvolution"] = label[adata.obs.index].T + + return adata diff --git a/stlearn/adds/add_image.py b/stlearn/adds/add_image.py index 0d07b3d3..20376ece 100644 --- a/stlearn/adds/add_image.py +++ b/stlearn/adds/add_image.py @@ -45,6 +45,7 @@ def image( **tissue_img** : `adata.uns` field Array format of image, saving by Pillow package. """ + adata = adata.copy() if copy else adata if imgpath is not None and os.path.isfile(imgpath): try: @@ -69,8 +70,6 @@ def image( adata.obs[["imagecol", "imagerow"]] = adata.obsm["spatial"] * scale print("Added tissue image to the object!") - - return adata if copy else None except: raise ValueError( f"""\ diff --git a/stlearn/adds/add_labels.py b/stlearn/adds/add_labels.py index 76d69c7c..d11cad49 100644 --- a/stlearn/adds/add_labels.py +++ b/stlearn/adds/add_labels.py @@ -8,7 +8,7 @@ def labels( adata: AnnData, label_filepath: str, index_col: int = 0, - use_label: str = None, + use_label: str | None = None, sep: str = "\t", copy: bool = False, ) -> AnnData | None: @@ -35,6 +35,8 @@ def labels( The data object that L-R added into """ + adata = adata.copy() if copy else adata + labels = pd.read_csv(label_filepath, index_col=index_col, sep=sep) uns_key = "label_transfer" if use_label is None else use_label adata.uns[uns_key] = labels.drop(["predicted.id", "prediction.score.max"], axis=1) diff --git a/stlearn/adds/add_loupe_clusters.py b/stlearn/adds/add_loupe_clusters.py index 4d1baef2..a85b15cf 100644 --- a/stlearn/adds/add_loupe_clusters.py +++ b/stlearn/adds/add_loupe_clusters.py @@ -34,9 +34,13 @@ def add_loupe_clusters( The annotation of cluster results. """ + adata = adata.copy() if copy else adata + label = pd.read_csv(loupe_path) adata.obs[key_add] = pd.Categorical( values=np.array(label[key_add]).astype("U"), categories=natsorted(label[key_add].unique().astype("U")), ) + + return adata if copy else None \ No newline at end of file diff --git a/stlearn/adds/add_lr.py b/stlearn/adds/add_lr.py index 78df7424..d40d11a8 100644 --- a/stlearn/adds/add_lr.py +++ b/stlearn/adds/add_lr.py @@ -29,6 +29,7 @@ def lr( adata: AnnData The data object that L-R added into """ + adata = adata.copy() if copy else adata if source == "cellphonedb": cpdb = pd.read_csv(db_filepath, sep=sep) diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index dd086217..680885f8 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -32,6 +32,8 @@ def add_mask( **mask_image** : `adata.uns` field Array format of image, saving by Pillow package. """ + adata = adata.copy() if copy else adata + try: library_id = list(adata.uns["spatial"].keys())[0] quality = adata.uns["spatial"][library_id]["use_quality"] @@ -58,8 +60,6 @@ def add_mask( adata.uns["mask_image"][library_id][key][quality] = img print("Added tissue mask to the object!") - - return adata if copy else None except: raise ValueError( f"""\ @@ -105,9 +105,10 @@ def apply_mask( Array format of image, saving by Pillow package. """ from scanpy.plotting import palettes - from stlearn.plotting import palettes_st + adata = adata.copy() if copy else adata + if cmap_name == "vega_10_scanpy": cmap = palettes.vega_10_scanpy elif cmap_name == "vega_20_scanpy": @@ -126,7 +127,6 @@ def apply_mask( ) cmaps = matplotlib.colors.LinearSegmentedColormap.from_list("", cmap) - cmap_ = plt.cm.get_cmap(cmaps) try: diff --git a/stlearn/adds/add_positions.py b/stlearn/adds/add_positions.py index 1ae3f9d6..7b4c3cb7 100644 --- a/stlearn/adds/add_positions.py +++ b/stlearn/adds/add_positions.py @@ -33,6 +33,8 @@ def positions( Spatial information of the tissue image. """ + adata = adata.copy() if copy else adata + tissue_positions = pd.read_csv(position_filepath, header=None) tissue_positions.columns = [ "barcode", diff --git a/stlearn/adds/annotation.py b/stlearn/adds/annotation.py index 809c0cea..8f5df9db 100644 --- a/stlearn/adds/annotation.py +++ b/stlearn/adds/annotation.py @@ -26,10 +26,11 @@ def annotation( **[cluster method name]_anno** : `adata.obs` field The annotation of cluster results. """ - if label_list is None: raise ValueError("Please give the label list!") + adata = adata.copy() if copy else adata + if len(label_list) != len(adata.obs[use_label].unique()): raise ValueError("Please give the correct number of label list!") diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index 282ed38d..4b824c59 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -1,3 +1,4 @@ +from os import PathLike from pathlib import Path import numpy as np @@ -6,7 +7,7 @@ def parsing( adata: AnnData, - coordinates_file: Path | str | None, + coordinates_file: int | str | bytes | PathLike[str] | PathLike[bytes], copy: bool = True, ) -> AnnData | None: """\ @@ -48,6 +49,8 @@ def parsing( "the coordinates file only contains 4 columns\n" ) + adata = adata.copy() if copy else adata + counts_table = adata.to_df() new_index_values = list() @@ -76,7 +79,6 @@ def parsing( adata.obs["imagecol"] = imgcol adata.obs["imagerow"] = imgrow - adata.obsm["spatial"] = np.c_[[imgcol, imgrow]].reshape(-1, 2) return adata if copy else None diff --git a/stlearn/classes.py b/stlearn/classes.py index 3068a55b..f441661b 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -26,7 +26,7 @@ def __init__( adata: AnnData, basis: str = "spatial", img: np.ndarray | None = None, - img_key: str | Empty = _empty, + img_key: str | None | Empty = _empty, library_id: str | None = None, crop_coord: bool = True, bw: bool = False, diff --git a/stlearn/embedding/diffmap.py b/stlearn/embedding/diffmap.py index 93338007..fb309d9e 100644 --- a/stlearn/embedding/diffmap.py +++ b/stlearn/embedding/diffmap.py @@ -34,11 +34,11 @@ def run_diffmap(adata: AnnData, n_comps: int = 15, copy: bool = False): Eigenvalues of transition matrix. """ - scanpy.tl.diffmap(adata, n_comps=n_comps, copy=copy) + adata = scanpy.tl.diffmap(adata, n_comps=n_comps, copy=copy) print( "Diffusion Map is done! Generated in adata.obsm['X_diffmap'] and " + "adata.uns['diffmap_evals']" ) - return adata if copy else None + return adata diff --git a/stlearn/embedding/fa.py b/stlearn/embedding/fa.py index b982c3d8..953ff96a 100644 --- a/stlearn/embedding/fa.py +++ b/stlearn/embedding/fa.py @@ -11,7 +11,7 @@ def run_fa( svd_method: str = "randomized", iterated_power: int = 3, random_state: int = 2108, - use_data: str = None, + use_data: str | None = None, copy: bool = False, ) -> AnnData | None: """\ @@ -69,6 +69,8 @@ def run_fa( Factor analysis representation of data. """ + adata = adata.copy() if copy else adata + if use_data is None: if issparse(adata.X): matrix = adata.X.toarray() diff --git a/stlearn/embedding/ica.py b/stlearn/embedding/ica.py index 5b990788..fde64c40 100644 --- a/stlearn/embedding/ica.py +++ b/stlearn/embedding/ica.py @@ -8,7 +8,7 @@ def run_ica( n_factors: int = 20, fun: str = "logcosh", tol: float = 0.0001, - use_data: str = None, + use_data: str | None = None, copy: bool = False, ) -> AnnData | None: """\ @@ -43,21 +43,19 @@ def my_g(x): Independent Component Analysis representation of data. """ + adata = adata.copy() if copy else adata + if use_data is None: if issparse(adata.X): matrix = adata.X.toarray() else: matrix = adata.X - else: matrix = adata.obsm[use_data].values ica = FastICA(n_components=n_factors, fun=fun, tol=tol) - latent = ica.fit_transform(matrix) - adata.obsm["X_ica"] = latent - adata.uns["ica"] = {"params": {"n_factors": n_factors, "fun": fun, "tol": tol}} print( diff --git a/stlearn/embedding/pca.py b/stlearn/embedding/pca.py index 22ae94fb..8870994e 100644 --- a/stlearn/embedding/pca.py +++ b/stlearn/embedding/pca.py @@ -17,7 +17,7 @@ def run_pca( copy: bool = False, chunked: bool = False, chunk_size: int | None = None, -) -> AnnData | np.ndarray | spmatrix: +) -> AnnData | None: """\ Wrap function scanpy.pp.pca Principal component analysis [Pedregosa11]_. @@ -83,7 +83,7 @@ def run_pca( covariance matrix. """ - scanpy.pp.pca( + adata = scanpy.pp.pca( data, n_comps=n_comps, zero_center=zero_center, @@ -101,3 +101,5 @@ def run_pca( "PCA is done! Generated in adata.obsm['X_pca'], adata.uns['pca'] and " + "adata.varm['PCs']" ) + + return adata diff --git a/stlearn/embedding/umap.py b/stlearn/embedding/umap.py index db59f5ad..aa509979 100644 --- a/stlearn/embedding/umap.py +++ b/stlearn/embedding/umap.py @@ -56,7 +56,7 @@ def run_umap( """ - scanpy.tl.umap( + adata = scanpy.tl.umap( adata, min_dist=min_dist, spread=spread, diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 75e3a2a2..dc07b343 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -45,6 +45,9 @@ def extract_feature( **X_morphology** : `adata.obsm` field Dimension reduced latent morphological features. """ + + adata = adata.copy() if copy else adata + feature_dfs = [] model = Model(cnn_base) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index ee338816..098cc58a 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -45,6 +45,8 @@ def tiling( Saved path for each spot image tiles """ + adata = adata.copy() if copy else adata + if library_id is None: library_id = list(adata.uns["spatial"].keys())[0] diff --git a/stlearn/plotting/QC_plot.py b/stlearn/plotting/QC_plot.py index ebe0faa6..a2224857 100644 --- a/stlearn/plotting/QC_plot.py +++ b/stlearn/plotting/QC_plot.py @@ -5,8 +5,8 @@ def QC_plot( adata: AnnData, - library_id: str = None, - name: str = None, + name: str, + library_id: str | None = None, data_alpha: float = 0.8, tissue_alpha: float = 1.0, cmap: str = "Spectral_r", @@ -17,8 +17,8 @@ def QC_plot( cropped: bool = True, margin: int = 100, dpi: int = 150, - output: str = None, -) -> AnnData | None: + output: str | None = None, +) -> None: """\ QC plot for sptial transcriptomics data. diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/plotting/deconvolution_plot.py index f41a9f8d..571bd370 100644 --- a/stlearn/plotting/deconvolution_plot.py +++ b/stlearn/plotting/deconvolution_plot.py @@ -1,4 +1,5 @@ import matplotlib as mpl +import matplotlib.colors as mcolors import matplotlib.pyplot as plt import numpy as np from anndata import AnnData @@ -6,30 +7,27 @@ def deconvolution_plot( adata: AnnData, - library_id: str = None, + library_id: str | None = None, use_label: str = "louvain", - cluster: [int, str] = None, - celltype: str = None, + cluster: int | str | None = None, + celltype: str | None = None, celltype_threshold: float = 0, data_alpha: float = 1.0, threshold: float = 0.0, cmap: str = "tab20", - colors: list = None, # The colors to use for each label... - tissue_alpha: float = 1.0, - title: str = None, + colors: list[tuple[float, float, float, float]] | None = None, # The colors to use for each label... spot_size: float | int = 10, show_axis: bool = False, show_legend: bool = True, show_donut: bool = True, cropped: bool = True, margin: int = 100, - name: str = None, + name: str | None = None, dpi: int = 150, - output: str = None, - copy: bool = False, + output: str | None = None, figsize: tuple = (6.4, 4.8), show=True, -) -> AnnData | None: +) -> None: """\ Clustering plot for sptial transcriptomics data. Also, it has a function to display trajectory inference. @@ -42,8 +40,8 @@ def deconvolution_plot( Library id stored in AnnData. use_label Use label result of cluster method. - list_cluster - Choose set of clusters that will display in the plot. + cluster + Choose a cluster (in adata.obs[use_label]) that will display in the plot. data_alpha Opacity of the spot. tissue_alpha @@ -100,12 +98,11 @@ def deconvolution_plot( ] label_filter_ = label_filter[base.index] - if colors is None: - color_vals = list(range(0, len(label_filter_), 1)) - my_norm = mpl.colors.Normalize(0, len(label_filter_)) - my_cmap = mpl.cm.get_cmap(cmap, len(color_vals)) - colors = my_cmap.colors + color_vals: list[int] = list(range(0, len(label_filter_), 1)) + my_norm: mcolors.Normalize = mpl.colors.Normalize(0, len(label_filter_)) + my_cmap: mcolors.Colormap = mpl.cm.get_cmap(cmap, len(color_vals)) + colors = [my_cmap(my_norm(i)) for i in color_vals] for i, xy in enumerate(base.values): _ = ax.pie( @@ -125,14 +122,14 @@ def deconvolution_plot( ] if show_donut: - ax_pie = fig.add_axes([0.5, -0.4, 0.03, 0.5]) + ax_pie = fig.add_axes((0.5, -0.4, 0.03, 0.5)) def my_autopct(pct): return ("%1.0f%%" % pct) if pct >= 4 else "" ax_pie.pie( label_filter_.sum(axis=1), - colors=my_cmap.colors, + colors=colors, radius=10, # frame=True, autopct=my_autopct, @@ -143,8 +140,8 @@ def my_autopct(pct): ) if show_legend: - ax_cb = fig.add_axes([0.9, 0.25, 0.03, 0.5], axisbelow=False) - cb = mpl.colorbar.ColorbarBase( + ax_cb = fig.add_axes((0.9, 0.25, 0.03, 0.5), axisbelow=False) + cb = mpl.pyplot.colorbar.ColorbarBase( ax_cb, cmap=my_cmap, norm=my_norm, ticks=color_vals ) @@ -165,11 +162,8 @@ def my_autopct(pct): if cropped: ax.set_xlim(imagecol.min() - margin, imagecol.max() + margin) - ax.set_ylim(imagerow.min() - margin, imagerow.max() + margin) - ax.set_ylim(ax.get_ylim()[::-1]) - # plt.gca().invert_yaxis() if name is None: diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/plotting/non_spatial_plot.py index 600f7897..800d5779 100644 --- a/stlearn/plotting/non_spatial_plot.py +++ b/stlearn/plotting/non_spatial_plot.py @@ -6,7 +6,7 @@ def non_spatial_plot( adata: AnnData, use_label: str = "louvain", -) -> AnnData | None: +) -> None: """\ A wrap function to plot all the non-spatial plot from scanpy. diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index e13bba29..c958575a 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -11,7 +11,7 @@ def stack_3d_plot( slide_col="sample_id", use_label=None, gene_symbol=None, -) -> AnnData | None: +) -> None: """\ Clustering plot for spatial transcriptomics data. Also, it has a function to display trajectory inference. diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/plotting/trajectory/check_trajectory.py index d9e84f12..587c20e9 100644 --- a/stlearn/plotting/trajectory/check_trajectory.py +++ b/stlearn/plotting/trajectory/check_trajectory.py @@ -6,21 +6,21 @@ def check_trajectory( adata: AnnData, - library_id: str = None, + trajectory: list[int], + library_id: str | None = None, use_label: str = "louvain", basis: str = "umap", pseudotime_key: str = "dpt_pseudotime", - trajectory: list = None, figsize=(10, 4), size_umap: int = 50, - size_spatial: int = 1.5, + size_spatial: float = 1.5, img_key: str = "hires", -) -> AnnData | None: +) -> None: trajectory = np.array(trajectory).astype(int) assert ( trajectory in adata.uns["available_paths"].values() ), "Please choose the right path!" - trajectory = trajectory.astype(str) + trajectory_str = [str(node) for node in trajectory] assert ( pseudotime_key in adata.obs.columns ), "Please run the pseudotime or choose the right one!" @@ -39,7 +39,7 @@ def check_trajectory( ax1 = sc.pl.umap(adata, size=size_umap, show=False, ax=ax1) sc.pl.umap( - adata[adata.obs[use_label].isin(trajectory)], + adata[adata.obs[use_label].isin(trajectory_str)], size=size_umap, color=pseudotime_key, ax=ax1, @@ -55,7 +55,7 @@ def check_trajectory( ax=ax2, ) sc.pl.spatial( - adata[adata.obs[use_label].isin(trajectory)], + adata[adata.obs[use_label].isin(trajectory_str)], size=size_spatial, ax=ax2, color=pseudotime_key, diff --git a/stlearn/preprocessing/filter_genes.py b/stlearn/preprocessing/filter_genes.py index 42cc3d24..71bd4b58 100644 --- a/stlearn/preprocessing/filter_genes.py +++ b/stlearn/preprocessing/filter_genes.py @@ -22,7 +22,7 @@ def filter_genes( `max_counts`, `max_cells` per call. Parameters ---------- - data + adata An annotated data matrix of shape `n_obs` × `n_vars`. Rows correspond to cells and columns to genes. min_counts @@ -47,7 +47,7 @@ def filter_genes( `n_counts` or `n_cells` per gene. """ - scanpy.pp.filter_genes( + return scanpy.pp.filter_genes( adata, min_counts=min_counts, min_cells=min_cells, diff --git a/stlearn/preprocessing/log_scale.py b/stlearn/preprocessing/log_scale.py index 9ebb63e0..0eb1cd1b 100644 --- a/stlearn/preprocessing/log_scale.py +++ b/stlearn/preprocessing/log_scale.py @@ -38,9 +38,9 @@ def log1p( Returns or updates `data`, depending on `copy`. """ - scanpy.pp.log1p(adata, copy=copy, chunked=chunked, chunk_size=chunk_size, base=base) - + result = scanpy.pp.log1p(adata, copy=copy, chunked=chunked, chunk_size=chunk_size, base=base) print("Log transformation step is finished in adata.X") + return result def scale( @@ -75,6 +75,6 @@ def scale( Depending on `copy` returns or updates `adata` with a scaled `adata.X`. """ - scanpy.pp.scale(adata, zero_center=zero_center, max_value=max_value, copy=copy) - + result = scanpy.pp.scale(adata, zero_center=zero_center, max_value=max_value, copy=copy) print("Scale step is finished in adata.X") + return result diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatials/SME/impute.py index a365b192..2e1b5968 100644 --- a/stlearn/spatials/SME/impute.py +++ b/stlearn/spatials/SME/impute.py @@ -48,6 +48,8 @@ def SME_impute0( ------- Anndata """ + adata = adata.copy() if copy else adata + if use_data == "raw": if isinstance(adata.X, csr_matrix): count_embed = adata.X.toarray() @@ -132,6 +134,8 @@ def pseudo_spot( from sklearn.linear_model import LinearRegression + adata = adata.copy() if copy else adata + if platform == "Visium": img_row = adata.obs["imagerow"] img_col = adata.obs["imagecol"] diff --git a/stlearn/spatials/SME/normalize.py b/stlearn/spatials/SME/normalize.py index 04ef41e4..39f65207 100644 --- a/stlearn/spatials/SME/normalize.py +++ b/stlearn/spatials/SME/normalize.py @@ -42,6 +42,8 @@ def SME_normalize( ------- Anndata """ + adata = adata.copy() if copy else adata + if use_data == "raw": if isinstance(adata.X, csr_matrix): count_embed = adata.X.toarray() diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index ce15c836..efc1774f 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -36,6 +36,8 @@ def localization( Anndata """ + adata = adata.copy() if copy else adata + if "sub_cluster_labels" in adata.obs.columns: adata.obs = adata.obs.drop("sub_cluster_labels", axis=1) diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 1305a3bb..7aced5f0 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -46,6 +46,8 @@ def adjust( **[use_data]_morphology** : `adata.obsm` field Add SME normalised gene expression matrix """ + adata = adata.copy() if copy else adata + if "X_morphology" not in adata.obsm: raise ValueError("Please run the function stlearn.pp.extract_feature") coor = adata.obs[["imagecol", "imagerow"]] diff --git a/stlearn/spatials/smooth/disk.py b/stlearn/spatials/smooth/disk.py index 970d1843..01ff2309 100644 --- a/stlearn/spatials/smooth/disk.py +++ b/stlearn/spatials/smooth/disk.py @@ -11,6 +11,9 @@ def disk( method: str = "mean", copy: bool = False, ) -> AnnData | None: + + adata = adata.copy() if copy else adata + coor = adata.obs[["imagecol", "imagerow"]] count_embed = adata.obsm[use_data] point_tree = spatial.cKDTree(coor) diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index faf91c92..da0cdf35 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -16,7 +16,6 @@ def global_level( n_dims: int = 40, return_graph: bool = False, verbose: bool = True, - copy: bool = False, ) -> networkx.Graph | None: """\ Perform global sptial trajectory inference. @@ -33,11 +32,12 @@ def global_level( Use label result of cluster method. return_graph Return PTS graph - copy - Return a copy instead of writing to adata. Returns ------- - Anndata + networkx.Graph: + + adata.uns["PTS_graph"]["graph"]: + adata.uns["PTS_graph"]["node_dict"]: """ assert w <= 1, "w should be in range 0 to 1" diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index bd791312..71a155a3 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -10,8 +10,7 @@ def local_level( w: float = 0.5, return_matrix: bool = False, verbose: bool = True, - copy: bool = False, -) -> AnnData | None: +) -> np.ndarray | None: """\ Perform local sptial trajectory inference (required run pseudotime first). @@ -29,11 +28,15 @@ def local_level( Pseudo-spatio-temporal distance weight (balance between spatial effect and DPT) return_matrix Return PTS matrix for local level - copy - Return a copy instead of writing to adata. Returns ------- - Anndata + np.ndarray: the STDM (spatio-temporal distance matrix) - weighted combination of spatial and temporal distances. + + adata["nonabs_dpt_distance_matrix"]: np.ndarray + Pseudotime distance (difference between values) matrix + + adata["nonabs_dpt_distance_matrix"]: np.ndarray + STDM """ if verbose: print("Start construct trajectory for subcluster " + str(cluster)) @@ -77,5 +80,5 @@ def local_level( if return_matrix: return stdm - - return adata if copy else None + else: + return None \ No newline at end of file diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index 1c06cc51..f4328f50 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -568,19 +568,6 @@ def _correlation_test_helper( confidence interval. Each array if of shape ``(n_genes, n_lineages)``. """ - def perm_test_extractor( - res: Sequence[tuple[np.ndarray, np.ndarray]], - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - pvals, corr_bs = zip(*res) - pvals = np.sum(pvals, axis=0) / float(n_perms) - - corr_bs = np.concatenate(corr_bs, axis=0) - corr_ci_low, corr_ci_high = np.quantile(corr_bs, q=ql, axis=0), np.quantile( - corr_bs, q=qh, axis=0 - ) - - return pvals, corr_ci_low, corr_ci_high - if not (0 <= confidence_level <= 1): raise ValueError( "Expected `confidence_level` to be in interval `[0, 1]`, " diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tools/clustering/kmeans.py index 398e4184..822130af 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tools/clustering/kmeans.py @@ -13,7 +13,7 @@ def kmeans( n_init: int = 10, max_iter: int = 300, tol: float = 0.0001, - random_state: str = None, + random_state: int | np.random.RandomState = None, copy_x: bool = True, algorithm: str = "auto", key_added: str = "kmeans", diff --git a/stlearn/utils.py b/stlearn/utils.py index 581aec49..a4531ea0 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -25,15 +25,17 @@ def _check_spot_size(spatial_data: Mapping | None, spot_size: float | None) -> f Resolve spot_size value. This is a required argument for spatial plots. """ - if spatial_data is None and spot_size is None: + if spot_size is not None: + return spot_size + + if spatial_data is None: raise ValueError( "When .uns['spatial'][library_id] does not exist, spot_size must be " "provided directly." ) - elif spot_size is None: - return spatial_data["scalefactors"]["spot_diameter_fullres"] - else: - return spot_size + + return spatial_data["scalefactors"]["spot_diameter_fullres"] + def _check_scale_factor( @@ -52,7 +54,7 @@ def _check_scale_factor( def _check_spatial_data( uns: Mapping, library_id: Empty | None | str -) -> tuple[str | None, Mapping | None]: +) -> tuple[str | Empty | None, Mapping | None]: """ Given a mapping, try and extract a library id/ mapping with spatial data. Assumes this is `.uns` from how we parse visium data. @@ -83,16 +85,51 @@ def _check_img( ) -> tuple[np.ndarray | None, str | None]: """ Resolve image for spatial plots. + + Parameters + ---------- + img : np.ndarray | None + If given an image will not look for another image and not check to see if it was in spatial_data. + img_key : None | str | Empty + If None - don't find an image. Empty - find best image, or specify with str. + + Returns + ------- + tuple[np.ndarray | None, str | None] + The image found or nothing, str of the key of image found or None if none found. + + """ - if img is None and spatial_data is not None and img_key is _empty: - img_key = next( - (k for k in ["hires", "lowres", "fulres"] if k in spatial_data["images"]), - ) # Throws StopIteration Error if keys not present - if img is None and spatial_data is not None and img_key is not None: - img = spatial_data["images"][img_key] - if bw: - img = np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]) - return img, img_key + + # Return [None, None] if there's no anndata mapping or img + if spatial_data is None and img is None: + return None, None + else: + # Find image and key + new_img_key: str | None = None + new_img: np.ndarray | None = None + + # Return the img if not None and convert the key to Empty -> None if Empty otherwise keep. + if img is not None: + new_img = img + new_img_key = img_key if img_key is not _empty else None + # Find key if empty or use key. + elif spatial_data is not None: + if img_key is _empty: + # Looks for image - or None if not found. + new_img_key = next( + (k for k in ["hires", "lowres", "fulres"] if k in spatial_data["images"]), None + ) + else: + new_img_key = img_key + + if new_img_key is not None: + new_img = spatial_data["images"][new_img_key] + + if new_img is not None and bw: + new_img = np.dot(new_img[..., :3], [0.2989, 0.5870, 0.1140]) + + return new_img, new_img_key def _check_coords( diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index be44ee8d..fbb3fa7a 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -2,7 +2,9 @@ import json import logging as logg +from os import PathLike from pathlib import Path +from typing import Iterator import matplotlib.pyplot as plt import numpy as np @@ -204,8 +206,8 @@ def Read10X( def ReadOldST( - count_matrix_file: str | Path | None = None, - spatial_file: str | Path | None = None, + count_matrix_file: PathLike[str] | str | Iterator[str], + spatial_file: int | str | bytes | PathLike[str] | PathLike[bytes], image_file: str | Path | None = None, library_id: str = "OldST", scale: float = 1.0, From 74e656c5ace81cdabb0a1bfbbec192eb41a7bda8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 14:01:55 +1000 Subject: [PATCH 025/123] Remove Literal shim. --- stlearn/_compat.py | 15 --- stlearn/_settings.py | 3 +- stlearn/embedding/umap.py | 4 +- .../image_preprocessing/feature_extractor.py | 3 +- stlearn/preprocessing/graph.py | 4 +- stlearn/preprocessing/normalize.py | 4 +- stlearn/spatials/SME/_weighting_matrix.py | 4 +- stlearn/spatials/SME/impute.py | 2 +- stlearn/spatials/morphology/adjust.py | 4 +- stlearn/tools/clustering/louvain.py | 3 +- stlearn/tools/microenv/cci/het_helpers.py | 120 ------------------ stlearn/wrapper/read.py | 4 +- 12 files changed, 14 insertions(+), 156 deletions(-) delete mode 100644 stlearn/_compat.py diff --git a/stlearn/_compat.py b/stlearn/_compat.py deleted file mode 100644 index ba28b435..00000000 --- a/stlearn/_compat.py +++ /dev/null @@ -1,15 +0,0 @@ -try: - from typing import Literal -except ImportError: - try: - from typing import Literal - except ImportError: - - class LiteralMeta(type): - def __getitem__(cls, values): - if not isinstance(values, tuple): - values = (values,) - return type("Literal_", (Literal,), dict(__args__=values)) - - class Literal(metaclass=LiteralMeta): - pass diff --git a/stlearn/_settings.py b/stlearn/_settings.py index 97276dbd..444612c3 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -6,10 +6,9 @@ from logging import getLevelName from pathlib import Path from time import time -from typing import Any, TextIO, Iterator +from typing import Any, TextIO, Iterator, Literal from . import logging -from ._compat import Literal from .logging import _RootLogger, _set_log_file, _set_log_level # All the code here migrated from scanpy diff --git a/stlearn/embedding/umap.py b/stlearn/embedding/umap.py index aa509979..ad3079ca 100644 --- a/stlearn/embedding/umap.py +++ b/stlearn/embedding/umap.py @@ -1,10 +1,10 @@ +from typing import Literal + import numpy as np import scanpy from anndata import AnnData from numpy.random.mtrand import RandomState -from .._compat import Literal - _InitPos = Literal["paga", "spectral", "random"] diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index dc07b343..50fe976c 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -1,3 +1,5 @@ +from typing import Literal + import numpy as np import pandas as pd from anndata import AnnData @@ -6,7 +8,6 @@ # Test progress bar from tqdm import tqdm -from .._compat import Literal from .model_zoo import Model, encode _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] diff --git a/stlearn/preprocessing/graph.py b/stlearn/preprocessing/graph.py index 1cfb2d05..ccc381ed 100644 --- a/stlearn/preprocessing/graph.py +++ b/stlearn/preprocessing/graph.py @@ -1,14 +1,12 @@ from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import Any +from typing import Any, Literal import numpy as np import scanpy from anndata import AnnData from numpy.random import RandomState -from .._compat import Literal - _Method = Literal["umap", "gauss", "rapids"] _MetricFn = Callable[[np.ndarray, np.ndarray], float] # from sklearn.metrics.pairwise_distances.__doc__: diff --git a/stlearn/preprocessing/normalize.py b/stlearn/preprocessing/normalize.py index 35638614..10018d6a 100644 --- a/stlearn/preprocessing/normalize.py +++ b/stlearn/preprocessing/normalize.py @@ -1,12 +1,10 @@ from collections.abc import Iterable +from typing import Literal import numpy as np import scanpy from anndata import AnnData -from stlearn._compat import Literal - - def normalize_total( adata: AnnData, target_sum: float | None = None, diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatials/SME/_weighting_matrix.py index dfc10727..2ff23eb4 100644 --- a/stlearn/spatials/SME/_weighting_matrix.py +++ b/stlearn/spatials/SME/_weighting_matrix.py @@ -1,10 +1,10 @@ +from typing import Literal + import numpy as np from anndata import AnnData from sklearn.metrics import pairwise_distances from tqdm import tqdm -from ..._compat import Literal - _PLATFORM = Literal["Visium", "Old_ST"] _WEIGHTING_MATRIX = Literal[ "weights_matrix_all", diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatials/SME/impute.py index 2e1b5968..68a20dc3 100644 --- a/stlearn/spatials/SME/impute.py +++ b/stlearn/spatials/SME/impute.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal import numpy as np import pandas as pd @@ -8,7 +9,6 @@ import stlearn -from ..._compat import Literal from ._weighting_matrix import ( _PLATFORM, _WEIGHTING_MATRIX, diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 7aced5f0..8ec70950 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -1,10 +1,10 @@ +from typing import Literal + import numpy as np import scipy.spatial as spatial from anndata import AnnData from tqdm import tqdm -from ..._compat import Literal - _SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 89a74a3c..05972dbb 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -1,12 +1,11 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Any +from typing import Any, Literal from anndata import AnnData from numpy.random.mtrand import RandomState from scipy.sparse import spmatrix import scanpy -from stlearn._compat import Literal from louvain.VertexPartition import MutableVertexPartition def louvain( diff --git a/stlearn/tools/microenv/cci/het_helpers.py b/stlearn/tools/microenv/cci/het_helpers.py index 67386fc2..3c8dc935 100644 --- a/stlearn/tools/microenv/cci/het_helpers.py +++ b/stlearn/tools/microenv/cci/het_helpers.py @@ -226,126 +226,6 @@ def get_data_for_counting(adata, use_label, mix_mode, all_set): ) # neighbourhood_bcs, neighbourhood_indices -def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): - """Retrieves the minimal information necessary to perform edge counting.""" - # First determining how the edge counting needs to be performed # - # Ensuring compatibility with current way of adding label_transfer to object - if use_label == "label_transfer" or use_label == "predictions": - obs_key, uns_key = "predictions", "label_transfer" - else: - obs_key, uns_key = use_label, use_label - - # Getting the neighbourhoods # - neighbours, neighbourhood_bcs, neighbourhood_indices = get_neighbourhoods(adata) - - # Getting the cell type information; if not mixtures then populate - # matrix with one's indicating pure spots. - if mix_mode: - cell_props = adata.uns[uns_key] - cols = cell_props.columns.values.astype(str) - col_order = [ - np.where([cell_type in col for col in cols])[0][0] for cell_type in all_set - ] - cell_data = adata.uns[uns_key].iloc[:, col_order].values.astype(np.float64) - else: - cell_labels = adata.obs.loc[:, obs_key].values - cell_data = np.zeros((len(cell_labels), len(all_set)), dtype=np.float64) - for i, cell_type in enumerate(all_set): - cell_data[:, i] = ( - (cell_labels == cell_type).astype(np.int32).astype(np.float64) - ) - - spot_bcs = adata.obs_names.values.astype(str) - return spot_bcs, cell_data, neighbourhood_bcs, neighbourhood_indices - - -# @njit -def get_neighbourhoods_FAST( - spot_bcs: np.array, - spot_neigh_bcs: np.ndarray, - n_spots: int, - str_dtype: str, - neigh_indices: np.array, - neigh_bcs: np.array, -): - """Gets the neighbourhood information, njit compiled.""" - - # Determining the neighbour spots used for significance testing # - # neighbours = List( numba.int64[:] ) - # neighbourhood_bcs = List((numba.int64, numba.int64[:])) - # neighbourhood_indices = List( (types.unicode_type, types.unicode_type[:]) ) - - # Numba version - # neighbours = List([neigh_indices])[1:] - # neighbourhood_bcs = List() - # neighbourhood_indices = List([(0, neigh_indices)])[1:] - - # Trying normal lists - neighbours, neighbourhood_bcs, neighbourhood_indices = [], [], [] - - for i in range(spot_neigh_bcs.shape[0]): - neigh_bcs = np.array(spot_neigh_bcs[i, :][0].split(",")) - neigh_bcs = neigh_bcs[neigh_bcs != ""] - # neigh_bcs_sub = List() - # for neigh_bc in neigh_bcs: - # if neigh_bc in spot_bcs: - # neigh_bcs_sub.append( neigh_bc ) - - # neigh_bcs_array = np.empty((len(neigh_bcs_sub)), str_dtype) - # neigh_bcs_array = np.empty(len(neigh_bcs_sub), dtype=str_dtype) - # neigh_indices = np.zeros((len(neigh_bcs_sub)), dtype=np.int64) - neigh_bcs_array, neigh_indices = [], [] - for j, neigh_bc in enumerate(neigh_bcs): - - bc_indices = np.where(spot_bcs == neigh_bc)[0] - if len(bc_indices) > 0: - neigh_bcs_array.append(neigh_bc) - neigh_indices.append(bc_indices[0]) - - neigh_bcs_array = np.array(neigh_bcs_array, dtype=str_dtype) - neigh_indices = np.array(neigh_indices, dtype=np.int64) - - neighbours.append(neigh_indices) - neighbourhood_indices.append((i, neigh_indices)) - neighbourhood_bcs.append((spot_bcs[i], neigh_bcs_array)) - - # return neighbours, neighbourhood_bcs, neighbourhood_indices - return List(neighbours), List(neighbourhood_bcs), List(neighbourhood_indices) - - -def get_data_for_counting_OLD(adata, use_label, mix_mode, all_set): - """Retrieves the minimal information necessary to perform edge counting.""" - # First determining how the edge counting needs to be performed # - # Ensuring compatibility with current way of adding label_transfer to object - if use_label == "label_transfer" or use_label == "predictions": - obs_key, uns_key = "predictions", "label_transfer" - else: - obs_key, uns_key = use_label, use_label - - # Getting the neighbourhoods # - neighbours, neighbourhood_bcs, neighbourhood_indices = get_neighbourhoods(adata) - - # Getting the cell type information; if not mixtures then populate - # matrix with one's indicating pure spots. - if mix_mode: - cell_props = adata.uns[uns_key] - cols = cell_props.columns.values.astype(str) - col_order = [ - np.where([cell_type in col for col in cols])[0][0] for cell_type in all_set - ] - cell_data = adata.uns[uns_key].iloc[:, col_order].values.astype(np.float64) - else: - cell_labels = adata.obs.loc[:, obs_key].values - cell_data = np.zeros((len(cell_labels), len(all_set)), dtype=np.float64) - for i, cell_type in enumerate(all_set): - cell_data[:, i] = ( - (cell_labels == cell_type).astype(np.int_).astype(np.float64) - ) - - spot_bcs = adata.obs_names.values.astype(str) - return spot_bcs, cell_data, neighbourhood_bcs, neighbourhood_indices - - def get_neighbourhoods_FAST( spot_bcs: np.array, spot_neigh_bcs: np.ndarray, diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index fbb3fa7a..291dd138 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -4,7 +4,7 @@ import logging as logg from os import PathLike from pathlib import Path -from typing import Iterator +from typing import Iterator, Literal import matplotlib.pyplot as plt import numpy as np @@ -16,8 +16,6 @@ import stlearn -from .._compat import Literal - _Quality = Literal["fulres", "hires", "lowres"] _Background = Literal["black", "white"] From b3e5b780fafcb56c9dfc73b78f5ffa34030c51ef Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 14:12:18 +1000 Subject: [PATCH 026/123] Reformat. --- stlearn/_settings.py | 1 + stlearn/adds/add_loupe_clusters.py | 2 +- stlearn/app/cli.py | 3 +- stlearn/em.py | 2 +- stlearn/logging.py | 196 ++++--- stlearn/pl.py | 1 + stlearn/plotting/cci_plot.py | 3 +- stlearn/plotting/cci_plot_helpers.py | 5 +- stlearn/plotting/classes.py | 482 +++++++++--------- stlearn/plotting/classes_bokeh.py | 1 - stlearn/plotting/cluster_plot.py | 5 +- stlearn/plotting/deconvolution_plot.py | 4 +- stlearn/plotting/feat_plot.py | 3 +- stlearn/plotting/gene_plot.py | 61 +-- stlearn/plotting/subcluster_plot.py | 6 +- .../plotting/trajectory/DE_transition_plot.py | 2 +- .../trajectory/transition_markers_plot.py | 4 +- stlearn/preprocessing/log_scale.py | 8 +- stlearn/preprocessing/normalize.py | 1 + stlearn/spatials/trajectory/local_level.py | 2 +- .../spatials/trajectory/pseudotimespace.py | 2 +- stlearn/tools/clustering/louvain.py | 1 + stlearn/tools/microenv/cci/base_grouping.py | 1 + stlearn/utils.py | 10 +- 24 files changed, 434 insertions(+), 372 deletions(-) diff --git a/stlearn/_settings.py b/stlearn/_settings.py index 444612c3..5e0c28c3 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -65,6 +65,7 @@ class stLearnConfig: # noqa N801 """\ Config manager for scanpy. """ + _logpath: Path | None _logfile: TextIO _verbosity: Verbosity diff --git a/stlearn/adds/add_loupe_clusters.py b/stlearn/adds/add_loupe_clusters.py index a85b15cf..f257f80f 100644 --- a/stlearn/adds/add_loupe_clusters.py +++ b/stlearn/adds/add_loupe_clusters.py @@ -43,4 +43,4 @@ def add_loupe_clusters( categories=natsorted(label[key_add].unique().astype("U")), ) - return adata if copy else None \ No newline at end of file + return adata if copy else None diff --git a/stlearn/app/cli.py b/stlearn/app/cli.py index 42e92779..4d66f843 100644 --- a/stlearn/app/cli.py +++ b/stlearn/app/cli.py @@ -34,5 +34,6 @@ def launch(): ) from e raise + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/stlearn/em.py b/stlearn/em.py index bfac0db9..5ba9551f 100644 --- a/stlearn/em.py +++ b/stlearn/em.py @@ -13,4 +13,4 @@ "run_ica", "run_fa", "run_diffmap", -] \ No newline at end of file +] diff --git a/stlearn/logging.py b/stlearn/logging.py index cfacdfad..985ee5e8 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -28,13 +28,13 @@ def __init__(self, level): _RootLogger.manager = logging.Manager(self) def log_with_timing( - self, - level: int, - msg: str, - *, - extra: dict | None = None, - time: datetime | None = None, - deep: str | None = None, + self, + level: int, + msg: str, + *, + extra: dict | None = None, + time: datetime | None = None, + deep: str | None = None, ) -> datetime: from . import settings @@ -48,14 +48,15 @@ def log_with_timing( super().log(level, msg, extra=extra) return now - def _handle_enhanced_logging(self, level: int, msg, *args, **kwargs) -> Optional[ - datetime]: + def _handle_enhanced_logging( + self, level: int, msg, *args, **kwargs + ) -> Optional[datetime]: """Handle logging with enhanced features (timing, deep info) or fall back to standard logging.""" - if 'time' in kwargs or 'deep' in kwargs or 'extra' in kwargs: + if "time" in kwargs or "deep" in kwargs or "extra" in kwargs: # Extract enhanced arguments - time_arg = kwargs.pop('time', None) - deep_arg = kwargs.pop('deep', None) - extra_arg = kwargs.pop('extra', None) + time_arg = kwargs.pop("time", None) + deep_arg = kwargs.pop("deep", None) + extra_arg = kwargs.pop("extra", None) # Format message if there are remaining args if args or kwargs: @@ -63,83 +64,104 @@ def _handle_enhanced_logging(self, level: int, msg, *args, **kwargs) -> Optional else: formatted_msg = msg - return self.log_with_timing(level, formatted_msg, - time=time_arg, deep=deep_arg, extra=extra_arg) + return self.log_with_timing( + level, formatted_msg, time=time_arg, deep=deep_arg, extra=extra_arg + ) else: super().log(level, msg, *args, **kwargs) return None - def hint(self, msg, *, time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None) -> datetime: + def hint( + self, + msg, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> datetime: return self.log_with_timing(HINT, msg, time=time, deep=deep, extra=extra) @overload - def debug(self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, - stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None) -> None: - ... + def debug( + self, + msg: object, + *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: ... @overload - def debug(self, msg, *args, **kwargs): - ... + def debug(self, msg, *args, **kwargs): ... def debug(self, msg, *args, **kwargs) -> Optional[datetime]: return self._handle_enhanced_logging(DEBUG, msg, *args, **kwargs) @overload - def info(self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, - stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None) -> None: - ... + def info( + self, + msg: object, + *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: ... @overload - def info(self, msg, *args, **kwargs): - ... + def info(self, msg, *args, **kwargs): ... def info(self, msg, *args, **kwargs) -> Optional[datetime]: return self._handle_enhanced_logging(INFO, msg, *args, **kwargs) @overload - def warning(self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, - stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None) -> None: - ... + def warning( + self, + msg: object, + *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: ... @overload - def warning(self, msg, *args, **kwargs): - ... + def warning(self, msg, *args, **kwargs): ... def warning(self, msg, *args, **kwargs) -> Optional[datetime]: return self._handle_enhanced_logging(WARNING, msg, *args, **kwargs) @overload - def error(self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, - stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None) -> None: - ... + def error( + self, + msg: object, + *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: ... @overload - def error(self, msg, *args, **kwargs): - ... + def error(self, msg, *args, **kwargs): ... def error(self, msg, *args, **kwargs) -> Optional[datetime]: return self._handle_enhanced_logging(ERROR, msg, *args, **kwargs) @overload - def critical(self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, - stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None) -> None: - ... + def critical( + self, + msg: object, + *args: object, + exc_info: Union[bool, tuple, BaseException, None] = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Optional[Mapping[str, object]] = None, + ) -> None: ... @overload - def critical(self, msg, *args, **kwargs): - ... + def critical(self, msg, *args, **kwargs): ... def critical(self, msg, *args, **kwargs) -> Optional[datetime]: return self._handle_enhanced_logging(CRITICAL, msg, *args, **kwargs) @@ -168,7 +190,7 @@ def _set_log_level(settings, level: int): class _LogFormatter(logging.Formatter): def __init__( - self, fmt="{levelname}: {message}", datefmt="%Y-%m-%d %H:%M", style="{" + self, fmt="{levelname}: {message}", datefmt="%Y-%m-%d %H:%M", style="{" ): super().__init__(fmt, datefmt, style) @@ -182,23 +204,19 @@ def format(self, record: logging.LogRecord): self._style._fmt = " {message}" # Handle time_passed if present (should be in extra) - time_passed = getattr(record, 'time_passed', None) + time_passed = getattr(record, "time_passed", None) if time_passed: # Strip microseconds if time_passed.microseconds: - time_passed = timedelta( - seconds=int(time_passed.total_seconds()) - ) + time_passed = timedelta(seconds=int(time_passed.total_seconds())) if "{time_passed}" in record.msg: - record.msg = record.msg.replace( - "{time_passed}", str(time_passed) - ) + record.msg = record.msg.replace("{time_passed}", str(time_passed)) else: self._style._fmt += " ({time_passed})" # Add time_passed to record for formatting record.time_passed = time_passed - deep = getattr(record, 'deep', None) + deep = getattr(record, "deep", None) if deep: record.msg = f"{record.msg}: {deep}" @@ -270,11 +288,11 @@ def _copy_docs_and_signature(fn): def error( - msg: str, - *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, ) -> datetime: """\ Log message with specific level and return current time. @@ -301,35 +319,55 @@ def error( @_copy_docs_and_signature(error) -def warning(msg: str, *, time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None) -> datetime: +def warning( + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, +) -> datetime: from ._settings import settings + result = settings._root_logger.warning(msg, time=time, deep=deep, extra=extra) return result or datetime.now(timezone.utc) @_copy_docs_and_signature(error) -def info(msg: str, *, time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None) -> datetime: +def info( + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, +) -> datetime: from ._settings import settings + result = settings._root_logger.info(msg, time=time, deep=deep, extra=extra) return result or datetime.now(timezone.utc) @_copy_docs_and_signature(error) -def hint(msg: str, *, time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None) -> datetime: +def hint( + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, +) -> datetime: from ._settings import settings + return settings._root_logger.hint(msg, time=time, deep=deep, extra=extra) @_copy_docs_and_signature(error) -def debug(msg: str, *, time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None) -> datetime: +def debug( + msg: str, + *, + time: Optional[datetime] = None, + deep: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, +) -> datetime: from ._settings import settings + result = settings._root_logger.debug(msg, time=time, deep=deep, extra=extra) return result or datetime.now(timezone.utc) diff --git a/stlearn/pl.py b/stlearn/pl.py index 56baff5c..9ef4f5d5 100644 --- a/stlearn/pl.py +++ b/stlearn/pl.py @@ -12,6 +12,7 @@ from .plotting.cci_plot import het_plot from .plotting.cci_plot import lr_diagnostics, lr_n_spots, lr_summary, lr_go from .plotting.cci_plot import lr_plot, lr_result_plot + # from .plotting.cci_plot import het_plot_interactive from .plotting.cci_plot import lr_plot_interactive, spatialcci_plot_interactive from .plotting.cluster_plot import cluster_plot diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 5c053747..df983f75 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -3,7 +3,8 @@ import sys from typing import ( Any, - Optional, Tuple, # Special + Optional, + Tuple, # Special ) import matplotlib diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 9007e83f..de4b243a 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,4 +1,5 @@ """Helper functions for cci_plot.py.""" + from typing import List, Tuple, Optional import matplotlib @@ -232,8 +233,8 @@ def add_arrows( int_df: pd.DataFrame | None, head_width: float = 4, width: float = 0.001, - arrow_cmap: str | None =None, - arrow_vmax: float | None =None, + arrow_cmap: str | None = None, + arrow_vmax: float | None = None, ): """ Adds arrows to the current plot for significant spots to neighbours \ which is interacting with. diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 6a6db687..097c3e81 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -7,7 +7,8 @@ import numbers import warnings from typing import ( # Special - Optional, Tuple, # Classes + Optional, + Tuple, # Classes ) import matplotlib @@ -25,31 +26,31 @@ class SpatialBasePlot(Spatial): def __init__( - self, - # plotting param - adata: AnnData, - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - color_bar_label: str = "", - zoom_coord: Tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 0.7, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - **kwds, + self, + # plotting param + adata: AnnData, + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 0.7, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + **kwds, ): super().__init__( adata, @@ -73,7 +74,7 @@ def __init__( if use_label is not None: assert ( - use_label in self.adata[0].obs.columns + use_label in self.adata[0].obs.columns ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label @@ -103,8 +104,8 @@ def __init__( stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" - "one of these: " + str(cmap_available) + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "one of these: " + str(cmap_available) ) if cmap is str: assert cmap in cmap_available, error_msg @@ -139,7 +140,7 @@ def create_query(list_cl, use_label): if self.list_clusters is not None: # IF not all clusters specified, subset, otherwise just copy. if len(self.list_clusters) != len( - self.adata[0].obs[self.use_label].cat.categories + self.adata[0].obs[self.use_label].cat.categories ): self.query_adata = self.query_adata[ self.query_adata.obs.query( @@ -181,8 +182,9 @@ def _crop_image(self, main_ax: _AxesSubplot, margin: float): main_ax.set_ylim(self.imagerow.min() - margin, self.imagerow.max() + margin) main_ax.set_ylim(main_ax.get_ylim()[::-1]) - def _zoom_image(self, main_ax: _AxesSubplot, - zoom_coord: Tuple[float, float, float, float]): + def _zoom_image( + self, main_ax: _AxesSubplot, zoom_coord: Tuple[float, float, float, float] + ): main_ax.set_xlim(zoom_coord[0], zoom_coord[1]) main_ax.set_ylim(zoom_coord[3], zoom_coord[2]) @@ -227,39 +229,39 @@ class GenePlot(SpatialBasePlot): gene_symbols: list[str] def __init__( - self, - adata: AnnData, - # plotting param - title: str | None = None, - figsize: Tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - color_bar_label: str = "", - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # gene plot param - gene_symbols: str | list[str] | None = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: str | None = None, + figsize: Tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # gene plot param + gene_symbols: str | list[str] | None = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -440,38 +442,38 @@ def _add_threshold(self, gene_values, threshold): class FeaturePlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - color_bar_label: str = "", - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # gene plot param - feature: str | None = None, - threshold: float | None = None, - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # gene plot param + feature: str | None = None, + threshold: float | None = None, + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -528,7 +530,7 @@ def _get_feature_values(self): self.feature + " is not in data.obs, please try another feature" ) elif not isinstance( - self.query_adata.obs[self.feature].values[0], numbers.Number + self.query_adata.obs[self.feature].values[0], numbers.Number ): raise ValueError( self.feature @@ -604,44 +606,44 @@ def _add_threshold(self, feature_values, threshold): # Cluster plot class class ClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "default", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 5, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - fname: str | None = None, - dpi: int = 120, - # cluster plot param - show_subcluster: bool = False, - show_cluster_labels: bool = False, - show_trajectories: bool = False, - reverse: bool = False, - show_node: bool = False, - threshold_spots: int = 5, - text_box_size: float = 5, - color_bar_size: float = 10, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - # trajectory - trajectory_node_size: int = 10, - trajectory_alpha: float = 1.0, - trajectory_width: float = 2.5, - trajectory_edge_color: str = "#f4efd3", - trajectory_arrowsize: int = 17, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "default", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + fname: str | None = None, + dpi: int = 120, + # cluster plot param + show_subcluster: bool = False, + show_cluster_labels: bool = False, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = False, + threshold_spots: int = 5, + text_box_size: float = 5, + color_bar_size: float = 10, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + # trajectory + trajectory_node_size: int = 10, + trajectory_alpha: float = 1.0, + trajectory_width: float = 2.5, + trajectory_edge_color: str = "#f4efd3", + trajectory_arrowsize: int = 17, ): super().__init__( adata=adata, @@ -766,7 +768,7 @@ def _add_cluster_labels(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -809,7 +811,7 @@ def _add_sub_clusters(self): label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ].index + ].index ) subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), label_index) @@ -819,18 +821,18 @@ def _add_sub_clusters(self): imgrow_new = subset_spatial[:, 1] * self.scale_factor if ( - len( - self.query_adata.obs[ - self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() - ) - < 2 + len( + self.query_adata.obs[ + self.query_adata.obs[self.use_label] == str(label) + ]["sub_cluster_labels"].unique() + ) + < 2 ): centroids = [centroidpython(imgcol_new, imgrow_new)] classes = np.array( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"].unique() + ]["sub_cluster_labels"].unique() ) else: @@ -841,7 +843,7 @@ def _add_sub_clusters(self): np.column_stack((imgcol_new, imgrow_new)), self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) - ]["sub_cluster_labels"], + ]["sub_cluster_labels"], ) centroids = clf.centroids_ @@ -849,12 +851,12 @@ def _add_sub_clusters(self): for j, label in enumerate(classes): if ( - len( - self.query_adata.obs[ - self.query_adata.obs["sub_cluster_labels"] == label - ] - ) - > self.threshold_spots + len( + self.query_adata.obs[ + self.query_adata.obs["sub_cluster_labels"] == label + ] + ) + > self.threshold_spots ): if centroids[j][0] < 1500: x = -100 @@ -956,34 +958,34 @@ def _add_trajectories(self): class SubClusterPlot(SpatialBasePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "jet", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 5, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - fname: str | None = None, - dpi: int = 120, - # subcluster plot param - cluster: int = 0, - threshold_spots: int = 5, - text_box_size: float = 5, - bbox_to_anchor: tuple[float, float] | None = (1, 1), - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "jet", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 5, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + fname: str | None = None, + dpi: int = 120, + # subcluster plot param + cluster: int = 0, + threshold_spots: int = 5, + text_box_size: float = 5, + bbox_to_anchor: tuple[float, float] | None = (1, 1), + **kwargs, ): super().__init__( adata=adata, @@ -1110,36 +1112,36 @@ def _add_subclusters_label(self, subset): class CciPlot(GenePlot): def __init__( - self, - adata: AnnData, - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # cci_rank param - use_het: str = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + use_het: str = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): super().__init__( adata=adata, @@ -1179,36 +1181,36 @@ def _get_gene_expression(self): class LrResultPlot(GenePlot): def __init__( - self, - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: Optional["str"] = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # cci_rank param - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, - **kwargs, + self, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: Optional["str"] = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + crop: bool = True, + zoom_coord: Tuple[float, float, float, float] | None = None, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, + **kwargs, ): # Making sure cci_rank has been run first # if "lr_summary" not in adata.uns: diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 2aff9167..9b9e8ee9 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -782,7 +782,6 @@ def __init__( else: image = (self.img * 255).astype(np.uint8) - img_pillow = Image.fromarray(image).convert("RGBA") self.xdim, self.ydim = img_pillow.size diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index 8c3194e0..e2f4e47e 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -1,5 +1,6 @@ from typing import ( - Optional, Tuple, # Special + Optional, + Tuple, # Special ) import matplotlib @@ -44,7 +45,7 @@ def cluster_plot( show_node: bool = False, threshold_spots: int = 5, text_box_size: float = 5, - color_bar_size: float= 10, + color_bar_size: float = 10, bbox_to_anchor: tuple[float, float] | None = (1, 1), # trajectory trajectory_node_size: int = 10, diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/plotting/deconvolution_plot.py index 571bd370..f0c92a5c 100644 --- a/stlearn/plotting/deconvolution_plot.py +++ b/stlearn/plotting/deconvolution_plot.py @@ -15,7 +15,9 @@ def deconvolution_plot( data_alpha: float = 1.0, threshold: float = 0.0, cmap: str = "tab20", - colors: list[tuple[float, float, float, float]] | None = None, # The colors to use for each label... + colors: ( + list[tuple[float, float, float, float]] | None + ) = None, # The colors to use for each label... spot_size: float | int = 10, show_axis: bool = False, show_legend: bool = True, diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index e4256bc9..092b7ff4 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -3,7 +3,8 @@ """ from typing import ( - Optional, Tuple, # Special + Optional, + Tuple, # Special ) import matplotlib diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index 7b5c3285..03489251 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,5 +1,6 @@ from typing import ( # Special - Optional, Tuple, # Classes + Optional, + Tuple, # Classes ) import matplotlib @@ -15,35 +16,35 @@ @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) def gene_plot( - adata: AnnData, - gene_symbols: str | list | None = None, - threshold: float | None = None, - method: str = "CumSum", - contour: bool = False, - step_size: int | None = None, - title: str | None = None, - figsize: Tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: matplotlib.axes.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - color_bar_label: str = "", - zoom_coord: Tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 0.7, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + gene_symbols: str | list | None = None, + threshold: float | None = None, + method: str = "CumSum", + contour: bool = False, + step_size: int | None = None, + title: str | None = None, + figsize: Tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: matplotlib.axes.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + color_bar_label: str = "", + zoom_coord: Tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 0.7, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + vmin: float | None = None, + vmax: float | None = None, ) -> AnnData | None: """\ Allows the visualization of a single gene or multiple genes as the values diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index c6f9c77c..0e8c66e5 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -31,11 +31,11 @@ def subcluster_plot( image_alpha: float = 1.0, cell_alpha: float = 1.0, fname: str | None = None, - dpi: int = 120, + dpi: int = 120, # subcluster plot param cluster: int = 0, threshold_spots: int = 5, - text_box_size: float= 5, + text_box_size: float = 5, bbox_to_anchor: tuple[float, float] | None = (1, 1), ) -> AnnData | None: """\ @@ -87,4 +87,4 @@ def subcluster_plot( threshold_spots=threshold_spots, ) - return adata \ No newline at end of file + return adata diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/plotting/trajectory/DE_transition_plot.py index 5f7b5147..1ea91831 100644 --- a/stlearn/plotting/trajectory/DE_transition_plot.py +++ b/stlearn/plotting/trajectory/DE_transition_plot.py @@ -240,4 +240,4 @@ def DE_transition_plot( if name is not None: plt.savefig(output + "/" + name, dpi=dpi, bbox_inches="tight", pad_inches=0) - return adata \ No newline at end of file + return adata diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/plotting/trajectory/transition_markers_plot.py index cf93e93d..c816b193 100644 --- a/stlearn/plotting/trajectory/transition_markers_plot.py +++ b/stlearn/plotting/trajectory/transition_markers_plot.py @@ -35,7 +35,9 @@ def transition_markers_plot( """ if trajectory not in adata.uns: - raise ValueError("Please input the right trajectory name - not found in adata.uns!") + raise ValueError( + "Please input the right trajectory name - not found in adata.uns!" + ) pos = ( adata.uns[trajectory][adata.uns[trajectory]["score"] >= 0] diff --git a/stlearn/preprocessing/log_scale.py b/stlearn/preprocessing/log_scale.py index 0eb1cd1b..2faf99cf 100644 --- a/stlearn/preprocessing/log_scale.py +++ b/stlearn/preprocessing/log_scale.py @@ -38,7 +38,9 @@ def log1p( Returns or updates `data`, depending on `copy`. """ - result = scanpy.pp.log1p(adata, copy=copy, chunked=chunked, chunk_size=chunk_size, base=base) + result = scanpy.pp.log1p( + adata, copy=copy, chunked=chunked, chunk_size=chunk_size, base=base + ) print("Log transformation step is finished in adata.X") return result @@ -75,6 +77,8 @@ def scale( Depending on `copy` returns or updates `adata` with a scaled `adata.X`. """ - result = scanpy.pp.scale(adata, zero_center=zero_center, max_value=max_value, copy=copy) + result = scanpy.pp.scale( + adata, zero_center=zero_center, max_value=max_value, copy=copy + ) print("Scale step is finished in adata.X") return result diff --git a/stlearn/preprocessing/normalize.py b/stlearn/preprocessing/normalize.py index 10018d6a..376a2f04 100644 --- a/stlearn/preprocessing/normalize.py +++ b/stlearn/preprocessing/normalize.py @@ -5,6 +5,7 @@ import scanpy from anndata import AnnData + def normalize_total( adata: AnnData, target_sum: float | None = None, diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index 71a155a3..3be6dd0b 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -81,4 +81,4 @@ def local_level( if return_matrix: return stdm else: - return None \ No newline at end of file + return None diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index 1dec4db9..acd52725 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -102,4 +102,4 @@ def pseudotimespace_local( local_level(adata, use_label=use_label, cluster=cluster, w=w) - return adata \ No newline at end of file + return adata diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 05972dbb..27bcbf5d 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -8,6 +8,7 @@ import scanpy from louvain.VertexPartition import MutableVertexPartition + def louvain( adata: AnnData, resolution: float | None = None, diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index a980ae9d..ac8fee7a 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -11,6 +11,7 @@ from tqdm import tqdm from stlearn.pl import het_plot + def get_hotspots( adata: AnnData, lr_scores: np.ndarray, diff --git a/stlearn/utils.py b/stlearn/utils.py index a4531ea0..a9b5cb38 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -37,7 +37,6 @@ def _check_spot_size(spatial_data: Mapping | None, spot_size: float | None) -> f return spatial_data["scalefactors"]["spot_diameter_fullres"] - def _check_scale_factor( spatial_data: Mapping | None, img_key: str | None, @@ -118,7 +117,12 @@ def _check_img( if img_key is _empty: # Looks for image - or None if not found. new_img_key = next( - (k for k in ["hires", "lowres", "fulres"] if k in spatial_data["images"]), None + ( + k + for k in ["hires", "lowres", "fulres"] + if k in spatial_data["images"] + ), + None, ) else: new_img_key = img_key @@ -133,7 +137,7 @@ def _check_img( def _check_coords( - obsm: Mapping | None, scale_factor: float | None + obsm: Mapping | None, scale_factor: float | None ) -> tuple[np.ndarray, np.ndarray]: if obsm is None: raise ValueError("obsm cannot be None") From f2ed7fd76ed8a521764fbe839cbd76d936a603e2 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 14:56:32 +1000 Subject: [PATCH 027/123] Fix style checks. --- stlearn/_settings.py | 8 +- stlearn/adds/add_mask.py | 1 + stlearn/adds/parsing.py | 1 - stlearn/app/app.py | 14 +--- stlearn/app/cli.py | 1 + stlearn/app/source/forms/views.py | 4 +- stlearn/em.py | 8 +- stlearn/logging.py | 83 ++++++++++--------- stlearn/pl.py | 31 +++---- stlearn/plotting/cci_plot.py | 7 +- stlearn/plotting/cci_plot_helpers.py | 7 +- stlearn/plotting/classes.py | 23 +++-- stlearn/plotting/cluster_plot.py | 5 +- stlearn/plotting/feat_plot.py | 5 +- stlearn/plotting/gene_plot.py | 8 +- stlearn/plotting/trajectory/tree_plot.py | 3 +- .../plotting/trajectory/tree_plot_simple.py | 3 +- stlearn/spatials/trajectory/local_level.py | 3 +- stlearn/spatials/trajectory/utils.py | 2 - stlearn/tools/clustering/louvain.py | 4 +- stlearn/tools/microenv/cci/base_grouping.py | 1 + stlearn/tools/microenv/cci/het.py | 4 +- stlearn/utils.py | 6 +- stlearn/wrapper/read.py | 3 +- 24 files changed, 113 insertions(+), 122 deletions(-) diff --git a/stlearn/_settings.py b/stlearn/_settings.py index 5e0c28c3..9e75a8d4 100644 --- a/stlearn/_settings.py +++ b/stlearn/_settings.py @@ -1,12 +1,12 @@ import inspect import sys -from collections.abc import Iterable -from contextlib import AbstractContextManager, contextmanager +from collections.abc import Iterable, Iterator +from contextlib import contextmanager from enum import IntEnum from logging import getLevelName from pathlib import Path from time import time -from typing import Any, TextIO, Iterator, Literal +from typing import Any, Literal, TextIO from . import logging from .logging import _RootLogger, _set_log_file, _set_log_level @@ -363,7 +363,7 @@ def logfile(self, logfile: str | Path | TextIO | None): if logfile is None or logfile == "": self._logfile = sys.stdout if self._is_run_from_ipython() else sys.stderr self._logpath = None - elif isinstance(logfile, (str, Path)): + elif isinstance(logfile, (str | Path)): path = Path(logfile) self._logfile = path.open("a") self._logpath = path diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 680885f8..998b4936 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -105,6 +105,7 @@ def apply_mask( Array format of image, saving by Pillow package. """ from scanpy.plotting import palettes + from stlearn.plotting import palettes_st adata = adata.copy() if copy else adata diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index 4b824c59..0ae6a9f0 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -1,5 +1,4 @@ from os import PathLike -from pathlib import Path import numpy as np from anndata import AnnData diff --git a/stlearn/app/app.py b/stlearn/app/app.py index 7343ceeb..1e393468 100644 --- a/stlearn/app/app.py +++ b/stlearn/app/app.py @@ -1,17 +1,9 @@ import os -import subprocess import sys from threading import Thread sys.path.append(os.path.dirname(__file__)) -try: - import flask -except ImportError: - subprocess.call( - "pip install -r " + os.path.dirname(__file__) + "//requirements.txt", shell=True - ) - import asyncio import tempfile @@ -32,14 +24,14 @@ send_file, url_for, ) - -# Functions related to processing the forms. -from stlearn.app.source.forms import views # for changing data in response to input from tornado.ioloop import IOLoop from werkzeug.utils import secure_filename import stlearn +# Functions related to processing the forms. +from stlearn.app.source.forms import views # for changing data in response to input + # Global variables. global adata # Storing the data diff --git a/stlearn/app/cli.py b/stlearn/app/cli.py index 4d66f843..78bfe02b 100644 --- a/stlearn/app/cli.py +++ b/stlearn/app/cli.py @@ -1,4 +1,5 @@ import errno + import click from .. import __version__ diff --git a/stlearn/app/source/forms/views.py b/stlearn/app/source/forms/views.py index 30a2c672..3dbeed18 100644 --- a/stlearn/app/source/forms/views.py +++ b/stlearn/app/source/forms/views.py @@ -10,12 +10,12 @@ import numpy as np import scanpy as sc from flask import flash, render_template + +import stlearn as st import stlearn.app.source.forms.view_helpers as vhs from stlearn.app.source.forms import forms from stlearn.app.source.forms.utils import flash_errors -import stlearn as st - # Creating the forms using a class generator # PreprocessForm = forms.getPreprocessForm() # CCIForm = forms.getCCIForm() #OLD diff --git a/stlearn/em.py b/stlearn/em.py index 5ba9551f..39d0c2db 100644 --- a/stlearn/em.py +++ b/stlearn/em.py @@ -1,11 +1,11 @@ # from .embedding.scvi import run_ldvae -from .embedding.pca import run_pca -from .embedding.umap import run_umap -from .embedding.ica import run_ica +from .embedding.diffmap import run_diffmap # from .embedding.scvi import run_ldvae from .embedding.fa import run_fa -from .embedding.diffmap import run_diffmap +from .embedding.ica import run_ica +from .embedding.pca import run_pca +from .embedding.umap import run_umap __all__ = [ "run_pca", diff --git a/stlearn/logging.py b/stlearn/logging.py index 985ee5e8..be37ad1f 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -1,10 +1,11 @@ """Logging and Profiling""" import logging +from collections.abc import Mapping from datetime import datetime, timedelta, timezone from functools import partial, update_wrapper from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING -from typing import Dict, Any, Optional, overload, Mapping, Union +from typing import Any, overload import anndata.logging @@ -13,12 +14,13 @@ class CustomLogRecord(logging.LogRecord): - """Custom root logger that maintains compatibility with standard logging interface.""" + """Custom root logger that maintains compatibility with standard logging + interface.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.time_passed: Optional[timedelta] = None - self.deep: Optional[str] = None + self.time_passed: timedelta | None = None + self.deep: str | None = None class _RootLogger(logging.RootLogger): @@ -39,7 +41,7 @@ def log_with_timing( from . import settings now = datetime.now(timezone.utc) - time_passed: Optional[timedelta] = None if time is None else now - time + time_passed: timedelta | None = None if time is None else now - time extra = { **(extra or {}), "deep": deep if settings.verbosity.level < level else None, @@ -50,8 +52,9 @@ def log_with_timing( def _handle_enhanced_logging( self, level: int, msg, *args, **kwargs - ) -> Optional[datetime]: - """Handle logging with enhanced features (timing, deep info) or fall back to standard logging.""" + ) -> datetime | None: + """Handle logging with enhanced features (timing, deep info) or fall back to + standard logging.""" if "time" in kwargs or "deep" in kwargs or "extra" in kwargs: # Extract enhanced arguments time_arg = kwargs.pop("time", None) @@ -75,9 +78,9 @@ def hint( self, msg, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: return self.log_with_timing(HINT, msg, time=time, deep=deep, extra=extra) @@ -86,16 +89,16 @@ def debug( self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, + exc_info: bool | tuple | BaseException | None = None, stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None, + extra: Mapping[str, object] | None = None, ) -> None: ... @overload def debug(self, msg, *args, **kwargs): ... - def debug(self, msg, *args, **kwargs) -> Optional[datetime]: + def debug(self, msg, *args, **kwargs) -> datetime | None: return self._handle_enhanced_logging(DEBUG, msg, *args, **kwargs) @overload @@ -103,16 +106,16 @@ def info( self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, + exc_info: bool | tuple | BaseException | None = None, stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None, + extra: Mapping[str, object] | None = None, ) -> None: ... @overload def info(self, msg, *args, **kwargs): ... - def info(self, msg, *args, **kwargs) -> Optional[datetime]: + def info(self, msg, *args, **kwargs) -> datetime | None: return self._handle_enhanced_logging(INFO, msg, *args, **kwargs) @overload @@ -120,16 +123,16 @@ def warning( self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, + exc_info: bool | tuple | BaseException | None = None, stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None, + extra: Mapping[str, object] | None = None, ) -> None: ... @overload def warning(self, msg, *args, **kwargs): ... - def warning(self, msg, *args, **kwargs) -> Optional[datetime]: + def warning(self, msg, *args, **kwargs) -> datetime | None: return self._handle_enhanced_logging(WARNING, msg, *args, **kwargs) @overload @@ -137,16 +140,16 @@ def error( self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, + exc_info: bool | tuple | BaseException | None = None, stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None, + extra: Mapping[str, object] | None = None, ) -> None: ... @overload def error(self, msg, *args, **kwargs): ... - def error(self, msg, *args, **kwargs) -> Optional[datetime]: + def error(self, msg, *args, **kwargs) -> datetime | None: return self._handle_enhanced_logging(ERROR, msg, *args, **kwargs) @overload @@ -154,16 +157,16 @@ def critical( self, msg: object, *args: object, - exc_info: Union[bool, tuple, BaseException, None] = None, + exc_info: bool | tuple | BaseException | None = None, stack_info: bool = False, stacklevel: int = 1, - extra: Optional[Mapping[str, object]] = None, + extra: Mapping[str, object] | None = None, ) -> None: ... @overload def critical(self, msg, *args, **kwargs): ... - def critical(self, msg, *args, **kwargs) -> Optional[datetime]: + def critical(self, msg, *args, **kwargs) -> datetime | None: return self._handle_enhanced_logging(CRITICAL, msg, *args, **kwargs) @@ -290,9 +293,9 @@ def _copy_docs_and_signature(fn): def error( msg: str, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: """\ Log message with specific level and return current time. @@ -322,9 +325,9 @@ def error( def warning( msg: str, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: from ._settings import settings @@ -336,9 +339,9 @@ def warning( def info( msg: str, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: from ._settings import settings @@ -350,9 +353,9 @@ def info( def hint( msg: str, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: from ._settings import settings @@ -363,9 +366,9 @@ def hint( def debug( msg: str, *, - time: Optional[datetime] = None, - deep: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, + time: datetime | None = None, + deep: str | None = None, + extra: dict[str, Any] | None = None, ) -> datetime: from ._settings import settings diff --git a/stlearn/pl.py b/stlearn/pl.py index 9ef4f5d5..78db0c8a 100644 --- a/stlearn/pl.py +++ b/stlearn/pl.py @@ -1,28 +1,31 @@ # from .plotting.cci_plot import het_plot_interactive from .plotting import trajectory -from .plotting.QC_plot import QC_plot + +# from .plotting.cci_plot import het_plot_interactive from .plotting.cci_plot import ( - ccinet_plot, + cci_check, cci_map, + ccinet_plot, + grid_plot, + het_plot, lr_cci_map, lr_chord_plot, - cci_check, + lr_diagnostics, + lr_go, + lr_n_spots, + lr_plot, + lr_plot_interactive, + lr_result_plot, + lr_summary, + spatialcci_plot_interactive, ) -from .plotting.cci_plot import grid_plot -from .plotting.cci_plot import het_plot -from .plotting.cci_plot import lr_diagnostics, lr_n_spots, lr_summary, lr_go -from .plotting.cci_plot import lr_plot, lr_result_plot - -# from .plotting.cci_plot import het_plot_interactive -from .plotting.cci_plot import lr_plot_interactive, spatialcci_plot_interactive -from .plotting.cluster_plot import cluster_plot -from .plotting.cluster_plot import cluster_plot_interactive +from .plotting.cluster_plot import cluster_plot, cluster_plot_interactive from .plotting.deconvolution_plot import deconvolution_plot from .plotting.feat_plot import feat_plot -from .plotting.gene_plot import gene_plot -from .plotting.gene_plot import gene_plot_interactive +from .plotting.gene_plot import gene_plot, gene_plot_interactive from .plotting.mask_plot import plot_mask from .plotting.non_spatial_plot import non_spatial_plot +from .plotting.QC_plot import QC_plot from .plotting.stack_3d_plot import stack_3d_plot from .plotting.subcluster_plot import subcluster_plot diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index df983f75..0f8b0d5d 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -3,8 +3,7 @@ import sys from typing import ( Any, - Optional, - Tuple, # Special + Optional, # Special ) import matplotlib @@ -434,7 +433,7 @@ def lr_result_plot( show_axis: bool = False, show_image: bool = True, show_color_bar: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 7, @@ -904,7 +903,7 @@ def het_plot( show_axis: bool = False, show_image: bool = True, show_color_bar: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 7, diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index de4b243a..b94f147f 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,6 +1,5 @@ """Helper functions for cci_plot.py.""" -from typing import List, Tuple, Optional import matplotlib import matplotlib.cm as cm @@ -275,7 +274,7 @@ def add_arrows( interact_bool = int_df.values > 0 # Subsetting to only significant CCI # - edges_sub: List[List[Tuple[str, str]]] = [[], []] # forward, reverse + edges_sub: list[list[tuple[str, str]]] = [[], []] # forward, reverse # ints_2 = np.zeros(int_df.shape) # Just for debugging make sure edge # list re-capitulates edge-counts. for i, edges in enumerate([forward_edges, reverse_edges]): @@ -301,7 +300,7 @@ def add_arrows( # If cmap specified, colour arrows by average LR expression on edge # if arrow_cmap is not None: - edges_means: List[List[float]] = [[], []] + edges_means: list[list[float]] = [[], []] all_means = [] for i, edges in enumerate([forward_edges, reverse_edges]): for j, edge in enumerate(edges): @@ -322,7 +321,7 @@ def add_arrows( scalar_map = cm.ScalarMappable(norm=c_norm, cmap=cmap) # Determining the edge colors # - edges_colors: List[List[Tuple[float, float, float, float]]] = [[], []] + edges_colors: list[list[tuple[float, float, float, float]]] = [[], []] for i, edges in enumerate([forward_edges, reverse_edges]): for j, edge in enumerate(edges): color_val = scalar_map.to_rgba(edges_means[i][j]) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 097c3e81..d40fcb2d 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -7,8 +7,7 @@ import numbers import warnings from typing import ( # Special - Optional, - Tuple, # Classes + Optional, # Classes ) import matplotlib @@ -19,9 +18,9 @@ from anndata import AnnData from scipy.interpolate import griddata -from .utils import centroidpython, check_sublist, get_cluster, get_cmap, get_node from ..classes import Spatial from ..utils import Axes, _AxesSubplot, _read_graph +from .utils import centroidpython, check_sublist, get_cluster, get_cmap, get_node class SpatialBasePlot(Spatial): @@ -41,7 +40,7 @@ def __init__( show_image: bool = True, show_color_bar: bool = True, color_bar_label: str = "", - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 7, @@ -183,7 +182,7 @@ def _crop_image(self, main_ax: _AxesSubplot, margin: float): main_ax.set_ylim(main_ax.get_ylim()[::-1]) def _zoom_image( - self, main_ax: _AxesSubplot, zoom_coord: Tuple[float, float, float, float] + self, main_ax: _AxesSubplot, zoom_coord: tuple[float, float, float, float] ): main_ax.set_xlim(zoom_coord[0], zoom_coord[1]) @@ -233,7 +232,7 @@ def __init__( adata: AnnData, # plotting param title: str | None = None, - figsize: Tuple[float, float] | None = None, + figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, @@ -245,7 +244,7 @@ def __init__( show_color_bar: bool = True, color_bar_label: str = "", crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 7, image_alpha: float = 1.0, @@ -458,7 +457,7 @@ def __init__( show_color_bar: bool = True, color_bar_label: str = "", crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 7, image_alpha: float = 1.0, @@ -621,7 +620,7 @@ def __init__( show_image: bool = True, show_color_bar: bool = True, crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 5, image_alpha: float = 1.0, @@ -973,7 +972,7 @@ def __init__( show_image: bool = True, show_color_bar: bool = True, crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 5, image_alpha: float = 1.0, @@ -1127,7 +1126,7 @@ def __init__( show_image: bool = True, show_color_bar: bool = True, crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 7, image_alpha: float = 1.0, @@ -1197,7 +1196,7 @@ def __init__( show_image: bool = True, show_color_bar: bool = True, crop: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, margin: float = 100, size: float = 7, image_alpha: float = 1.0, diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index e2f4e47e..2eb6a66c 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -1,6 +1,5 @@ from typing import ( - Optional, - Tuple, # Special + Optional, # Special ) import matplotlib @@ -29,7 +28,7 @@ def cluster_plot( show_axis: bool = False, show_image: bool = True, show_color_bar: bool = True, - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 5, diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index 092b7ff4..e47450e9 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -3,8 +3,7 @@ """ from typing import ( - Optional, - Tuple, # Special + Optional, # Special ) import matplotlib @@ -32,7 +31,7 @@ def feat_plot( show_image: bool = True, show_color_bar: bool = True, color_bar_label: str = "", - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 7, diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index 03489251..f7f770e0 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,7 +1,3 @@ -from typing import ( # Special - Optional, - Tuple, # Classes -) import matplotlib from anndata import AnnData @@ -23,7 +19,7 @@ def gene_plot( contour: bool = False, step_size: int | None = None, title: str | None = None, - figsize: Tuple[float, float] | None = None, + figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, list_clusters: list | None = None, @@ -34,7 +30,7 @@ def gene_plot( show_image: bool = True, show_color_bar: bool = True, color_bar_label: str = "", - zoom_coord: Tuple[float, float, float, float] | None = None, + zoom_coord: tuple[float, float, float, float] | None = None, crop: bool = True, margin: float = 100, size: float = 7, diff --git a/stlearn/plotting/trajectory/tree_plot.py b/stlearn/plotting/trajectory/tree_plot.py index 88583991..ce753128 100644 --- a/stlearn/plotting/trajectory/tree_plot.py +++ b/stlearn/plotting/trajectory/tree_plot.py @@ -1,6 +1,5 @@ import math import random -from typing import Tuple import networkx as nx from anndata import AnnData @@ -12,7 +11,7 @@ def tree_plot( adata: AnnData, library_id: str | None = None, - figsize: Tuple[float, float] = (10, 4), + figsize: tuple[float, float] = (10, 4), data_alpha: float = 1.0, use_label: str = "louvain", spot_size: float | int = 50, diff --git a/stlearn/plotting/trajectory/tree_plot_simple.py b/stlearn/plotting/trajectory/tree_plot_simple.py index 03f1b7bd..0dd9902c 100644 --- a/stlearn/plotting/trajectory/tree_plot_simple.py +++ b/stlearn/plotting/trajectory/tree_plot_simple.py @@ -1,6 +1,5 @@ import math import random -from typing import Tuple import networkx as nx from anndata import AnnData @@ -12,7 +11,7 @@ def tree_plot_simple( adata: AnnData, library_id: str | None = None, - figsize: Tuple[float, float] = (10, 4), + figsize: tuple[float, float] = (10, 4), data_alpha: float = 1.0, use_label: str = "louvain", spot_size: float | int = 50, diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index 3be6dd0b..a0490b88 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -30,7 +30,8 @@ def local_level( Return PTS matrix for local level Returns ------- - np.ndarray: the STDM (spatio-temporal distance matrix) - weighted combination of spatial and temporal distances. + np.ndarray: the STDM (spatio-temporal distance matrix) - weighted combination of + spatial and temporal distances. adata["nonabs_dpt_distance_matrix"]: np.ndarray Pseudotime distance (difference between values) matrix diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index f4328f50..46d700fb 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -1,4 +1,3 @@ -from collections.abc import Sequence import networkx as nx import numpy as np @@ -575,7 +574,6 @@ def _correlation_test_helper( ) n = X.shape[1] # genes x cells - ql = 1 - confidence_level - (1 - confidence_level) / 2.0 qh = confidence_level + (1 - confidence_level) / 2.0 if issparse(X) and not isspmatrix_csr(X): diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 27bcbf5d..e0426662 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -2,11 +2,11 @@ from types import MappingProxyType from typing import Any, Literal +import scanpy from anndata import AnnData +from louvain.VertexPartition import MutableVertexPartition from numpy.random.mtrand import RandomState from scipy.sparse import spmatrix -import scanpy -from louvain.VertexPartition import MutableVertexPartition def louvain( diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index ac8fee7a..24201a71 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -9,6 +9,7 @@ from anndata import AnnData from sklearn.cluster import DBSCAN, AgglomerativeClustering from tqdm import tqdm + from stlearn.pl import het_plot diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index 60e6e1bb..8ed58b79 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -1,4 +1,4 @@ -from typing import Iterable +from collections.abc import Iterable import numpy as np import pandas as pd @@ -416,7 +416,7 @@ def create_grids(adata: AnnData, num_row: int, num_col: int, radius: int = 1): grids, neighbours = [], [] # generate grids from top to bottom and left to right for n in range(num_row * num_col): - neighbour: Iterable[float] + neighbour: Iterable[float] = [] x = min_x + n // num_row * width # left side y = min_y + n % num_row * height # upper side grids.append([x, y]) diff --git a/stlearn/utils.py b/stlearn/utils.py index a9b5cb38..fb03fcf1 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -88,7 +88,8 @@ def _check_img( Parameters ---------- img : np.ndarray | None - If given an image will not look for another image and not check to see if it was in spatial_data. + If given an image will not look for another image and not check to see if it + was in spatial_data. img_key : None | str | Empty If None - don't find an image. Empty - find best image, or specify with str. @@ -108,7 +109,8 @@ def _check_img( new_img_key: str | None = None new_img: np.ndarray | None = None - # Return the img if not None and convert the key to Empty -> None if Empty otherwise keep. + # Return the img if not None and convert the key to Empty -> None if Empty + # otherwise keep. if img is not None: new_img = img new_img_key = img_key if img_key is not _empty else None diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 291dd138..a3faac59 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -2,9 +2,10 @@ import json import logging as logg +from collections.abc import Iterator from os import PathLike from pathlib import Path -from typing import Iterator, Literal +from typing import Literal import matplotlib.pyplot as plt import numpy as np From eb12b699f226de3b99b603e911964896ebe61e0c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 15:00:04 +1000 Subject: [PATCH 028/123] Reformat. --- stlearn/__main__.py | 1 - stlearn/adds/add_mask.py | 4 ++- stlearn/app/app.py | 2 -- stlearn/app/source/forms/form_validators.py | 1 - stlearn/app/source/forms/utils.py | 1 - stlearn/app/source/forms/views.py | 1 - stlearn/classes.py | 1 - stlearn/logging.py | 2 +- stlearn/plotting/cci_plot.py | 4 +-- stlearn/plotting/cci_plot_helpers.py | 4 +-- stlearn/plotting/classes.py | 29 +++---------------- stlearn/plotting/classes_bokeh.py | 9 ------ stlearn/plotting/gene_plot.py | 1 - stlearn/plotting/non_spatial_plot.py | 1 - stlearn/plotting/stack_3d_plot.py | 6 ++-- stlearn/plotting/subcluster_plot.py | 6 ++-- .../plotting/trajectory/check_trajectory.py | 18 ++++++------ .../plotting/trajectory/pseudotime_plot.py | 3 -- stlearn/spatials/SME/_weighting_matrix.py | 1 - stlearn/spatials/clustering/localization.py | 1 - stlearn/spatials/smooth/disk.py | 1 - stlearn/spatials/trajectory/utils.py | 3 +- stlearn/tools/microenv/cci/analysis.py | 16 +++++----- stlearn/tools/microenv/cci/base.py | 2 +- stlearn/tools/microenv/cci/het.py | 1 - stlearn/tools/microenv/cci/het_helpers.py | 2 -- stlearn/tools/microenv/cci/perm_utils.py | 10 +++++-- stlearn/tools/microenv/cci/permutation.py | 3 +- stlearn/wrapper/concatenate_spatial_adata.py | 1 - stlearn/wrapper/convert_scanpy.py | 1 - stlearn/wrapper/read.py | 27 +++++++++-------- tests/test_PSTS.py | 1 - tests/test_SME.py | 1 - tox.ini | 2 +- 34 files changed, 59 insertions(+), 108 deletions(-) diff --git a/stlearn/__main__.py b/stlearn/__main__.py index 3802ae27..43559dfc 100644 --- a/stlearn/__main__.py +++ b/stlearn/__main__.py @@ -2,7 +2,6 @@ """Package entry point.""" - from stlearn.app import cli if __name__ == "__main__": # pragma: no cover diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 998b4936..84c24c4a 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -49,8 +49,10 @@ def add_mask( img = plt.imread(imgpath, 0) assert ( img.shape == adata.uns["spatial"][library_id]["images"][quality].shape - ), "\ + ), ( + "\ size of mask image does not match size of H&E images" + ) if "mask_image" not in adata.uns: adata.uns["mask_image"] = {} if library_id not in adata.uns["mask_image"]: diff --git a/stlearn/app/app.py b/stlearn/app/app.py index 1e393468..25964ae7 100644 --- a/stlearn/app/app.py +++ b/stlearn/app/app.py @@ -154,7 +154,6 @@ def folder_uploader(): uploaded = [] i = 0 for file in files: - filename = secure_filename(file.filename) if allow_files[0] in filename: @@ -226,7 +225,6 @@ def folder_uploader(): @app.route("/file_uploader", methods=["GET", "POST"]) def file_uploader(): if request.method == "POST": - global adata, step_log # Clean uploads folder before upload a new data diff --git a/stlearn/app/source/forms/form_validators.py b/stlearn/app/source/forms/form_validators.py index 1d6f2672..4a279164 100644 --- a/stlearn/app/source/forms/form_validators.py +++ b/stlearn/app/source/forms/form_validators.py @@ -10,7 +10,6 @@ def __init__(self, lower, upper, hint=""): self.hint = hint def __call__(self, form, field): - if field.data is not None: if not (self.lower <= float(field.data) <= self.upper): if self.hint: diff --git a/stlearn/app/source/forms/utils.py b/stlearn/app/source/forms/utils.py index 1d5d0c75..42121bcf 100644 --- a/stlearn/app/source/forms/utils.py +++ b/stlearn/app/source/forms/utils.py @@ -11,7 +11,6 @@ def flash_errors(form, category="warning"): def get_all_paths(adata): - import networkx as nx G = nx.from_numpy_array(adata.uns["paga"]["connectivities_tree"].toarray()) diff --git a/stlearn/app/source/forms/views.py b/stlearn/app/source/forms/views.py index 3dbeed18..3aa58bbb 100644 --- a/stlearn/app/source/forms/views.py +++ b/stlearn/app/source/forms/views.py @@ -367,7 +367,6 @@ def run_dea(request, adata, step_log): else: try: - sc.tl.rank_genes_groups(adata, element_values[0], method=element_values[1]) step_log["dea"][0] = True diff --git a/stlearn/classes.py b/stlearn/classes.py index f441661b..27e1322e 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -35,7 +35,6 @@ def __init__( use_raw: bool = False, **kwargs, ): - self.adata = (adata,) self.library_id, self.spatial_data = _check_spatial_data(adata.uns, library_id) self.img, self.img_key = _check_img(self.spatial_data, img, img_key, bw=bw) diff --git a/stlearn/logging.py b/stlearn/logging.py index be37ad1f..6b4d0ce8 100644 --- a/stlearn/logging.py +++ b/stlearn/logging.py @@ -280,7 +280,7 @@ def print_version_and_date(): from ._settings import settings print( - f"Running Scanpy {__version__}, " f"on {datetime.now():%Y-%m-%d %H:%M}.", + f"Running Scanpy {__version__}, on {datetime.now():%Y-%m-%d %H:%M}.", file=settings.logfile, ) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 0f8b0d5d..1e544486 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -667,7 +667,7 @@ def lr_plot( elif sig_spots and not lr_sig: raise Exception( - "LR has no significant spots, to visualise anyhow set" "sig_spots=False" + "LR has no significant spots, to visualise anyhow setsig_spots=False" ) # Making sure have run_cci first with respective labelling # @@ -713,7 +713,7 @@ def lr_plot( and use_label not in lr_use_labels ): raise Exception( - f"use_label must be in adata.obs or " f"one of lr stats: {lr_use_labels}." + f"use_label must be in adata.obs or one of lr stats: {lr_use_labels}." ) out_options = ["binary", "continuous", None] diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index b94f147f..4706b3a2 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -1,6 +1,5 @@ """Helper functions for cci_plot.py.""" - import matplotlib import matplotlib.cm as cm import matplotlib.colors as plt_colors @@ -48,7 +47,7 @@ def lr_scatter( lr_features = data.uns["lrfeatures"] lr_df = pd.concat([lr_df, lr_features], axis=1).loc[lrs, :] if feature not in lr_df.columns: - raise Exception(f"Inputted {feature}; must be one of " f"{list(lr_df.columns)}") + raise Exception(f"Inputted {feature}; must be one of {list(lr_df.columns)}") rot = 90 if feature != "n_spots_sig" else 70 @@ -426,7 +425,6 @@ def get_int_df(adata, lr, use_label, sig_interactions, title): )[labels_ordered].loc[labels_ordered] title = "Cell-Cell LR Interactions" if no_title else title else: - labels_ordered = adata.obs[use_label].cat.categories int_df = ( adata.uns[f"per_lr_cci_{use_label}"][lr] diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index d40fcb2d..4772f2a6 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -71,14 +71,12 @@ def __init__( assert use_label is not None, "Please specify `use_label` parameter!" if use_label is not None: - - assert ( - use_label in self.adata[0].obs.columns - ), "Please choose the right label in `adata.obs.columns`!" + assert use_label in self.adata[0].obs.columns, ( + "Please choose the right label in `adata.obs.columns`!" + ) self.use_label = use_label if self.list_clusters is None: - self.list_clusters = np.array( self.adata[0].obs[use_label].cat.categories ) @@ -166,7 +164,6 @@ def _add_image(self, main_ax: Axes): ) def _plot_colorbar(self, plot_ax: Axes, color_bar_label: str = ""): - cb = plt.colorbar( plot_ax, aspect=10, shrink=0.5, cmap=self.cmap, label=color_bar_label ) @@ -176,7 +173,6 @@ def _remove_axis(self, main_ax: Axes): main_ax.axis("off") def _crop_image(self, main_ax: _AxesSubplot, margin: float): - main_ax.set_xlim(self.imagecol.min() - margin, self.imagecol.max() + margin) main_ax.set_ylim(self.imagerow.min() - margin, self.imagerow.max() + margin) main_ax.set_ylim(main_ax.get_ylim()[::-1]) @@ -184,7 +180,6 @@ def _crop_image(self, main_ax: _AxesSubplot, margin: float): def _zoom_image( self, main_ax: _AxesSubplot, zoom_coord: tuple[float, float, float, float] ): - main_ax.set_xlim(zoom_coord[0], zoom_coord[1]) main_ax.set_ylim(zoom_coord[3], zoom_coord[2]) @@ -211,7 +206,6 @@ def _get_query_clusters_index(self): return index_query def _save_output(self): - self.fig.savefig( fname=self.fname, bbox_inches="tight", pad_inches=0, dpi=self.dpi ) @@ -324,13 +318,11 @@ def __init__( self._save_output() def _get_gene_expression(self): - # Gene plot option if len(self.gene_symbols) == 0: raise ValueError("Genes should be provided, please input genes") elif len(self.gene_symbols) == 1: - if self.gene_symbols[0] not in self.query_adata.var_names: raise ValueError( self.gene_symbols[0] @@ -341,7 +333,6 @@ def _get_gene_expression(self): return colors else: - for gene in self.gene_symbols: if gene not in self.query_adata.var.index: self.gene_symbols.remove(gene) @@ -371,7 +362,6 @@ def _get_gene_expression(self): return colors def _plot_genes(self, gene_values: pd.Series): - if self.vmin is None and self.vmax is None: vmin = min(gene_values) vmax = max(gene_values) @@ -396,7 +386,6 @@ def _plot_genes(self, gene_values: pd.Series): return plot def _plot_contour(self, gene_values: pd.Series): - imgcol_new = self.query_adata.obsm["spatial"][:, 0] * self.scale_factor imgrow_new = self.query_adata.obsm["spatial"][:, 1] * self.scale_factor # Extracting x,y and values (z) @@ -523,7 +512,6 @@ def __init__( self._save_output() def _get_feature_values(self): - if self.feature not in self.query_adata.obs: raise ValueError( self.feature + " is not in data.obs, please try another feature" @@ -541,7 +529,6 @@ def _get_feature_values(self): return colors def _plot_feature(self, feature_values: pd.Series): - if self.vmin is None and self.vmax is None: vmin = min(feature_values) vmax = max(feature_values) @@ -566,7 +553,6 @@ def _plot_feature(self, feature_values: pd.Series): return plot def _plot_contour(self, feature_values: pd.Series): - imgcol_new = self.query_adata.obsm["spatial"][:, 0] * self.scale_factor imgrow_new = self.query_adata.obsm["spatial"][:, 1] * self.scale_factor # Extracting x,y and values (z) @@ -715,7 +701,6 @@ def _plot_clusters(self): # Plot scatter plot based on pixel of spots for i, cluster in enumerate(self.query_adata.obs.groupby(self.use_label)): - # Plot scatter plot based on pixel of spots subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), list(cluster[1].index)) @@ -761,9 +746,7 @@ def _add_cluster_bar(self, bbox_to_anchor): handle.set_sizes([20.0]) def _add_cluster_labels(self): - for i, label in enumerate(self.list_clusters): - label_index = list( self.query_adata.obs[ self.query_adata.obs[self.use_label] == str(label) @@ -802,7 +785,6 @@ def _add_cluster_labels(self): ) def _add_sub_clusters(self): - if "sub_cluster_labels" not in self.query_adata.obs.columns: raise ValueError("Please run stlearn.spatial.cluster.localization") @@ -926,7 +908,6 @@ def _add_trajectories(self): if self.show_node: for x, y in centroid_dict.items(): - if x in get_node(self.list_clusters, self.adata[0].uns["split_node"]): self.ax.text( y[0], @@ -1221,9 +1202,7 @@ def __init__( if use_lr is None: use_lr = adata.uns["lr_summary"].index.values[0] elif use_lr not in adata.uns["lr_summary"].index: - raise Exception( - f"use_lr must be one of:\n" f'{adata.uns["lr_summary"].index}' - ) + raise Exception(f"use_lr must be one of:\n{adata.uns['lr_summary'].index}") else: use_lr = str(use_lr) diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/plotting/classes_bokeh.py index 9b9e8ee9..54a69c0b 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/plotting/classes_bokeh.py @@ -136,7 +136,6 @@ def __init__( # self.tab = Tabs(tabs = [Panel(child=self.layout, title="Gene plot")]) def modify_fig(doc): - doc.add_root(row(self.layout, width=800)) self.data_alpha.on_change("value", self.update_data) @@ -153,7 +152,6 @@ def modify_fig(doc): self.app = Application(handler) def make_fig(self): - fig = figure( title=self.gene_select.value, x_range=(0, self.dim), @@ -269,7 +267,6 @@ def add_violin(self): return p def update_data(self, attrname, old, new): - if len(self.menu) != 0: self.layout.children[0].children[1] = self.make_fig() self.layout.children[1] = self.add_violin() @@ -277,7 +274,6 @@ def update_data(self, attrname, old, new): self.layout.children[1] = self.make_fig() def _get_gene_expression(self, gene_symbols): - if gene_symbols[0] not in self.adata[0].var_names: raise ValueError( gene_symbols[0] + " is not exist in the data, please try another gene" @@ -508,7 +504,6 @@ def modify_fig(doc): self.app = Application(handler) def update_list(self, attrname, old, name): - # Initialize the color from stlearn.plotting.cluster_plot import cluster_plot @@ -521,7 +516,6 @@ def update_list(self, attrname, old, name): ) def update_data(self, attrname, old, new): - if "rank_genes_groups" in self.adata[0].uns: if ( self.use_label.value @@ -862,7 +856,6 @@ def modify_fig(doc): self.app = Application(handler) def make_fig(self): - fig = figure( title=self.lr_select.value, # self.het_select.value, x_range=(0, self.dim - 150), @@ -929,7 +922,6 @@ def update_data(self, attrname, old, new): self.layout.children[1] = self.make_fig() def _get_het(self, het): - if het not in self.adata[0].obsm: raise ValueError(het + " is not exist in the data, please try another het") @@ -1058,7 +1050,6 @@ def modify_fig(doc): self.app = Application(handler) def make_fig(self): - fig = figure( title="Spatial CCI plot", x_range=(0, self.dim - 150), diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index f7f770e0..cca713d0 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -1,4 +1,3 @@ - import matplotlib from anndata import AnnData from bokeh.io import output_notebook diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/plotting/non_spatial_plot.py index 800d5779..dcdf2307 100644 --- a/stlearn/plotting/non_spatial_plot.py +++ b/stlearn/plotting/non_spatial_plot.py @@ -45,7 +45,6 @@ def non_spatial_plot( scanpy.pl.draw_graph(adata, color="dpt_pseudotime") else: - scanpy.pl.draw_graph(adata) # adata.uns[use_label+"_colors"] = adata.uns["tmp_color"] diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index c958575a..0bc23896 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -43,9 +43,9 @@ def stack_3d_plot( except ModuleNotFoundError: raise ModuleNotFoundError("Please install plotly by `pip install plotly`") - assert ( - slide_col in adata.obs.columns - ), "Please provide the right column for slide_id!" + assert slide_col in adata.obs.columns, ( + "Please provide the right column for slide_id!" + ) list_df = [] for i, slide in enumerate(slides): diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index 0e8c66e5..1763c716 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -58,9 +58,9 @@ def subcluster_plot( """ assert use_label is not None, "Please select `use_label` parameter" - assert ( - use_label in adata.obs.columns - ), "Please run `stlearn.spatial.cluster.localization` function!" + assert use_label in adata.obs.columns, ( + "Please run `stlearn.spatial.cluster.localization` function!" + ) SubClusterPlot( adata, diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/plotting/trajectory/check_trajectory.py index 587c20e9..2699d2a0 100644 --- a/stlearn/plotting/trajectory/check_trajectory.py +++ b/stlearn/plotting/trajectory/check_trajectory.py @@ -17,16 +17,16 @@ def check_trajectory( img_key: str = "hires", ) -> None: trajectory = np.array(trajectory).astype(int) - assert ( - trajectory in adata.uns["available_paths"].values() - ), "Please choose the right path!" + assert trajectory in adata.uns["available_paths"].values(), ( + "Please choose the right path!" + ) trajectory_str = [str(node) for node in trajectory] - assert ( - pseudotime_key in adata.obs.columns - ), "Please run the pseudotime or choose the right one!" - assert ( - use_label in adata.obs.columns - ), "Please run the cluster or choose the right label!" + assert pseudotime_key in adata.obs.columns, ( + "Please run the pseudotime or choose the right one!" + ) + assert use_label in adata.obs.columns, ( + "Please run the cluster or choose the right label!" + ) assert basis in adata.obsm, ( "Please run the " + basis + "before you check the trajectory!" ) diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index b6d3a167..4ee2a115 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -153,7 +153,6 @@ def pseudotime_plot( ) for x, y in centroid_dict.items(): - if x in get_node(list_clusters, adata.uns["split_node"]): a.text( y[0], @@ -173,7 +172,6 @@ def pseudotime_plot( ) if show_trajectories: - used_colors = adata.uns[use_label + "_colors"] cmaps = matplotlib.colors.LinearSegmentedColormap.from_list("", used_colors) @@ -218,7 +216,6 @@ def pseudotime_plot( if show_node: for x, y in centroid_dict.items(): - if x in get_node(list_clusters, adata.uns["split_node"]): a.text( y[0], diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatials/SME/_weighting_matrix.py index 2ff23eb4..12848161 100644 --- a/stlearn/spatials/SME/_weighting_matrix.py +++ b/stlearn/spatials/SME/_weighting_matrix.py @@ -126,7 +126,6 @@ def impute_neighbour( bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(len(coor)): - main_weights = weights_matrix[i] if weights == "physical_distance": diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index efc1774f..1fd17129 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -44,7 +44,6 @@ def localization( pd.set_option("mode.chained_assignment", None) subclusters_list = [] for i in adata.obs[use_label].unique(): - tmp = adata.obs[adata.obs[use_label] == i] clustering = DBSCAN(eps=eps, min_samples=1, algorithm="kd_tree").fit( diff --git a/stlearn/spatials/smooth/disk.py b/stlearn/spatials/smooth/disk.py index 01ff2309..a259aee4 100644 --- a/stlearn/spatials/smooth/disk.py +++ b/stlearn/spatials/smooth/disk.py @@ -11,7 +11,6 @@ def disk( method: str = "mean", copy: bool = False, ) -> AnnData | None: - adata = adata.copy() if copy else adata coor = adata.obs[["imagecol", "imagerow"]] diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index 46d700fb..2490b9d7 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -1,4 +1,3 @@ - import networkx as nx import numpy as np from numpy import linalg as la @@ -359,7 +358,7 @@ def resistance_matrix(A, check_connected=True): G = nx.from_numpy_array(A) if not nx.is_connected(G): raise UndefinedException( - "Graph is not connected. " "Resistance matrix is undefined." + "Graph is not connected. Resistance matrix is undefined." ) L = laplacian_matrix(A) try: diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 1758f154..5318ac08 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -51,7 +51,7 @@ def load_lrs(names: str | list | None = None, species: str = "human") -> np.ndar dbs = [pd.read_csv(f"{path}/databases/{name}.txt", sep="\t") for name in names] lrs_full = [] for db in dbs: - lrs = [f"{db.values[i,0]}_{db.values[i,1]}" for i in range(db.shape[0])] + lrs = [f"{db.values[i, 0]}_{db.values[i, 1]}" for i in range(db.shape[0])] lrs_full.extend(lrs) lrs_full_arr = np.unique(np.array(lrs_full)) # If dealing with mouse, need to reformat # @@ -112,9 +112,10 @@ def grid( # Retrieving the coordinates of each grid # n_squares = n_row * n_col cell_bcs = adata.obs_names.values.astype(str) - xs, ys = adata.obs["imagecol"].values.astype(int), adata.obs[ - "imagerow" - ].values.astype(int) + xs, ys = ( + adata.obs["imagecol"].values.astype(int), + adata.obs["imagerow"].values.astype(int), + ) grid_counts, xedges, yedges = np.histogram2d(xs, ys, bins=[n_col, n_row]) grid_counts, xedges, yedges = ( @@ -484,7 +485,7 @@ def run_lr_go( # Making sure inputted correct species all_species = ["human", "mouse"] if species not in all_species: - raise Exception(f"Got {species} for species, must be one of " f"{all_species}") + raise Exception(f"Got {species} for species, must be one of {all_species}") # Getting the genes from the top LR pairs if "lr_summary" not in adata.uns: @@ -604,7 +605,7 @@ def run_cci( ran_sig = False if not ran_lr else "n_spots_sig" in adata.uns["lr_summary"].columns if not ran_lr and not ran_sig: raise Exception( - "No LR results testing results found, " "please run st.tl.cci.run first" + "No LR results testing results found, please run st.tl.cci.run first" ) # Ensuring compatibility with current way of adding label_transfer to object @@ -616,8 +617,7 @@ def run_cci( # Getting the cell/tissue types that we are actually testing # if obs_key not in adata.obs: raise Exception( - f"Missing {obs_key} from adata.obs, need this even if " - f"using mixture mode." + f"Missing {obs_key} from adata.obs, need this even if using mixture mode." ) tissue_types = adata.obs[obs_key].values.astype(str) all_set = np.unique(tissue_types) diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index c72b2db3..2b668728 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -181,7 +181,7 @@ def get_spot_lrs( l1, ... rn, ln """ df = adata.to_df() - pairs_rev = [f'{pair.split("_")[1]}_{pair.split("_")[0]}' for pair in lr_pairs] + pairs_rev = [f"{pair.split('_')[1]}_{pair.split('_')[0]}" for pair in lr_pairs] pairs_wRev = [] for i in range(len(lr_pairs)): pairs_wRev.extend([lr_pairs[i], pairs_rev[i]]) diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tools/microenv/cci/het.py index 8ed58b79..c558a441 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tools/microenv/cci/het.py @@ -356,7 +356,6 @@ def get_interactions( # Now retrieving the interaction edges # for i in range(all_set.shape[0]): - # Determining which spots have cell type A # A_bool_2 = cell_data[:, i] > cell_prop_cutoff A_gene1_bool = np.logical_and(A_bool_2, gene1_bool) diff --git a/stlearn/tools/microenv/cci/het_helpers.py b/stlearn/tools/microenv/cci/het_helpers.py index 3c8dc935..e5761f15 100644 --- a/stlearn/tools/microenv/cci/het_helpers.py +++ b/stlearn/tools/microenv/cci/het_helpers.py @@ -245,7 +245,6 @@ def get_neighbourhoods_FAST( neigh_bcs_array, neigh_indices = [], [] for j, neigh_bc in enumerate(neigh_bcs): - bc_indices = np.where(spot_bcs == neigh_bc)[0] if len(bc_indices) > 0: neigh_bcs_array.append(neigh_bc) @@ -285,7 +284,6 @@ def get_neighbourhoods(adata): neighbourhood_indices.append((spot_i, neighbours[spot_i])) neighbourhood_bcs.append((spot_bcs[spot_i], spot_bcs[neighbours[spot_i]])) else: # Newer version - spot_bcs = adata.obs_names.values.astype(str) spot_neigh_bcs = adata.obsm["spot_neigh_bcs"].values.astype(str) diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 63b048b8..869e5894 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -355,7 +355,10 @@ def get_lr_bg( l_, r_ = lr_.split("_") if l_ not in gene_bg_genes: l_genes = get_similar_genesFAST( - l_quant, n_genes, candidate_quants, genes # group_l_props, + l_quant, + n_genes, + candidate_quants, + genes, # group_l_props, ) gene_bg_genes[l_] = l_genes else: @@ -363,7 +366,10 @@ def get_lr_bg( if r_ not in gene_bg_genes: r_genes = get_similar_genesFAST( - r_quant, n_genes, candidate_quants, genes # group_r_props, + r_quant, + n_genes, + candidate_quants, + genes, # group_r_props, ) gene_bg_genes[r_] = r_genes else: diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 46491b0b..60bd9bd3 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -47,8 +47,7 @@ def perform_spot_testing( n_genes = round(np.sqrt(n_pairs) * 2) if len(genes) < n_genes: print( - "Exiting since need atleast " - f"{n_genes} genes to generate {n_pairs} pairs." + f"Exiting since need atleast {n_genes} genes to generate {n_pairs} pairs." ) return diff --git a/stlearn/wrapper/concatenate_spatial_adata.py b/stlearn/wrapper/concatenate_spatial_adata.py index cc9273d7..a1c8b8ce 100644 --- a/stlearn/wrapper/concatenate_spatial_adata.py +++ b/stlearn/wrapper/concatenate_spatial_adata.py @@ -15,7 +15,6 @@ def transform_spatial(coordinates, original, resized): def correct_size(adata, fixed_size): - image = adata.uns["spatial"][list(adata.uns["spatial"].keys())[0]]["images"][ "hires" ] diff --git a/stlearn/wrapper/convert_scanpy.py b/stlearn/wrapper/convert_scanpy.py index 94a74ded..aac9c6a9 100644 --- a/stlearn/wrapper/convert_scanpy.py +++ b/stlearn/wrapper/convert_scanpy.py @@ -5,7 +5,6 @@ def convert_scanpy( adata: AnnData, use_quality: str = "hires", ) -> AnnData | None: - adata.var_names_make_unique() library_id = list(adata.uns["spatial"].keys())[0] diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index a3faac59..409b45cc 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -121,8 +121,7 @@ def Read10X( if not f.exists(): if any(x in str(f) for x in ["hires_image", "lowres_image"]): logg.warning( - f"You seem to be missing an image file.\n" - f"Could not find '{f}'." + f"You seem to be missing an image file.\nCould not find '{f}'." ) else: raise OSError(f"Could not find '{f}'") @@ -329,9 +328,9 @@ def ReadSlideSeq( "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"][ - "spot_diameter_fullres" - ] = spot_diameter_fullres + adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( + spot_diameter_fullres + ) adata.obsm["spatial"] = meta[["x", "y"]].values return adata @@ -501,9 +500,9 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"][ - "spot_diameter_fullres" - ] = spot_diameter_fullres + adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( + spot_diameter_fullres + ) return adata @@ -594,9 +593,9 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"][ - "spot_diameter_fullres" - ] = spot_diameter_fullres + adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( + spot_diameter_fullres + ) return adata @@ -676,8 +675,8 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"][ - "spot_diameter_fullres" - ] = spot_diameter_fullres + adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( + spot_diameter_fullres + ) return adata diff --git a/tests/test_PSTS.py b/tests/test_PSTS.py index 21089a6a..08ceba53 100644 --- a/tests/test_PSTS.py +++ b/tests/test_PSTS.py @@ -2,7 +2,6 @@ """Tests for `stlearn` package.""" - import unittest import numpy as np diff --git a/tests/test_SME.py b/tests/test_SME.py index a1f38200..49ea98ec 100644 --- a/tests/test_SME.py +++ b/tests/test_SME.py @@ -2,7 +2,6 @@ """Tests for `stlearn` package.""" - import unittest import scanpy as sc diff --git a/tox.ini b/tox.ini index 76e1b229..984d1e61 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] requires = tox>=4 -env_list = lint, type, 3.1{3,2,1,0}, ruff +env_list = lint, type, 3.10, ruff [testenv:lint] description = run linters From 6e4a45cc2cdc400041bebdd6174cc2dc1994c291 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 6 Jun 2025 15:11:32 +1000 Subject: [PATCH 029/123] Reformat. --- stlearn/adds/add_mask.py | 4 +--- stlearn/plotting/classes.py | 6 ++--- stlearn/plotting/stack_3d_plot.py | 6 ++--- stlearn/plotting/subcluster_plot.py | 6 ++--- .../plotting/trajectory/check_trajectory.py | 18 +++++++------- stlearn/wrapper/read.py | 24 +++++++++---------- tox.ini | 1 - 7 files changed, 31 insertions(+), 34 deletions(-) diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 84c24c4a..998b4936 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -49,10 +49,8 @@ def add_mask( img = plt.imread(imgpath, 0) assert ( img.shape == adata.uns["spatial"][library_id]["images"][quality].shape - ), ( - "\ + ), "\ size of mask image does not match size of H&E images" - ) if "mask_image" not in adata.uns: adata.uns["mask_image"] = {} if library_id not in adata.uns["mask_image"]: diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 4772f2a6..6c21f832 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -71,9 +71,9 @@ def __init__( assert use_label is not None, "Please specify `use_label` parameter!" if use_label is not None: - assert use_label in self.adata[0].obs.columns, ( - "Please choose the right label in `adata.obs.columns`!" - ) + assert ( + use_label in self.adata[0].obs.columns + ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label if self.list_clusters is None: diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/plotting/stack_3d_plot.py index 0bc23896..c958575a 100644 --- a/stlearn/plotting/stack_3d_plot.py +++ b/stlearn/plotting/stack_3d_plot.py @@ -43,9 +43,9 @@ def stack_3d_plot( except ModuleNotFoundError: raise ModuleNotFoundError("Please install plotly by `pip install plotly`") - assert slide_col in adata.obs.columns, ( - "Please provide the right column for slide_id!" - ) + assert ( + slide_col in adata.obs.columns + ), "Please provide the right column for slide_id!" list_df = [] for i, slide in enumerate(slides): diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index 1763c716..0e8c66e5 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -58,9 +58,9 @@ def subcluster_plot( """ assert use_label is not None, "Please select `use_label` parameter" - assert use_label in adata.obs.columns, ( - "Please run `stlearn.spatial.cluster.localization` function!" - ) + assert ( + use_label in adata.obs.columns + ), "Please run `stlearn.spatial.cluster.localization` function!" SubClusterPlot( adata, diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/plotting/trajectory/check_trajectory.py index 2699d2a0..587c20e9 100644 --- a/stlearn/plotting/trajectory/check_trajectory.py +++ b/stlearn/plotting/trajectory/check_trajectory.py @@ -17,16 +17,16 @@ def check_trajectory( img_key: str = "hires", ) -> None: trajectory = np.array(trajectory).astype(int) - assert trajectory in adata.uns["available_paths"].values(), ( - "Please choose the right path!" - ) + assert ( + trajectory in adata.uns["available_paths"].values() + ), "Please choose the right path!" trajectory_str = [str(node) for node in trajectory] - assert pseudotime_key in adata.obs.columns, ( - "Please run the pseudotime or choose the right one!" - ) - assert use_label in adata.obs.columns, ( - "Please run the cluster or choose the right label!" - ) + assert ( + pseudotime_key in adata.obs.columns + ), "Please run the pseudotime or choose the right one!" + assert ( + use_label in adata.obs.columns + ), "Please run the cluster or choose the right label!" assert basis in adata.obsm, ( "Please run the " + basis + "before you check the trajectory!" ) diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 409b45cc..730666c2 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -328,9 +328,9 @@ def ReadSlideSeq( "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( - spot_diameter_fullres - ) + adata.uns["spatial"][library_id]["scalefactors"][ + "spot_diameter_fullres" + ] = spot_diameter_fullres adata.obsm["spatial"] = meta[["x", "y"]].values return adata @@ -500,9 +500,9 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( - spot_diameter_fullres - ) + adata.uns["spatial"][library_id]["scalefactors"][ + "spot_diameter_fullres" + ] = spot_diameter_fullres return adata @@ -593,9 +593,9 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( - spot_diameter_fullres - ) + adata.uns["spatial"][library_id]["scalefactors"][ + "spot_diameter_fullres" + ] = spot_diameter_fullres return adata @@ -675,8 +675,8 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" ] = scale - adata.uns["spatial"][library_id]["scalefactors"]["spot_diameter_fullres"] = ( - spot_diameter_fullres - ) + adata.uns["spatial"][library_id]["scalefactors"][ + "spot_diameter_fullres" + ] = spot_diameter_fullres return adata diff --git a/tox.ini b/tox.ini index 984d1e61..dcdf7115 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,6 @@ skip_install = true deps = ruff commands = ruff check stlearn tests - ruff format --check stlearn tests [testenv] setenv = From 34e636e5ff913c8ddd28b56a7871ac7a186224d3 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 9 Jun 2025 09:54:41 +1000 Subject: [PATCH 030/123] Upgrade numba and numpy. --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a1f23831..4c11dc46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ bokeh==3.7.3 click==8.2.1 leidenalg==0.10.2 louvain==0.8.2 -numba==0.56.4 -numpy==1.23.5 +numba==0.58.1 +numpy==1.26.4 pillow==11.2.1 scanpy==1.10.4 scikit-image==0.22.0 From 67c999db0a6b1eaa2f1428d1e55144ba6ea3cae6 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 9 Jun 2025 10:04:59 +1000 Subject: [PATCH 031/123] WIP. --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f6f1d45e..b9769b45 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -66,7 +66,7 @@ Ready to contribute? Here's how to set up `stlearn` for local development. 3. Install your local copy into a virtualenv. This is how you set up your fork for local development:: - $ conda create -n stlearn-dev python=3.10 + $ conda create -n stlearn-dev python=3.10 --y $ conda activate stlearn-dev $ cd stlearn/ $ pip install -e .[dev,test] From 3ed0539f22bce232cf15a4b096183b9fab2c7a20 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 9 Jun 2025 16:45:09 +1000 Subject: [PATCH 032/123] Add tests and fix bug. --- stlearn/classes.py | 2 +- stlearn/utils.py | 5 +++++ tests/test_Spatial.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/test_Spatial.py diff --git a/stlearn/classes.py b/stlearn/classes.py index 27e1322e..b131c0d6 100644 --- a/stlearn/classes.py +++ b/stlearn/classes.py @@ -27,7 +27,7 @@ def __init__( basis: str = "spatial", img: np.ndarray | None = None, img_key: str | None | Empty = _empty, - library_id: str | None = None, + library_id: str | None | Empty = _empty, crop_coord: bool = True, bw: bool = False, scale_factor: float | None = None, diff --git a/stlearn/utils.py b/stlearn/utils.py index fb03fcf1..6845a23c 100644 --- a/stlearn/utils.py +++ b/stlearn/utils.py @@ -57,6 +57,11 @@ def _check_spatial_data( """ Given a mapping, try and extract a library id/ mapping with spatial data. Assumes this is `.uns` from how we parse visium data. + + Parameters + ---------- + library_id : None | str | Empty + If None - don't find an image. Empty - find best image, or specify with str. """ spatial_mapping = uns.get("spatial", {}) if library_id is _empty: diff --git a/tests/test_Spatial.py b/tests/test_Spatial.py new file mode 100644 index 00000000..b31fa8cd --- /dev/null +++ b/tests/test_Spatial.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +"""Tests for `stlearn` package.""" + +import unittest + +import numpy.testing as npt +from stlearn.classes import Spatial + +from .utils import read_test_data + +global adata +adata = read_test_data() + + +class TestSpatial(unittest.TestCase): + """Tests for `stlearn` package.""" + + def test_setup_Spatial(self): + spatial = Spatial(adata) + self.assertIsNotNone(spatial) + self.assertEqual("V1_Breast_Cancer_Block_A_Section_1", spatial.library_id) + self.assertEqual("hires", spatial.img_key) + self.assertEqual(177.4829519178534, spatial.spot_size) + self.assertEqual(True, spatial.crop_coord) + self.assertEqual(False, spatial.use_raw) + npt.assert_array_almost_equal( + [896.782, 1370.627, 1483.498, 1178.713, 1584.901], + spatial.imagecol[:5], decimal=3) + npt.assert_array_almost_equal( + [1549.092, 1158.003, 1040.594, 1373.267, 1021.205], + spatial.imagerow[:5], + decimal=3) From 38829f6a51cb83e4bd32090c5b56866400c6bff3 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 12:27:59 +1000 Subject: [PATCH 033/123] Fixup type checking. --- stlearn/app/source/forms/forms.py | 4 +++- stlearn/plotting/cci_plot_helpers.py | 2 +- stlearn/plotting/classes.py | 18 +++++++++--------- stlearn/plotting/utils.py | 8 ++++---- stlearn/spatials/trajectory/global_level.py | 2 +- stlearn/tools/microenv/cci/analysis.py | 2 +- tests/test_Spatial.py | 8 ++++++-- 7 files changed, 25 insertions(+), 19 deletions(-) diff --git a/stlearn/app/source/forms/forms.py b/stlearn/app/source/forms/forms.py index 790bc97e..466c1da1 100644 --- a/stlearn/app/source/forms/forms.py +++ b/stlearn/app/source/forms/forms.py @@ -217,7 +217,9 @@ def getCCIForm(adata): fields = [] mix = False else: - fields = [key for key in adata.obs.keys() if adata.obs[key].values[0] is str] + fields = [ + key for key in adata.obs.keys() if isinstance(adata.obs[key].values[0], str) + ] mix = fields[0] in adata.uns.keys() element_values = [fields, 20, mix, 0.2, 100] return createSuperForm(elements, element_fields, element_values) diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 4706b3a2..85efe6ae 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -715,7 +715,7 @@ def chordDiagram(X, ax, colors=None, width=0.1, pad=2, chordwidth=0.7, lim=1.1): ] if len(x) > 10: print("x is too large! Use x smaller than 10") - if colors[0] is str: + if isinstance(colors[0], str): colors = [hex2rgb(colors[i]) for i in range(len(x))] # find position for each start and end diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 6c21f832..862f874e 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -81,7 +81,7 @@ def __init__( self.adata[0].obs[use_label].cat.categories ) else: - if self.list_clusters is not list: + if not isinstance(self.list_clusters, list): self.list_clusters = [self.list_clusters] clusters_indexes = [ @@ -101,12 +101,12 @@ def __init__( stlearn_cmap = ["jana_40", "default"] cmap_available = plt.colormaps() + scanpy_cmap + stlearn_cmap error_msg = ( - "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" + "cmap must be a matplotlib.colors.LinearSegmentedColormap OR " "one of these: " + str(cmap_available) ) - if cmap is str: + if isinstance(cmap, str): assert cmap in cmap_available, error_msg - elif cmap is not matplotlib.colors.LinearSegmentedColormap: + elif not isinstance(cmap, matplotlib.colors.LinearSegmentedColormap): raise Exception(error_msg) self.cmap = cmap @@ -380,7 +380,7 @@ def _plot_genes(self, gene_values: pd.Series): marker="o", vmin=vmin, vmax=vmax, - cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, + cmap=plt.get_cmap(self.cmap) if isinstance(self.cmap, str) else self.cmap, c=gene_values, ) return plot @@ -409,7 +409,7 @@ def _plot_contour(self, gene_values: pd.Series): yi, zi, range(0, int(np.nanmax(zi)) + self.step_size, self.step_size), - cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, + cmap=plt.get_cmap(self.cmap) if isinstance(self.cmap, str) else self.cmap, alpha=self.cell_alpha, ) return cs @@ -547,7 +547,7 @@ def _plot_feature(self, feature_values: pd.Series): marker="o", vmin=vmin, vmax=vmax, - cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, + cmap=plt.get_cmap(self.cmap) if isinstance(self.cmap, str) else self.cmap, c=feature_values, ) return plot @@ -576,7 +576,7 @@ def _plot_contour(self, feature_values: pd.Series): yi, zi, range(0, int(np.nanmax(zi)) + self.step_size, self.step_size), - cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, + cmap=plt.get_cmap(self.cmap) if isinstance(self.cmap, str) else self.cmap, alpha=self.cell_alpha, ) return cs @@ -1031,7 +1031,7 @@ def _plot_subclusters(self, threshold_spots): edgecolor="none", s=self.size, marker="o", - cmap=plt.get_cmap(self.cmap) if self.cmap is str else self.cmap, + cmap=plt.get_cmap(self.cmap) if isinstance(self.cmap, str) else self.cmap, c=colors, alpha=self.cell_alpha, ) diff --git a/stlearn/plotting/utils.py b/stlearn/plotting/utils.py index 6304d740..e819a8be 100644 --- a/stlearn/plotting/utils.py +++ b/stlearn/plotting/utils.py @@ -70,10 +70,10 @@ def get_cmap(cmap): cmap = palettes_st.jana_40 elif cmap == "default": cmap = palettes_st.default - elif cmap is str: # If refers to matplotlib cmap + elif isinstance(cmap, str): # If refers to matplotlib cmap cmap_n = plt.get_cmap(cmap).N return plt.get_cmap(cmap), cmap_n - elif cmap is matplotlib.colors.LinearSegmentedColormap: # already cmap + elif isinstance(cmap, matplotlib.colors.LinearSegmentedColormap): # already cmap cmap_n = cmap.N return cmap, cmap_n @@ -94,9 +94,9 @@ def check_cmap(cmap): "cmap must be a matplotlib.colors.LinearSegmentedColormap OR" "one of these: " + str(cmap_available) ) - if cmap is str: + if isinstance(cmap, str): assert cmap in cmap_available, error_msg - elif cmap is not matplotlib.colors.LinearSegmentedColormap: + elif not isinstance(cmap, matplotlib.colors.LinearSegmentedColormap): raise Exception(error_msg) return cmap diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index da0cdf35..21d4a3fc 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -50,7 +50,7 @@ def global_level( inds_cat = {v: k for (k, v) in cat_inds.items()} # Query cluster - if list_clusters[0] is str: + if isinstance(list_clusters[0], str): list_clusters = [cat_inds[label] for label in list_clusters] query_nodes = list_clusters diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 5318ac08..13c6ae20 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -44,7 +44,7 @@ def load_lrs(names: str | list | None = None, species: str = "human") -> np.ndar """ if names is None: names = ["connectomeDB2020_lit"] - if names is str: + if isinstance(names, str): names = [names] path = os.path.dirname(os.path.realpath(__file__)) diff --git a/tests/test_Spatial.py b/tests/test_Spatial.py index b31fa8cd..7177466a 100644 --- a/tests/test_Spatial.py +++ b/tests/test_Spatial.py @@ -5,6 +5,7 @@ import unittest import numpy.testing as npt + from stlearn.classes import Spatial from .utils import read_test_data @@ -26,8 +27,11 @@ def test_setup_Spatial(self): self.assertEqual(False, spatial.use_raw) npt.assert_array_almost_equal( [896.782, 1370.627, 1483.498, 1178.713, 1584.901], - spatial.imagecol[:5], decimal=3) + spatial.imagecol[:5], + decimal=3, + ) npt.assert_array_almost_equal( [1549.092, 1158.003, 1040.594, 1373.267, 1021.205], spatial.imagerow[:5], - decimal=3) + decimal=3, + ) From 70381ca12057c4ea420c569f2842b35ec158d617 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 13:51:17 +1000 Subject: [PATCH 034/123] Fix warnings and deprecated call of legendHandles. --- stlearn/plotting/classes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 862f874e..18a35aec 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -691,7 +691,8 @@ def _add_cluster_colors(self): # self.adata[0].uns[self.use_label + "_set"] = [] self.adata[0].uns[self.use_label + "_colors"] = [] - for i, cluster in enumerate(self.adata[0].obs.groupby(self.use_label)): + for i, cluster in enumerate(self.adata[0].obs.groupby(self.use_label, + observed=True)): self.adata[0].uns[self.use_label + "_colors"].append( matplotlib.colors.to_hex(self.cmap_(i / (self.cmap_n - 1))) ) @@ -700,7 +701,8 @@ def _add_cluster_colors(self): def _plot_clusters(self): # Plot scatter plot based on pixel of spots - for i, cluster in enumerate(self.query_adata.obs.groupby(self.use_label)): + for i, cluster in enumerate( + self.query_adata.obs.groupby(self.use_label, observed=True)): # Plot scatter plot based on pixel of spots subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), list(cluster[1].index)) @@ -742,7 +744,7 @@ def _add_cluster_bar(self, bbox_to_anchor): handleheight=1.0, edgecolor="white", ) - for handle in lgnd.legendHandles: + for handle in lgnd.legend_handles: handle.set_sizes([20.0]) def _add_cluster_labels(self): From ae7f4bbc8cd1d89bad442872f0e674531f26786d Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 13:57:15 +1000 Subject: [PATCH 035/123] Remove deprecated np warnings. --- stlearn/spatials/trajectory/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index 2490b9d7..ce99e9b5 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -1,5 +1,6 @@ import networkx as nx import numpy as np +import warnings from numpy import linalg as la from scipy import linalg as spla from scipy import sparse as sps @@ -526,8 +527,8 @@ def _mat_mat_corr_sparse( y_bar = np.reshape(np.mean(Y, axis=0), (1, -1)) y_std = np.reshape(np.std(Y, axis=0), (1, -1)) - with np.warnings.catch_warnings(): - np.warnings.filterwarnings( + with warnings.catch_warnings(): + warnings.filterwarnings( "ignore", r"invalid value encountered in true_divide" ) return (X @ Y - (n * X_bar * y_bar)) / ((n - 1) * X_std * y_std) @@ -602,8 +603,8 @@ def _mat_mat_corr_dense(X: np.ndarray, Y: np.ndarray) -> np.ndarray: y_bar = np.reshape(np_mean(Y, axis=0), (1, -1)) y_std = np.reshape(np_std(Y, axis=0), (1, -1)) - with np.warnings.catch_warnings(): - np.warnings.filterwarnings( + with warnings.catch_warnings(): + warnings.filterwarnings( "ignore", r"invalid value encountered in true_divide" ) return (X @ Y - (n * X_bar * y_bar)) / ((n - 1) * X_std * y_std) From e7c832428ed64860d135e10a38ee7cb43af4d50e Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 16:11:14 +1000 Subject: [PATCH 036/123] Add tests for feature extraction. --- .../image_preprocessing/feature_extractor.py | 102 ++++++++++++------ stlearn/image_preprocessing/image_tiling.py | 65 +++++++---- tests/test_extract_features.py | 62 +++++++++++ tests/test_tiling.py | 82 ++++++++++++++ 4 files changed, 260 insertions(+), 51 deletions(-) create mode 100644 tests/test_extract_features.py create mode 100644 tests/test_tiling.py diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 50fe976c..b1b35e3d 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -9,17 +9,18 @@ from tqdm import tqdm from .model_zoo import Model, encode +from sklearn.decomposition import PCA _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] - -def extract_feature( +def new_extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", n_components: int = 50, + seeds: int = 1, + batch_size: int = 32, verbose: bool = False, copy: bool = False, - seeds: int = 1, ) -> AnnData | None: """\ Extract latent morphological features from H&E images using pre-trained @@ -27,19 +28,21 @@ def extract_feature( Parameters ---------- - adata + adata: Annotated data matrix. - cnn_base + cnn_base: Established convolutional neural network bases choose one from ['resnet50', 'vgg16', 'inception_v3', 'xception'] - n_components + n_components: Number of principal components to compute for latent morphological features - verbose + seeds: + Fix random state + batch_size: + Number of images to process in each batch (default: 32) + verbose: Verbose output - copy + copy: Return a copy instead of writing to adata. - seeds - Fix random state Returns ------- Depending on `copy`, returns or updates `adata` with the following fields. @@ -49,39 +52,78 @@ def extract_feature( adata = adata.copy() if copy else adata - feature_dfs = [] - model = Model(cnn_base) - if "tile_path" not in adata.obs: raise ValueError("Please run the function stlearn.pp.tiling") + model = Model(cnn_base) + n_spots = len(adata) + spots = list(adata.obs["tile_path"].items()) + + spot_names = [] + feature_matrix = None + current_row = 0 + with tqdm( - total=len(adata), + total=n_spots, desc="Extract feature", bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: - for spot, tile_path in adata.obs["tile_path"].items(): - tile = Image.open(tile_path) - tile = np.asarray(tile, dtype="int32") - tile = tile.astype(np.float32) - tile = np.stack([tile]) - if verbose: - print(f"extract feature for spot: {str(spot)}") - features = encode(tile, model) - feature_dfs.append(pd.DataFrame(features, columns=[spot])) - pbar.update(1) - feature_df = pd.concat(feature_dfs, axis=1) + for i in range(0, n_spots, batch_size): + batch_spots = spots[i:i + batch_size] + batch_tiles, batch_spot_names = _load_batch_images(batch_spots, verbose) - adata.obsm["X_tile_feature"] = feature_df.transpose().to_numpy() + if batch_tiles: + batch_array = np.stack(batch_tiles, axis=0) + batch_features = model.predict(batch_array) - from sklearn.decomposition import PCA + if feature_matrix is None: + n_features = batch_features.shape[1] + feature_matrix = np.empty((n_spots, n_features), + dtype=np.float32) - pca = PCA(n_components=n_components, random_state=seeds) - pca.fit(feature_df.transpose().to_numpy()) + end_row = current_row + len(batch_features) + feature_matrix[current_row:end_row] = batch_features + current_row = end_row + + spot_names.extend(batch_spot_names) - adata.obsm["X_morphology"] = pca.transform(feature_df.transpose().to_numpy()) + pbar.update(len(batch_spots)) + + if feature_matrix is None or current_row == 0: + raise ValueError("No features were successfully extracted") + + feature_matrix = feature_matrix[:current_row] + + feature_df = pd.DataFrame(feature_matrix.T, columns=spot_names) + feature_array = feature_df.T.to_numpy() + + adata.obsm["X_tile_feature"] = feature_array + + pca = PCA(n_components=n_components, random_state=seeds) + adata.obsm["X_morphology"] = pca.fit_transform(feature_matrix) print("The morphology feature is added to adata.obsm['X_morphology']!") return adata if copy else None + + +def _load_batch_images(batch_spots, verbose=False): + """Load a batch of images from file paths.""" + images = [] + names = [] + + for spot_name, tile_path in batch_spots: + try: + image = np.asarray(Image.open(tile_path), dtype=np.float32) + images.append(image) + names.append(spot_name) + + if verbose: + print(f"Loaded image for spot: {spot_name}") + + except Exception as e: + print(f"Warning: Failed to load image for spot {spot_name}: {e}") + continue + + return images, names diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index 098cc58a..d782a77e 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -16,6 +16,7 @@ def tiling( crop_size: int = 40, target_size: int = 299, img_fmt: str = "JPEG", + quality: int = 95, verbose: bool = False, copy: bool = False, ) -> AnnData | None: @@ -24,20 +25,24 @@ def tiling( Parameters ---------- - adata + adata: Annotated data matrix. - out_path + out_path: Path to save spot image tiles - library_id + library_id: Library id stored in AnnData. - crop_size + crop_size: Size of tiles - verbose + target_size: + Input size for convolutional neuron network + img_fmt: + Image format ('JPEG' or 'PNG') + quality: + JPEG quality 1-100. + verbose: Verbose output - copy + copy: Return a copy instead of writing to adata. - target_size - Input size for convolutional neuron network Returns ------- Depending on `copy`, returns or updates `adata` with the following fields. @@ -47,23 +52,38 @@ def tiling( adata = adata.copy() if copy else adata + if not isinstance(crop_size, int) or crop_size <= 0: + raise ValueError("crop_size must be a positive integer") + if not isinstance(target_size, int) or target_size <= 0: + raise ValueError("target_size must be a positive integer") + if img_fmt.upper() not in ["JPEG", "PNG"]: + raise ValueError("img_fmt must be 'JPEG' or 'PNG'") + if library_id is None: library_id = list(adata.uns["spatial"].keys())[0] - # Check the exist of out_path - if not os.path.isdir(out_path): - os.mkdir(out_path) + out_path = Path(out_path) + out_path.mkdir(parents=True, exist_ok=True) + + # Load and prepare image + try: + image = adata.uns["spatial"][library_id]["images"][ + adata.uns["spatial"][library_id]["use_quality"] + ] + except KeyError as e: + raise ValueError(f"Could not find image data in adata.uns['spatial']: {e}") - image = adata.uns["spatial"][library_id]["images"][ - adata.uns["spatial"][library_id]["use_quality"] - ] - if image.dtype == np.float32 or image.dtype == np.float64: + if image.dtype in (np.float32, np.float64): + image = np.clip(image, 0, 1) image = (image * 255).astype(np.uint8) + img_pillow = Image.fromarray(image) if img_pillow.mode == "RGBA": img_pillow = img_pillow.convert("RGB") + coordinates = list(zip(adata.obs["imagerow"], adata.obs["imagecol"])) + tile_names = [] with tqdm( @@ -71,14 +91,17 @@ def tiling( desc="Tiling image", bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: - for imagerow, imagecol in zip(adata.obs["imagerow"], adata.obs["imagecol"]): - imagerow_down = imagerow - crop_size / 2 - imagerow_up = imagerow + crop_size / 2 - imagecol_left = imagecol - crop_size / 2 - imagecol_right = imagecol + crop_size / 2 + for imagerow, imagecol in coordinates: + half_crop = crop_size // 2 + imagerow_down = max(0, imagerow - half_crop) + imagerow_up = imagerow + half_crop + imagecol_left = max(0, imagecol - half_crop) + imagecol_right = imagecol + half_crop + tile = img_pillow.crop( (imagecol_left, imagerow_down, imagecol_right, imagerow_up) ) + tile.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) tile = tile.resize((target_size, target_size)) tile_name = str(imagecol) + "-" + str(imagerow) + "-" + str(crop_size) @@ -86,7 +109,7 @@ def tiling( if img_fmt == "JPEG": out_tile = Path(out_path) / (tile_name + ".jpeg") tile_names.append(str(out_tile)) - tile.save(out_tile, "JPEG") + tile.save(out_tile, "JPEG", quality=quality) else: out_tile = Path(out_path) / (tile_name + ".png") tile_names.append(str(out_tile)) diff --git a/tests/test_extract_features.py b/tests/test_extract_features.py new file mode 100644 index 00000000..39f0f694 --- /dev/null +++ b/tests/test_extract_features.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +import unittest +import numpy as np +import tempfile +import shutil +import os + +import scanpy as sc +import stlearn as st + +from .utils import read_test_data + +global adata +adata = read_test_data() + + +class TestFeatureExtractionPerformance(unittest.TestCase): + """Comprehensive tests for feature extraction.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_data = adata.copy() + self.temp_dir = tempfile.mkdtemp() + sc.pp.pca(self.test_data) + st.pp.tiling(self.test_data, self.temp_dir) + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_deterministic_behavior(self): + """Test that results are deterministic with same seed.""" + data1 = self.test_data.copy() + data2 = self.test_data.copy() + + st.pp.extract_feature(data1, seeds=42, batch=16) + st.pp.extract_feature(data2, seeds=42, batch=32) + + np.testing.assert_array_equal( + data1.obsm["X_morphology"], + data2.obsm["X_morphology"], + err_msg="Results should be deterministic with same seed" + ) + + + def test_copy_behavior(self): + """Test copy=True vs copy=False behavior.""" + original_data = self.test_data.copy() + + # Test copy=True + result_copy = st.pp.extract_feature(original_data, copy=True) + self.assertIsNotNone(result_copy) + self.assertNotIn("X_morphology", original_data.obsm) + self.assertIn("X_morphology", result_copy.obsm) + + # Test copy=False + result_inplace = st.pp.extract_feature(original_data, copy=False) + self.assertIsNone(result_inplace) + self.assertIn("X_morphology", original_data.obsm) + diff --git a/tests/test_tiling.py b/tests/test_tiling.py new file mode 100644 index 00000000..90fb2df8 --- /dev/null +++ b/tests/test_tiling.py @@ -0,0 +1,82 @@ + +# !/usr/bin/env python + +"""Tests for tiling function.""" + +import unittest +import time +import numpy as np +import pandas as pd +from pathlib import Path +import tempfile +import shutil +import os +from PIL import Image +from unittest.mock import patch, MagicMock +import filecmp + +import scanpy as sc +import stlearn as st + +from .utils import read_test_data + +global adata +adata = read_test_data() + + +class TestTiling(unittest.TestCase): + """Tests for `stlearn` package.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_data = adata.copy() + self.temp_dir = tempfile.mkdtemp() + self.temp_dir_orig = tempfile.mkdtemp(suffix="_orig") + + # Ensure we have required spatial data + if "spatial" not in self.test_data.uns: + self.skipTest("Test data missing spatial information") + + # Add imagerow/imagecol if missing (for testing) + if "imagerow" not in self.test_data.obs: + # Create synthetic coordinates for testing + n_spots = len(self.test_data) + self.test_data.obs["imagerow"] = np.random.randint(50, 450, n_spots) + self.test_data.obs["imagecol"] = np.random.randint(50, 450, n_spots) + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + if os.path.exists(self.temp_dir_orig): + shutil.rmtree(self.temp_dir_orig) + + def test_directory_creation(self): + """Test directory creation behavior.""" + + # Test nested directory creation + nested_path = Path(self.temp_dir) / "level1" / "level2" / "tiles" + data = self.test_data.copy() + + st.pp.tiling(data, nested_path) + + self.assertTrue(nested_path.exists(), "Nested directories not created") + self.assertGreater(len(list(nested_path.glob("*"))), 0, + "No files in nested directory") + + def test_quality_parameter(self): + """Test JPEG quality parameter.""" + data = self.test_data[:3].copy() # Small subset + + # Test different quality settings + for quality in [50, 95]: + temp_quality = tempfile.mkdtemp(suffix=f"_q{quality}") + test_data = data.copy() + + st.pp.tiling(test_data, temp_quality, img_fmt="JPEG", quality=quality) + + # Verify files exist + jpeg_files = list(Path(temp_quality).glob("*.jpeg")) + self.assertEqual(len(jpeg_files), len(data)) + + shutil.rmtree(temp_quality) \ No newline at end of file From 9e5f8b825d16d31c47c9a729fbc0fb670d1f42c4 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 16:13:01 +1000 Subject: [PATCH 037/123] Oops. --- stlearn/image_preprocessing/feature_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index b1b35e3d..0fcb9b87 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -13,7 +13,7 @@ _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] -def new_extract_feature( +def extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", n_components: int = 50, From 242a7deaa10b83951a0859c2834d48b4a1dafcb5 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 17:17:24 +1000 Subject: [PATCH 038/123] Oops. --- .../image_preprocessing/feature_extractor.py | 75 ++++++++++++++++++- stlearn/pp.py | 2 +- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 0fcb9b87..e7dc8a0e 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -13,7 +13,7 @@ _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] -def extract_feature( +def new_extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", n_components: int = 50, @@ -127,3 +127,76 @@ def _load_batch_images(batch_spots, verbose=False): continue return images, names + +def extract_feature( + adata: AnnData, + cnn_base: _CNN_BASE = "resnet50", + n_components: int = 50, + verbose: bool = False, + copy: bool = False, + seeds: int = 1, +) -> AnnData | None: + """\ + Extract latent morphological features from H&E images using pre-trained + convolutional neural network base + + Parameters + ---------- + adata + Annotated data matrix. + cnn_base + Established convolutional neural network bases + choose one from ['resnet50', 'vgg16', 'inception_v3', 'xception'] + n_components + Number of principal components to compute for latent morphological features + verbose + Verbose output + copy + Return a copy instead of writing to adata. + seeds + Fix random state + Returns + ------- + Depending on `copy`, returns or updates `adata` with the following fields. + **X_morphology** : `adata.obsm` field + Dimension reduced latent morphological features. + """ + + adata = adata.copy() if copy else adata + + feature_dfs = [] + model = Model(cnn_base) + + if "tile_path" not in adata.obs: + raise ValueError("Please run the function stlearn.pp.tiling") + + with tqdm( + total=len(adata), + desc="Extract feature", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + ) as pbar: + for spot, tile_path in adata.obs["tile_path"].items(): + tile = Image.open(tile_path) + tile = np.asarray(tile, dtype="int32") + tile = tile.astype(np.float32) + tile = np.stack([tile]) + if verbose: + print(f"extract feature for spot: {str(spot)}") + features = encode(tile, model) + feature_dfs.append(pd.DataFrame(features, columns=[spot])) + pbar.update(1) + + feature_df = pd.concat(feature_dfs, axis=1) + + adata.obsm["X_tile_feature"] = feature_df.transpose().to_numpy() + + from sklearn.decomposition import PCA + + pca = PCA(n_components=n_components, random_state=seeds) + pca.fit(feature_df.transpose().to_numpy()) + + adata.obsm["X_morphology"] = pca.transform(feature_df.transpose().to_numpy()) + + print("The morphology feature is added to adata.obsm['X_morphology']!") + + return adata if copy else None diff --git a/stlearn/pp.py b/stlearn/pp.py index 695efd24..931e1c17 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,4 +1,4 @@ -from .image_preprocessing.feature_extractor import extract_feature +from .image_preprocessing.feature_extractor import extract_feature, new_extract_feature from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors From 3d96012d1305b4c7ee6a38e5ed4a4b2e38cc74d0 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 19:05:53 +1000 Subject: [PATCH 039/123] Refactor. --- stlearn/image_preprocessing/feature_extractor.py | 10 +++++++++- stlearn/image_preprocessing/model_zoo.py | 6 ------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index e7dc8a0e..997ee448 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -77,6 +77,8 @@ def new_extract_feature( batch_array = np.stack(batch_tiles, axis=0) batch_features = model.predict(batch_array) + batch_features = batch_features.reshape(batch_features.shape[0], -1) + if feature_matrix is None: n_features = batch_features.shape[1] feature_matrix = np.empty((n_spots, n_features), @@ -128,6 +130,12 @@ def _load_batch_images(batch_spots, verbose=False): return images, names + +def _encode(tiles, model): + features = model.predict(tiles) + features = features.ravel() + return features + def extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", @@ -182,7 +190,7 @@ def extract_feature( tile = np.stack([tile]) if verbose: print(f"extract feature for spot: {str(spot)}") - features = encode(tile, model) + features = _encode(tile, model) feature_dfs.append(pd.DataFrame(features, columns=[spot])) pbar.update(1) diff --git a/stlearn/image_preprocessing/model_zoo.py b/stlearn/image_preprocessing/model_zoo.py index c21af134..1969f9b1 100644 --- a/stlearn/image_preprocessing/model_zoo.py +++ b/stlearn/image_preprocessing/model_zoo.py @@ -1,9 +1,3 @@ -def encode(tiles, model): - features = model.predict(tiles) - features = features.ravel() - return features - - class Model: __name__ = "CNN base model" From 3b04b30ea35b7452a3cbb5e5003c5e9fe433d6bc Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 19:08:58 +1000 Subject: [PATCH 040/123] Fix. --- stlearn/image_preprocessing/feature_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 997ee448..c7d3c8a1 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -8,7 +8,7 @@ # Test progress bar from tqdm import tqdm -from .model_zoo import Model, encode +from .model_zoo import Model from sklearn.decomposition import PCA _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] From 86a8232fddb1c4708318a36ca8775fc969c264e8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 20:39:11 +1000 Subject: [PATCH 041/123] Fix. --- .../image_preprocessing/feature_extractor.py | 200 ++++-------------- stlearn/pp.py | 2 +- tests/test_extract_features.py | 9 +- 3 files changed, 55 insertions(+), 156 deletions(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index c7d3c8a1..0fe7d5fc 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -1,26 +1,23 @@ from typing import Literal import numpy as np -import pandas as pd -from anndata import AnnData from PIL import Image - -# Test progress bar +from anndata import AnnData +from sklearn.decomposition import PCA from tqdm import tqdm from .model_zoo import Model -from sklearn.decomposition import PCA _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] -def new_extract_feature( - adata: AnnData, - cnn_base: _CNN_BASE = "resnet50", - n_components: int = 50, - seeds: int = 1, - batch_size: int = 32, - verbose: bool = False, - copy: bool = False, + +def extract_feature( + adata: AnnData, + cnn_base: _CNN_BASE = "resnet50", + n_components: int = 50, + seeds: int = 1, + verbose: bool = False, + copy: bool = False, ) -> AnnData | None: """\ Extract latent morphological features from H&E images using pre-trained @@ -37,8 +34,6 @@ def new_extract_feature( Number of principal components to compute for latent morphological features seeds: Fix random state - batch_size: - Number of images to process in each batch (default: 32) verbose: Verbose output copy: @@ -48,6 +43,10 @@ def new_extract_feature( Depending on `copy`, returns or updates `adata` with the following fields. **X_morphology** : `adata.obsm` field Dimension reduced latent morphological features. + Raises + ------ + ValueError + If any image fails to process or if tile_path column is missing. """ adata = adata.copy() if copy else adata @@ -56,155 +55,50 @@ def new_extract_feature( raise ValueError("Please run the function stlearn.pp.tiling") model = Model(cnn_base) - n_spots = len(adata) - spots = list(adata.obs["tile_path"].items()) - - spot_names = [] - feature_matrix = None - current_row = 0 - - with tqdm( - total=n_spots, - desc="Extract feature", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - ) as pbar: - - for i in range(0, n_spots, batch_size): - batch_spots = spots[i:i + batch_size] - batch_tiles, batch_spot_names = _load_batch_images(batch_spots, verbose) - - if batch_tiles: - batch_array = np.stack(batch_tiles, axis=0) - batch_features = model.predict(batch_array) - - batch_features = batch_features.reshape(batch_features.shape[0], -1) - - if feature_matrix is None: - n_features = batch_features.shape[1] - feature_matrix = np.empty((n_spots, n_features), - dtype=np.float32) - - end_row = current_row + len(batch_features) - feature_matrix[current_row:end_row] = batch_features - current_row = end_row - - spot_names.extend(batch_spot_names) - - pbar.update(len(batch_spots)) - - if feature_matrix is None or current_row == 0: - raise ValueError("No features were successfully extracted") - - feature_matrix = feature_matrix[:current_row] - - feature_df = pd.DataFrame(feature_matrix.T, columns=spot_names) - feature_array = feature_df.T.to_numpy() - - adata.obsm["X_tile_feature"] = feature_array - - pca = PCA(n_components=n_components, random_state=seeds) - adata.obsm["X_morphology"] = pca.fit_transform(feature_matrix) - - print("The morphology feature is added to adata.obsm['X_morphology']!") - - return adata if copy else None - - -def _load_batch_images(batch_spots, verbose=False): - """Load a batch of images from file paths.""" - images = [] - names = [] - - for spot_name, tile_path in batch_spots: - try: - image = np.asarray(Image.open(tile_path), dtype=np.float32) - images.append(image) - names.append(spot_name) - - if verbose: - print(f"Loaded image for spot: {spot_name}") - - except Exception as e: - print(f"Warning: Failed to load image for spot {spot_name}: {e}") - continue - - return images, names + # Pre-allocate feature matrix, spot names and arrays to avoid overhead + tile_paths = adata.obs["tile_path"].values + n_spots = len(tile_paths) + if n_spots == 0: + raise ValueError("No tile paths found in adata.obs['tile_path']") -def _encode(tiles, model): - features = model.predict(tiles) - features = features.ravel() - return features + first_features = _read_and_predict(tile_paths[0], model, verbose=verbose) + n_features = len(first_features) -def extract_feature( - adata: AnnData, - cnn_base: _CNN_BASE = "resnet50", - n_components: int = 50, - verbose: bool = False, - copy: bool = False, - seeds: int = 1, -) -> AnnData | None: - """\ - Extract latent morphological features from H&E images using pre-trained - convolutional neural network base - - Parameters - ---------- - adata - Annotated data matrix. - cnn_base - Established convolutional neural network bases - choose one from ['resnet50', 'vgg16', 'inception_v3', 'xception'] - n_components - Number of principal components to compute for latent morphological features - verbose - Verbose output - copy - Return a copy instead of writing to adata. - seeds - Fix random state - Returns - ------- - Depending on `copy`, returns or updates `adata` with the following fields. - **X_morphology** : `adata.obsm` field - Dimension reduced latent morphological features. - """ - - adata = adata.copy() if copy else adata - - feature_dfs = [] - model = Model(cnn_base) - - if "tile_path" not in adata.obs: - raise ValueError("Please run the function stlearn.pp.tiling") + # Setup feature matrix + feature_matrix = np.empty((n_spots, n_features), dtype=np.float32) + feature_matrix[0] = first_features with tqdm( - total=len(adata), - desc="Extract feature", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", + total=n_spots, + desc="Extract feature", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + initial=1, # We already processed the first image ) as pbar: - for spot, tile_path in adata.obs["tile_path"].items(): - tile = Image.open(tile_path) - tile = np.asarray(tile, dtype="int32") - tile = tile.astype(np.float32) - tile = np.stack([tile]) - if verbose: - print(f"extract feature for spot: {str(spot)}") - features = _encode(tile, model) - feature_dfs.append(pd.DataFrame(features, columns=[spot])) + for i in range(1, n_spots): + features = _read_and_predict(tile_paths[i], model, verbose=verbose) + feature_matrix[i] = features pbar.update(1) - feature_df = pd.concat(feature_dfs, axis=1) + adata.obsm["X_tile_feature"] = feature_matrix + pca = PCA(n_components=n_components, random_state=seeds) + pca.fit(feature_matrix) + adata.obsm["X_morphology"] = pca.transform(feature_matrix) - adata.obsm["X_tile_feature"] = feature_df.transpose().to_numpy() + print("The morphology feature is added to adata.obsm['X_morphology']!") - from sklearn.decomposition import PCA + return adata if copy else None - pca = PCA(n_components=n_components, random_state=seeds) - pca.fit(feature_df.transpose().to_numpy()) - adata.obsm["X_morphology"] = pca.transform(feature_df.transpose().to_numpy()) +def _read_and_predict(path, model, verbose=False): + try: + with Image.open(path) as img: + tile = np.asarray(img, dtype=np.float32) - print("The morphology feature is added to adata.obsm['X_morphology']!") + if verbose: + print(f"Loaded image: {path}") - return adata if copy else None + tile = tile[np.newaxis, ...] + return model.predict(tile).ravel() + except Exception as e: + raise ValueError(f"Failed to process image: {path}. Error: {str(e)}") diff --git a/stlearn/pp.py b/stlearn/pp.py index 931e1c17..695efd24 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,4 +1,4 @@ -from .image_preprocessing.feature_extractor import extract_feature, new_extract_feature +from .image_preprocessing.feature_extractor import extract_feature from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors diff --git a/tests/test_extract_features.py b/tests/test_extract_features.py index 39f0f694..6a6651f3 100644 --- a/tests/test_extract_features.py +++ b/tests/test_extract_features.py @@ -35,14 +35,19 @@ def test_deterministic_behavior(self): data1 = self.test_data.copy() data2 = self.test_data.copy() - st.pp.extract_feature(data1, seeds=42, batch=16) - st.pp.extract_feature(data2, seeds=42, batch=32) + st.pp.extract_feature(data1, seeds=42) + st.pp.extract_feature(data2, seeds=42) np.testing.assert_array_equal( data1.obsm["X_morphology"], data2.obsm["X_morphology"], err_msg="Results should be deterministic with same seed" ) + np.testing.assert_array_equal( + data1.obsm["X_tile_feature"], + data2.obsm["X_tile_feature"], + err_msg="Results should be deterministic with same seed" + ) def test_copy_behavior(self): From 7a7f9ed32c179766dd4c7c4fe0f808f72f90cbf9 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 20:47:27 +1000 Subject: [PATCH 042/123] Fix. --- stlearn/image_preprocessing/feature_extractor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 0fe7d5fc..e49c21ed 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -78,7 +78,8 @@ def extract_feature( for i in range(1, n_spots): features = _read_and_predict(tile_paths[i], model, verbose=verbose) feature_matrix[i] = features - pbar.update(1) + if i % 10 == 0: + pbar.update(10) adata.obsm["X_tile_feature"] = feature_matrix pca = PCA(n_components=n_components, random_state=seeds) From a159053784b85a0953c2932d7ece970b5e7bb110 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 20:47:38 +1000 Subject: [PATCH 043/123] Fix. --- stlearn/image_preprocessing/feature_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index e49c21ed..bc1f52f5 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -79,7 +79,7 @@ def extract_feature( features = _read_and_predict(tile_paths[i], model, verbose=verbose) feature_matrix[i] = features if i % 10 == 0: - pbar.update(10) + pbar.update(100) adata.obsm["X_tile_feature"] = feature_matrix pca = PCA(n_components=n_components, random_state=seeds) From e3e9bfc075ef1d121a126bef73a9e6d81a101290 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 10 Jun 2025 20:52:29 +1000 Subject: [PATCH 044/123] Fix. --- stlearn/image_preprocessing/feature_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index bc1f52f5..dd8fee92 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -78,7 +78,7 @@ def extract_feature( for i in range(1, n_spots): features = _read_and_predict(tile_paths[i], model, verbose=verbose) feature_matrix[i] = features - if i % 10 == 0: + if i % 100 == 0: pbar.update(100) adata.obsm["X_tile_feature"] = feature_matrix From c9b0e4265a869e5757d633608c8b06e046afc900 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 11 Jun 2025 14:38:14 +1000 Subject: [PATCH 045/123] Don't have plot fail if already called before. --- stlearn/plotting/classes.py | 20 +++--- tests/test_cluster_plot.py | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 tests/test_cluster_plot.py diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 18a35aec..41450bae 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -687,29 +687,25 @@ def __init__( self._save_output() def _add_cluster_colors(self): - if self.use_label + "_colors" not in self.adata[0].uns: - # self.adata[0].uns[self.use_label + "_set"] = [] - self.adata[0].uns[self.use_label + "_colors"] = [] - - for i, cluster in enumerate(self.adata[0].obs.groupby(self.use_label, - observed=True)): - self.adata[0].uns[self.use_label + "_colors"].append( - matplotlib.colors.to_hex(self.cmap_(i / (self.cmap_n - 1))) - ) - # self.adata[0].uns[self.use_label + "_set"].append( cluster[0] ) + self.adata[0].uns[self.use_label + "_colors"] = [] + + for i, cluster in enumerate(self.adata[0].obs.groupby(self.use_label, + observed=True)): + self.adata[0].uns[self.use_label + "_colors"].append( + matplotlib.colors.to_hex(self.cmap_(i / (self.cmap_n - 1))) + ) def _plot_clusters(self): # Plot scatter plot based on pixel of spots for i, cluster in enumerate( - self.query_adata.obs.groupby(self.use_label, observed=True)): + self.query_adata.obs.groupby(self.use_label, observed=True)): # Plot scatter plot based on pixel of spots subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), list(cluster[1].index)) ] if self.use_label + "_colors" in self.adata[0].uns: - # label_set = self.adata[0].uns[self.use_label+'_set'] label_set = ( self.adata[0].obs[self.use_label].cat.categories.values.astype(str) ) diff --git a/tests/test_cluster_plot.py b/tests/test_cluster_plot.py new file mode 100644 index 00000000..4f04afcd --- /dev/null +++ b/tests/test_cluster_plot.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python + +"""Tests for ClusterPlot.""" + +import unittest +from unittest.mock import patch, MagicMock +import numpy as np +import pandas as pd +import matplotlib.colors +import matplotlib.pyplot as plt + +from stlearn.plotting.classes import ClusterPlot +from .utils import read_test_data + +global adata +adata = read_test_data() + + +class TestClusterPlot(unittest.TestCase): + """Tests for ClusterPlot.""" + + def setUp(self): + """Set up test data with known clusters.""" + self.adata = adata.copy() + + # Create test clustering data + n_spots = len(self.adata.obs) + cluster_labels = np.random.choice(['Cluster_0', 'Cluster_1', 'Cluster_2'], + n_spots) + self.adata.obs['test_clusters'] = pd.Categorical(cluster_labels) + + # Ensure we have a clean slate + if 'test_clusters_colors' in self.adata.uns: + del self.adata.uns['test_clusters_colors'] + + def test_color_generation_first_call(self): + """Test that colors are generated correctly on first call.""" + with patch('matplotlib.pyplot.subplots') as mock_subplots, \ + patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ + patch.object(ClusterPlot, '_add_image'): + # Mock matplotlib components + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + + # Create ClusterPlot + label_name = 'test_clusters' + plot = ClusterPlot( + adata=self.adata, + use_label=label_name, + show_image=False, + show_color_bar=False + ) + + # Check that colors were generated + colors = plot.adata[0].uns[f"{label_name}_colors"] + self.assertIsNotNone(colors) + self.assertEqual(len(colors), 3) # 3 clusters + + # Check that all colors are valid hex colors + for color in colors: + self.assertTrue(matplotlib.colors.is_color_like(color)) + self.assertTrue(color.startswith('#')) + self.assertEqual(len(color), 7) # #RRGGBB format + + def test_multiple_calls_same_adata(self): + """Test that multiple calls with same adata work correctly.""" + with patch('matplotlib.pyplot.subplots') as mock_subplots, \ + patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ + patch.object(ClusterPlot, '_add_image'): + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + + label_name = 'test_clusters' + + # First call + plot1 = ClusterPlot( + adata=self.adata, + use_label=label_name, + show_image=False, + show_color_bar=False + ) + + # Second call with same adata + plot2 = ClusterPlot( + adata=self.adata, + use_label=label_name, + show_image=False, + show_color_bar=False + ) + + # Both should succeed and generate consistent colors + colors1 = plot1.adata[0].uns[f"{label_name}_colors"] + colors2 = plot2.adata[0].uns[f"{label_name}_colors"] + + self.assertEqual(len(colors1), len(colors2)) + self.assertEqual(colors1, colors2) + + def test_insufficient_existing_colors_extended(self): + """Test that insufficient existing colors are extended.""" + # Pre-populate adata with insufficient colors (only 2 colors for 3 clusters) + existing_colors = ['#FF0000', '#00FF00'] + label_name = 'test_clusters' + self.adata.uns[f'{label_name}_colors'] = existing_colors + + with patch('matplotlib.pyplot.subplots') as mock_subplots, \ + patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ + patch.object(ClusterPlot, '_add_image'): + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + + plot = ClusterPlot( + adata=self.adata, + use_label=label_name, + show_image=False, + show_color_bar=False + ) + + # Should extend existing colors + colors = plot.adata[0].uns[f"{label_name}_colors"] + self.assertEqual(len(colors), 3) + self.assertNotEqual(colors[:2], existing_colors) + + def tearDown(self): + """Clean up after each test.""" + # Clear any test artifacts + if hasattr(self, 'adata') and 'test_clusters_colors' in self.adata.uns: + del self.adata.uns['test_clusters_colors'] From 4200d2bb744bb8e4b0d9e10aac43d6147c5589d8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 11 Jun 2025 16:28:59 +1000 Subject: [PATCH 046/123] Fix up copy semantics. --- stlearn/preprocessing/graph.py | 10 ++++------ stlearn/tools/clustering/louvain.py | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/stlearn/preprocessing/graph.py b/stlearn/preprocessing/graph.py index ccc381ed..3f69bfee 100644 --- a/stlearn/preprocessing/graph.py +++ b/stlearn/preprocessing/graph.py @@ -7,7 +7,7 @@ from anndata import AnnData from numpy.random import RandomState -_Method = Literal["umap", "gauss", "rapids"] +_Method = Literal["umap", "gauss"] _MetricFn = Callable[[np.ndarray, np.ndarray], float] # from sklearn.metrics.pairwise_distances.__doc__: _MetricSparseCapable = Literal[ @@ -42,7 +42,7 @@ def neighbors( use_rep: str | None = None, knn: bool = True, random_state: int | RandomState | None = 0, - method: _Method | None = "umap", + method: _Method = "umap", metric: _Metric | _MetricFn = "euclidean", metric_kwds: Mapping[str, Any] = MappingProxyType({}), copy: bool = False, @@ -78,8 +78,6 @@ def neighbors( method Use 'umap' [McInnes18]_ or 'gauss' (Gauss kernel following [Coifman05]_ with adaptive width [Haghverdi16]_) for computing connectivities. - Use 'rapids' for the RAPIDS implementation of UMAP (experimental, GPU - only). metric A known metric’s name or a callable that returns a distance. metric_kwds @@ -97,7 +95,7 @@ def neighbors( neighbors. """ - scanpy.pp.neighbors( + adata = scanpy.pp.neighbors( adata, n_neighbors=n_neighbors, n_pcs=n_pcs, @@ -112,4 +110,4 @@ def neighbors( print("Created k-Nearest-Neighbor graph in adata.uns['neighbors'] ") - return adata + return adata if copy else None diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index e0426662..18bfe9e6 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -34,37 +34,37 @@ def louvain( or explicitly passing a ``adjacency`` matrix. Parameters ---------- - adata + adata: The annotated data matrix. - resolution + resolution: For the default flavor (``'vtraag'``), you can provide a resolution (higher resolution means finding more and smaller clusters), which defaults to 1.0. See “Time as a resolution parameter” in [Lambiotte09]_. - random_state + random_state: Change the initialization of the optimization. - restrict_to + restrict_to: Restrict the cluster to the categories within the key for sample annotation, tuple needs to contain ``(obs_key, list_of_categories)``. - key_added + key_added: Key under which to add the cluster labels. (default: ``'louvain'``) - adjacency + adjacency: Sparse adjacency matrix of the graph, defaults to ``adata.uns['neighbors']['connectivities']``. - flavor + flavor: Choose between to packages for computing the cluster. ``'vtraag'`` is much more powerful, and the default. - directed + directed: Interpret the ``adjacency`` matrix as directed graph? - use_weights + use_weights: Use weights from knn graph. - partition_type + partition_type: Type of partition to use. Only a valid argument if ``flavor`` is ``'vtraag'``. - partition_kwargs + partition_kwargs: Key word arguments to pass to partitioning, if ``vtraag`` method is being used. - copy + copy: Copy adata or modify it inplace. Returns ------- @@ -77,7 +77,7 @@ def louvain( When ``copy=True`` is set, a copy of ``adata`` with those fields is returned. """ - scanpy.tl.louvain( + adata = scanpy.tl.louvain( adata, resolution=resolution, random_state=random_state, @@ -97,4 +97,4 @@ def louvain( "Louvain cluster is done! The labels are stored in adata.obs['%s']" % key_added ) - return adata + return adata if copy else None \ No newline at end of file From 21f7e94f6a1d2b5470c753dd399fea00afa291d8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 08:09:49 +1000 Subject: [PATCH 047/123] Only support 3.10 for now. --- pyproject.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 84795255..4ca3d373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "A downstream analysis toolkit for Spatial Transcriptomic data" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "BSD license"} -requires-python = ">=3.10" +requires-python = "==3.10" keywords = ["stlearn"] classifiers = [ "Development Status :: 2 - Pre-Alpha", @@ -19,9 +19,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Natural Language :: English", "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", ] dynamic = ["dependencies"] From 1f5e329d45fa4f4af98ae9ee7041996678013947 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 08:12:16 +1000 Subject: [PATCH 048/123] Only support 3.10 for now. --- setup.py | 55 ------------------------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 setup.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 292288be..00000000 --- a/setup.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python - -"""The setup script.""" - -from setuptools import setup, find_packages - -with open("README.md", encoding="utf8") as readme_file: - readme = readme_file.read() - -with open("HISTORY.rst") as history_file: - history = history_file.read() - -with open("requirements.txt") as f: - requirements = f.read().splitlines() - - -setup_requirements = [] - -test_requirements = [] - -setup( - author="Genomics and Machine Learning lab", - author_email="andrew.newman@uq.edu.au", - python_requires=">=3.10", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Natural Language :: English", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - ], - description="A downstream analysis toolkit for Spatial Transcriptomic data", - entry_points={ - "console_scripts": [ - "stlearn=stlearn.app.cli:main", - ], - }, - install_requires=requirements, - license="BSD license", - long_description=readme + "\n\n" + history, - long_description_content_type="text/markdown", - include_package_data=True, - keywords="stlearn", - name="stlearn", - packages=find_packages(include=["stlearn", "stlearn.*"]), - setup_requires=setup_requirements, - test_suite="tests", - tests_require=test_requirements, - url="https://github.com/BiomedicalMachineLearning/stLearn", - version="0.4.2", - zip_safe=False, -) From db8b19d3a6abd14d278ad95404b3c3d9dff4aa08 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 08:20:14 +1000 Subject: [PATCH 049/123] Make similar to previous code. --- stlearn/adds/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index 0ae6a9f0..88847fbd 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -29,7 +29,7 @@ def parsing( # Get a map of the new coordinates new_coordinates = dict() - with open(coordinates_file) as filehandler: + with open(coordinates_file, mode='r') as filehandler: for line in filehandler.readlines(): tokens = line.split() assert len(tokens) >= 6 or len(tokens) == 4 From 7885722c507bea89bb6b8c1b4b9851cc393bcc1f Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 12:57:01 +1000 Subject: [PATCH 050/123] Make similar to previous code. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4ca3d373..3b599795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "A downstream analysis toolkit for Spatial Transcriptomic data" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "BSD license"} -requires-python = "==3.10" +requires-python = "~=3.10.0" keywords = ["stlearn"] classifiers = [ "Development Status :: 2 - Pre-Alpha", From e1d9db52c4232157539c3e419888a2853bb8800c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 13:09:32 +1000 Subject: [PATCH 051/123] Don't need to check if copy is on - just return. --- stlearn/tools/clustering/louvain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index 18bfe9e6..af28430f 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -97,4 +97,4 @@ def louvain( "Louvain cluster is done! The labels are stored in adata.obs['%s']" % key_added ) - return adata if copy else None \ No newline at end of file + return adata \ No newline at end of file From e82fd288d4b77baf8cc9a1a8fd8a51b6c08bb4ea Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 13:30:03 +1000 Subject: [PATCH 052/123] Small fixes to types and name. --- stlearn/preprocessing/log_scale.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stlearn/preprocessing/log_scale.py b/stlearn/preprocessing/log_scale.py index 2faf99cf..4a434507 100644 --- a/stlearn/preprocessing/log_scale.py +++ b/stlearn/preprocessing/log_scale.py @@ -46,11 +46,11 @@ def log1p( def scale( - adata: AnnData | np.ndarray | spmatrix, + data: AnnData | spmatrix | np.ndarray, zero_center: bool = True, max_value: float | None = None, copy: bool = False, -) -> AnnData | None: +) -> AnnData | spmatrix | np.ndarray | None: """\ Wrap function of scanpy.pp.scale @@ -61,7 +61,7 @@ def scale( the future, they might be set to NaNs. Parameters ---------- - data + data: The (annotated) data matrix of shape `n_obs` × `n_vars`. Rows correspond to cells and columns to genes. zero_center @@ -74,11 +74,11 @@ def scale( determines whether a copy is returned. Returns ------- - Depending on `copy` returns or updates `adata` with a scaled `adata.X`. + Depending on `copy` returns or updates `data` with a scaled `data.X`. """ result = scanpy.pp.scale( - adata, zero_center=zero_center, max_value=max_value, copy=copy + data, zero_center=zero_center, max_value=max_value, copy=copy ) print("Scale step is finished in adata.X") return result From fda0ca53281c8364e2e0137265c75df8dd13e16b Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 13:37:34 +1000 Subject: [PATCH 053/123] Add old one back again. --- .../image_preprocessing/feature_extractor.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index dd8fee92..6f521ec8 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -5,12 +5,89 @@ from anndata import AnnData from sklearn.decomposition import PCA from tqdm import tqdm +import pandas as pd from .model_zoo import Model _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] +def old_extract_feature( + adata: AnnData, + cnn_base: _CNN_BASE = "resnet50", + n_components: int = 50, + verbose: bool = False, + copy: bool = False, + seeds: int = 1, +) -> AnnData | None: + """\ + Extract latent morphological features from H&E images using pre-trained + convolutional neural network base + + Parameters + ---------- + adata: + Annotated data matrix. + cnn_base: + Established convolutional neural network bases + choose one from ['resnet50', 'vgg16', 'inception_v3', 'xception'] + n_components: + Number of principal components to compute for latent morphological features + verbose: + Verbose output + copy: + Return a copy instead of writing to adata. + seeds: + Fix random state + Returns + ------- + Depending on `copy`, returns or updates `adata` with the following fields. + **X_morphology** : `adata.obsm` field + Dimension reduced latent morphological features. + """ + feature_dfs = [] + model = Model(cnn_base) + + if "tile_path" not in adata.obs: + raise ValueError("Please run the function stlearn.pp.tiling") + + def encode(tiles, model): + features = model.predict(tiles) + features = features.ravel() + return features + + with tqdm( + total=len(adata), + desc="Extract feature", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + ) as pbar: + for spot, tile_path in adata.obs["tile_path"].items(): + tile = Image.open(tile_path) + tile = np.asarray(tile, dtype="int32") + tile = tile.astype(np.float32) + tile = np.stack([tile]) + if verbose: + print("extract feature for spot: {}".format(str(spot))) + features = encode(tile, model) + feature_dfs.append(pd.DataFrame(features, columns=[spot])) + pbar.update(1) + + feature_df = pd.concat(feature_dfs, axis=1) + + adata.obsm["X_tile_feature"] = feature_df.transpose().to_numpy() + + from sklearn.decomposition import PCA + + pca = PCA(n_components=n_components, random_state=seeds) + pca.fit(feature_df.transpose().to_numpy()) + + adata.obsm["X_morphology"] = pca.transform(feature_df.transpose().to_numpy()) + + print("The morphology feature is added to adata.obsm['X_morphology']!") + + return adata if copy else None + + def extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", From 9c9e1e801b3410c8be11d5f33b5f9690d28233d5 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 13:41:52 +1000 Subject: [PATCH 054/123] Add old one back again. --- stlearn/pp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stlearn/pp.py b/stlearn/pp.py index 695efd24..b91eb648 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,4 +1,4 @@ -from .image_preprocessing.feature_extractor import extract_feature +from .image_preprocessing.feature_extractor import extract_feature, old_extract_feature from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors @@ -13,4 +13,5 @@ "neighbors", "tiling", "extract_feature", + "old_extract_feature", ] From 63b4d746ca30c0bcd36fca4b7db27fdb3d3c97ba Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 14:13:11 +1000 Subject: [PATCH 055/123] Add old tiling back. --- stlearn/image_preprocessing/image_tiling.py | 104 ++++++++++++++++++-- stlearn/pp.py | 3 +- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index d782a77e..f1ffba94 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -1,12 +1,104 @@ -import os -from pathlib import Path - -import numpy as np +from typing import Optional, Union from anndata import AnnData from PIL import Image - -# Test progress bar +from pathlib import Path from tqdm import tqdm +import numpy as np +import os + + +def old_tiling( + adata: AnnData, + out_path: Union[Path, str] = "./tiling", + library_id: Union[str, None] = None, + crop_size: int = 40, + target_size: int = 299, + img_fmt: str = "JPEG", + verbose: bool = False, + copy: bool = False, +) -> Optional[AnnData]: + """\ + Tiling H&E images to small tiles based on spot spatial location + + Parameters + ---------- + adata + Annotated data matrix. + out_path + Path to save spot image tiles + library_id + Library id stored in AnnData. + crop_size + Size of tiles + verbose + Verbose output + copy + Return a copy instead of writing to adata. + target_size + Input size for convolutional neuron network + Returns + ------- + Depending on `copy`, returns or updates `adata` with the following fields. + **tile_path** : `adata.obs` field + Saved path for each spot image tiles + """ + + if library_id is None: + library_id = list(adata.uns["spatial"].keys())[0] + + # Check the exist of out_path + if not os.path.isdir(out_path): + os.mkdir(out_path) + + image = adata.uns["spatial"][library_id]["images"][ + adata.uns["spatial"][library_id]["use_quality"] + ] + if image.dtype == np.float32 or image.dtype == np.float64: + image = (image * 255).astype(np.uint8) + img_pillow = Image.fromarray(image) + + if img_pillow.mode == "RGBA": + img_pillow = img_pillow.convert("RGB") + + tile_names = [] + + with tqdm( + total=len(adata), + desc="Tiling image", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + ) as pbar: + for imagerow, imagecol in zip(adata.obs["imagerow"], adata.obs["imagecol"]): + imagerow_down = imagerow - crop_size / 2 + imagerow_up = imagerow + crop_size / 2 + imagecol_left = imagecol - crop_size / 2 + imagecol_right = imagecol + crop_size / 2 + tile = img_pillow.crop( + (imagecol_left, imagerow_down, imagecol_right, imagerow_up) + ) + tile.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + tile = tile.resize((target_size, target_size)) + tile_name = str(imagecol) + "-" + str(imagerow) + "-" + str(crop_size) + + if img_fmt == "JPEG": + out_tile = Path(out_path) / (tile_name + ".jpeg") + tile_names.append(str(out_tile)) + tile.save(out_tile, "JPEG") + else: + out_tile = Path(out_path) / (tile_name + ".png") + tile_names.append(str(out_tile)) + tile.save(out_tile, "PNG") + + if verbose: + print( + "generate tile at location ({}, {})".format( + str(imagecol), str(imagerow) + ) + ) + + pbar.update(1) + + adata.obs["tile_path"] = tile_names + return adata if copy else None def tiling( diff --git a/stlearn/pp.py b/stlearn/pp.py index b91eb648..596ab46c 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,5 +1,5 @@ from .image_preprocessing.feature_extractor import extract_feature, old_extract_feature -from .image_preprocessing.image_tiling import tiling +from .image_preprocessing.image_tiling import tiling, old_tiling from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors from .preprocessing.log_scale import log1p, scale @@ -12,6 +12,7 @@ "scale", "neighbors", "tiling", + "old_tiling", "extract_feature", "old_extract_feature", ] From 2c996a50850145b3325bf38e9730aa51abdffe5c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 14:56:56 +1000 Subject: [PATCH 056/123] Fix style, remove old methods. --- stlearn/adds/parsing.py | 2 +- .../image_preprocessing/feature_extractor.py | 91 ++-------------- stlearn/image_preprocessing/image_tiling.py | 101 +----------------- stlearn/plotting/classes.py | 8 +- stlearn/pp.py | 6 +- stlearn/spatials/trajectory/utils.py | 11 +- stlearn/tools/clustering/louvain.py | 2 +- tests/test_cluster_plot.py | 68 ++++++------ tests/test_extract_features.py | 15 ++- tests/test_tiling.py | 23 ++-- 10 files changed, 77 insertions(+), 250 deletions(-) diff --git a/stlearn/adds/parsing.py b/stlearn/adds/parsing.py index 88847fbd..0ae6a9f0 100644 --- a/stlearn/adds/parsing.py +++ b/stlearn/adds/parsing.py @@ -29,7 +29,7 @@ def parsing( # Get a map of the new coordinates new_coordinates = dict() - with open(coordinates_file, mode='r') as filehandler: + with open(coordinates_file) as filehandler: for line in filehandler.readlines(): tokens = line.split() assert len(tokens) >= 6 or len(tokens) == 4 diff --git a/stlearn/image_preprocessing/feature_extractor.py b/stlearn/image_preprocessing/feature_extractor.py index 6f521ec8..a4cf5730 100644 --- a/stlearn/image_preprocessing/feature_extractor.py +++ b/stlearn/image_preprocessing/feature_extractor.py @@ -1,100 +1,23 @@ from typing import Literal import numpy as np -from PIL import Image from anndata import AnnData +from PIL import Image from sklearn.decomposition import PCA from tqdm import tqdm -import pandas as pd from .model_zoo import Model _CNN_BASE = Literal["resnet50", "vgg16", "inception_v3", "xception"] -def old_extract_feature( +def extract_feature( adata: AnnData, cnn_base: _CNN_BASE = "resnet50", n_components: int = 50, + seeds: int = 1, verbose: bool = False, copy: bool = False, - seeds: int = 1, -) -> AnnData | None: - """\ - Extract latent morphological features from H&E images using pre-trained - convolutional neural network base - - Parameters - ---------- - adata: - Annotated data matrix. - cnn_base: - Established convolutional neural network bases - choose one from ['resnet50', 'vgg16', 'inception_v3', 'xception'] - n_components: - Number of principal components to compute for latent morphological features - verbose: - Verbose output - copy: - Return a copy instead of writing to adata. - seeds: - Fix random state - Returns - ------- - Depending on `copy`, returns or updates `adata` with the following fields. - **X_morphology** : `adata.obsm` field - Dimension reduced latent morphological features. - """ - feature_dfs = [] - model = Model(cnn_base) - - if "tile_path" not in adata.obs: - raise ValueError("Please run the function stlearn.pp.tiling") - - def encode(tiles, model): - features = model.predict(tiles) - features = features.ravel() - return features - - with tqdm( - total=len(adata), - desc="Extract feature", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - ) as pbar: - for spot, tile_path in adata.obs["tile_path"].items(): - tile = Image.open(tile_path) - tile = np.asarray(tile, dtype="int32") - tile = tile.astype(np.float32) - tile = np.stack([tile]) - if verbose: - print("extract feature for spot: {}".format(str(spot))) - features = encode(tile, model) - feature_dfs.append(pd.DataFrame(features, columns=[spot])) - pbar.update(1) - - feature_df = pd.concat(feature_dfs, axis=1) - - adata.obsm["X_tile_feature"] = feature_df.transpose().to_numpy() - - from sklearn.decomposition import PCA - - pca = PCA(n_components=n_components, random_state=seeds) - pca.fit(feature_df.transpose().to_numpy()) - - adata.obsm["X_morphology"] = pca.transform(feature_df.transpose().to_numpy()) - - print("The morphology feature is added to adata.obsm['X_morphology']!") - - return adata if copy else None - - -def extract_feature( - adata: AnnData, - cnn_base: _CNN_BASE = "resnet50", - n_components: int = 50, - seeds: int = 1, - verbose: bool = False, - copy: bool = False, ) -> AnnData | None: """\ Extract latent morphological features from H&E images using pre-trained @@ -147,10 +70,10 @@ def extract_feature( feature_matrix[0] = first_features with tqdm( - total=n_spots, - desc="Extract feature", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - initial=1, # We already processed the first image + total=n_spots, + desc="Extract feature", + bar_format="{l_bar}{bar} [ time left: {remaining} ]", + initial=1, # We already processed the first image ) as pbar: for i in range(1, n_spots): features = _read_and_predict(tile_paths[i], model, verbose=verbose) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index f1ffba94..302d3f23 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -1,104 +1,9 @@ -from typing import Optional, Union +from pathlib import Path + +import numpy as np from anndata import AnnData from PIL import Image -from pathlib import Path from tqdm import tqdm -import numpy as np -import os - - -def old_tiling( - adata: AnnData, - out_path: Union[Path, str] = "./tiling", - library_id: Union[str, None] = None, - crop_size: int = 40, - target_size: int = 299, - img_fmt: str = "JPEG", - verbose: bool = False, - copy: bool = False, -) -> Optional[AnnData]: - """\ - Tiling H&E images to small tiles based on spot spatial location - - Parameters - ---------- - adata - Annotated data matrix. - out_path - Path to save spot image tiles - library_id - Library id stored in AnnData. - crop_size - Size of tiles - verbose - Verbose output - copy - Return a copy instead of writing to adata. - target_size - Input size for convolutional neuron network - Returns - ------- - Depending on `copy`, returns or updates `adata` with the following fields. - **tile_path** : `adata.obs` field - Saved path for each spot image tiles - """ - - if library_id is None: - library_id = list(adata.uns["spatial"].keys())[0] - - # Check the exist of out_path - if not os.path.isdir(out_path): - os.mkdir(out_path) - - image = adata.uns["spatial"][library_id]["images"][ - adata.uns["spatial"][library_id]["use_quality"] - ] - if image.dtype == np.float32 or image.dtype == np.float64: - image = (image * 255).astype(np.uint8) - img_pillow = Image.fromarray(image) - - if img_pillow.mode == "RGBA": - img_pillow = img_pillow.convert("RGB") - - tile_names = [] - - with tqdm( - total=len(adata), - desc="Tiling image", - bar_format="{l_bar}{bar} [ time left: {remaining} ]", - ) as pbar: - for imagerow, imagecol in zip(adata.obs["imagerow"], adata.obs["imagecol"]): - imagerow_down = imagerow - crop_size / 2 - imagerow_up = imagerow + crop_size / 2 - imagecol_left = imagecol - crop_size / 2 - imagecol_right = imagecol + crop_size / 2 - tile = img_pillow.crop( - (imagecol_left, imagerow_down, imagecol_right, imagerow_up) - ) - tile.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) - tile = tile.resize((target_size, target_size)) - tile_name = str(imagecol) + "-" + str(imagerow) + "-" + str(crop_size) - - if img_fmt == "JPEG": - out_tile = Path(out_path) / (tile_name + ".jpeg") - tile_names.append(str(out_tile)) - tile.save(out_tile, "JPEG") - else: - out_tile = Path(out_path) / (tile_name + ".png") - tile_names.append(str(out_tile)) - tile.save(out_tile, "PNG") - - if verbose: - print( - "generate tile at location ({}, {})".format( - str(imagecol), str(imagerow) - ) - ) - - pbar.update(1) - - adata.obs["tile_path"] = tile_names - return adata if copy else None def tiling( diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 41450bae..343bdc93 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -689,8 +689,9 @@ def __init__( def _add_cluster_colors(self): self.adata[0].uns[self.use_label + "_colors"] = [] - for i, cluster in enumerate(self.adata[0].obs.groupby(self.use_label, - observed=True)): + for i, cluster in enumerate( + self.adata[0].obs.groupby(self.use_label, observed=True) + ): self.adata[0].uns[self.use_label + "_colors"].append( matplotlib.colors.to_hex(self.cmap_(i / (self.cmap_n - 1))) ) @@ -699,7 +700,8 @@ def _plot_clusters(self): # Plot scatter plot based on pixel of spots for i, cluster in enumerate( - self.query_adata.obs.groupby(self.use_label, observed=True)): + self.query_adata.obs.groupby(self.use_label, observed=True) + ): # Plot scatter plot based on pixel of spots subset_spatial = self.query_adata.obsm["spatial"][ check_sublist(list(self.query_adata.obs.index), list(cluster[1].index)) diff --git a/stlearn/pp.py b/stlearn/pp.py index 596ab46c..695efd24 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,5 +1,5 @@ -from .image_preprocessing.feature_extractor import extract_feature, old_extract_feature -from .image_preprocessing.image_tiling import tiling, old_tiling +from .image_preprocessing.feature_extractor import extract_feature +from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors from .preprocessing.log_scale import log1p, scale @@ -12,7 +12,5 @@ "scale", "neighbors", "tiling", - "old_tiling", "extract_feature", - "old_extract_feature", ] diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatials/trajectory/utils.py index ce99e9b5..d8cc4277 100644 --- a/stlearn/spatials/trajectory/utils.py +++ b/stlearn/spatials/trajectory/utils.py @@ -1,6 +1,7 @@ +import warnings + import networkx as nx import numpy as np -import warnings from numpy import linalg as la from scipy import linalg as spla from scipy import sparse as sps @@ -528,9 +529,7 @@ def _mat_mat_corr_sparse( y_std = np.reshape(np.std(Y, axis=0), (1, -1)) with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", r"invalid value encountered in true_divide" - ) + warnings.filterwarnings("ignore", r"invalid value encountered in true_divide") return (X @ Y - (n * X_bar * y_bar)) / ((n - 1) * X_std * y_std) @@ -604,9 +603,7 @@ def _mat_mat_corr_dense(X: np.ndarray, Y: np.ndarray) -> np.ndarray: y_std = np.reshape(np_std(Y, axis=0), (1, -1)) with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", r"invalid value encountered in true_divide" - ) + warnings.filterwarnings("ignore", r"invalid value encountered in true_divide") return (X @ Y - (n * X_bar * y_bar)) / ((n - 1) * X_std * y_std) diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tools/clustering/louvain.py index af28430f..78e973dd 100644 --- a/stlearn/tools/clustering/louvain.py +++ b/stlearn/tools/clustering/louvain.py @@ -97,4 +97,4 @@ def louvain( "Louvain cluster is done! The labels are stored in adata.obs['%s']" % key_added ) - return adata \ No newline at end of file + return adata diff --git a/tests/test_cluster_plot.py b/tests/test_cluster_plot.py index 4f04afcd..3d9280f8 100644 --- a/tests/test_cluster_plot.py +++ b/tests/test_cluster_plot.py @@ -3,13 +3,14 @@ """Tests for ClusterPlot.""" import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + +import matplotlib.colors import numpy as np import pandas as pd -import matplotlib.colors -import matplotlib.pyplot as plt from stlearn.plotting.classes import ClusterPlot + from .utils import read_test_data global adata @@ -25,30 +26,33 @@ def setUp(self): # Create test clustering data n_spots = len(self.adata.obs) - cluster_labels = np.random.choice(['Cluster_0', 'Cluster_1', 'Cluster_2'], - n_spots) - self.adata.obs['test_clusters'] = pd.Categorical(cluster_labels) + cluster_labels = np.random.choice( + ["Cluster_0", "Cluster_1", "Cluster_2"], n_spots + ) + self.adata.obs["test_clusters"] = pd.Categorical(cluster_labels) # Ensure we have a clean slate - if 'test_clusters_colors' in self.adata.uns: - del self.adata.uns['test_clusters_colors'] + if "test_clusters_colors" in self.adata.uns: + del self.adata.uns["test_clusters_colors"] def test_color_generation_first_call(self): """Test that colors are generated correctly on first call.""" - with patch('matplotlib.pyplot.subplots') as mock_subplots, \ - patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ - patch.object(ClusterPlot, '_add_image'): + with ( + patch("matplotlib.pyplot.subplots") as mock_subplots, + patch.object(ClusterPlot, "_plot_clusters") as _, + patch.object(ClusterPlot, "_add_image"), + ): # Mock matplotlib components mock_fig, mock_ax = MagicMock(), MagicMock() mock_subplots.return_value = (mock_fig, mock_ax) # Create ClusterPlot - label_name = 'test_clusters' + label_name = "test_clusters" plot = ClusterPlot( adata=self.adata, use_label=label_name, show_image=False, - show_color_bar=False + show_color_bar=False, ) # Check that colors were generated @@ -59,25 +63,27 @@ def test_color_generation_first_call(self): # Check that all colors are valid hex colors for color in colors: self.assertTrue(matplotlib.colors.is_color_like(color)) - self.assertTrue(color.startswith('#')) + self.assertTrue(color.startswith("#")) self.assertEqual(len(color), 7) # #RRGGBB format def test_multiple_calls_same_adata(self): """Test that multiple calls with same adata work correctly.""" - with patch('matplotlib.pyplot.subplots') as mock_subplots, \ - patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ - patch.object(ClusterPlot, '_add_image'): + with ( + patch("matplotlib.pyplot.subplots") as mock_subplots, + patch.object(ClusterPlot, "_plot_clusters") as _, + patch.object(ClusterPlot, "_add_image"), + ): mock_fig, mock_ax = MagicMock(), MagicMock() mock_subplots.return_value = (mock_fig, mock_ax) - label_name = 'test_clusters' + label_name = "test_clusters" # First call plot1 = ClusterPlot( adata=self.adata, use_label=label_name, show_image=False, - show_color_bar=False + show_color_bar=False, ) # Second call with same adata @@ -85,7 +91,7 @@ def test_multiple_calls_same_adata(self): adata=self.adata, use_label=label_name, show_image=False, - show_color_bar=False + show_color_bar=False, ) # Both should succeed and generate consistent colors @@ -98,13 +104,15 @@ def test_multiple_calls_same_adata(self): def test_insufficient_existing_colors_extended(self): """Test that insufficient existing colors are extended.""" # Pre-populate adata with insufficient colors (only 2 colors for 3 clusters) - existing_colors = ['#FF0000', '#00FF00'] - label_name = 'test_clusters' - self.adata.uns[f'{label_name}_colors'] = existing_colors - - with patch('matplotlib.pyplot.subplots') as mock_subplots, \ - patch.object(ClusterPlot, '_plot_clusters') as mock_plot, \ - patch.object(ClusterPlot, '_add_image'): + existing_colors = ["#FF0000", "#00FF00"] + label_name = "test_clusters" + self.adata.uns[f"{label_name}_colors"] = existing_colors + + with ( + patch("matplotlib.pyplot.subplots") as mock_subplots, + patch.object(ClusterPlot, "_plot_clusters") as _, + patch.object(ClusterPlot, "_add_image"), + ): mock_fig, mock_ax = MagicMock(), MagicMock() mock_subplots.return_value = (mock_fig, mock_ax) @@ -112,7 +120,7 @@ def test_insufficient_existing_colors_extended(self): adata=self.adata, use_label=label_name, show_image=False, - show_color_bar=False + show_color_bar=False, ) # Should extend existing colors @@ -123,5 +131,5 @@ def test_insufficient_existing_colors_extended(self): def tearDown(self): """Clean up after each test.""" # Clear any test artifacts - if hasattr(self, 'adata') and 'test_clusters_colors' in self.adata.uns: - del self.adata.uns['test_clusters_colors'] + if hasattr(self, "adata") and "test_clusters_colors" in self.adata.uns: + del self.adata.uns["test_clusters_colors"] diff --git a/tests/test_extract_features.py b/tests/test_extract_features.py index 6a6651f3..baaa2d06 100644 --- a/tests/test_extract_features.py +++ b/tests/test_extract_features.py @@ -1,12 +1,13 @@ #!/usr/bin/env python -import unittest -import numpy as np -import tempfile -import shutil import os +import shutil +import tempfile +import unittest +import numpy as np import scanpy as sc + import stlearn as st from .utils import read_test_data @@ -41,15 +42,14 @@ def test_deterministic_behavior(self): np.testing.assert_array_equal( data1.obsm["X_morphology"], data2.obsm["X_morphology"], - err_msg="Results should be deterministic with same seed" + err_msg="Results should be deterministic with same seed", ) np.testing.assert_array_equal( data1.obsm["X_tile_feature"], data2.obsm["X_tile_feature"], - err_msg="Results should be deterministic with same seed" + err_msg="Results should be deterministic with same seed", ) - def test_copy_behavior(self): """Test copy=True vs copy=False behavior.""" original_data = self.test_data.copy() @@ -64,4 +64,3 @@ def test_copy_behavior(self): result_inplace = st.pp.extract_feature(original_data, copy=False) self.assertIsNone(result_inplace) self.assertIn("X_morphology", original_data.obsm) - diff --git a/tests/test_tiling.py b/tests/test_tiling.py index 90fb2df8..fa6ce0cc 100644 --- a/tests/test_tiling.py +++ b/tests/test_tiling.py @@ -1,21 +1,15 @@ - # !/usr/bin/env python """Tests for tiling function.""" +import os +import shutil +import tempfile import unittest -import time -import numpy as np -import pandas as pd from pathlib import Path -import tempfile -import shutil -import os -from PIL import Image -from unittest.mock import patch, MagicMock -import filecmp -import scanpy as sc +import numpy as np + import stlearn as st from .utils import read_test_data @@ -61,8 +55,9 @@ def test_directory_creation(self): st.pp.tiling(data, nested_path) self.assertTrue(nested_path.exists(), "Nested directories not created") - self.assertGreater(len(list(nested_path.glob("*"))), 0, - "No files in nested directory") + self.assertGreater( + len(list(nested_path.glob("*"))), 0, "No files in nested directory" + ) def test_quality_parameter(self): """Test JPEG quality parameter.""" @@ -79,4 +74,4 @@ def test_quality_parameter(self): jpeg_files = list(Path(temp_quality).glob("*.jpeg")) self.assertEqual(len(jpeg_files), len(data)) - shutil.rmtree(temp_quality) \ No newline at end of file + shutil.rmtree(temp_quality) From cdc37a0e5916bcd9d1fc9bc270eea76935cb4fee Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 15:37:21 +1000 Subject: [PATCH 057/123] Fix documentation. --- stlearn/spatials/morphology/adjust.py | 29 +++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 8ec70950..2c68bcc2 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -6,16 +6,16 @@ from tqdm import tqdm _SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] - +_METHOD = Literal["mean", "median", "sum"] def adjust( adata: AnnData, use_data: str = "X_pca", radius: float = 50.0, rates: int = 1, - method="mean", - copy: bool = False, + method: _SIMILARITY_MATRIX = "mean", similarity_matrix: _SIMILARITY_MATRIX = "cosine", + copy: bool = False, ) -> AnnData | None: """\ SME normalisation: Using spot location information and tissue morphological @@ -23,23 +23,22 @@ def adjust( Parameters ---------- - adata + adata : AnnData Annotated data matrix. - use_data + use_data : str, default "X_pca" Input date to be adjusted by morphological features. choose one from ["raw", "X_pca", "X_umap"] - radius + radius: float, default 50.0 Radius to select neighbour spots. - rates - Strength for adjustment. - method - Method for disk smoothing. - choose one from ["means", "median"] - copy + rates: int, default 1 + Number of times to add the aggregated neighbor contribution. + Higher values increase the strength of morphological adjustment. + method: {'mean', 'median', 'sum'}, default 'mean' + Method for aggregating neighbor contributions. + similarity_matrix : {'cosine', 'euclidean', 'pearson', 'spearman'}, default 'cosine' + Method to calculate morphological similarity between spots. + copy : bool, default False Return a copy instead of writing to adata. - similarity_matrix - Matrix to calculate morphological similarity of two spots - choose one from ["cosine", "euclidean", "pearson", "spearman"] Returns ------- Depending on `copy`, returns or updates `adata` with the following fields. From d980bdadd636b069023f726bfe290928aeffc79f Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 12 Jun 2025 15:42:51 +1000 Subject: [PATCH 058/123] Update. --- .github/workflows/pre-commit.yml | 14 -------------- .github/workflows/python-package.yml | 7 ++----- 2 files changed, 2 insertions(+), 19 deletions(-) delete mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index 72334791..00000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [master] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.0 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index cb7120fa..3f862acd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8] + python-version: [3.10.18] steps: - uses: actions/checkout@v2 @@ -27,10 +27,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install pytest - - pip install leidenalg if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install louvain - name: Test with pytest run: | - pytest + pytest \ No newline at end of file From 49aa9c624f5eeea8f8ba5f3e67ac5d737a7f92f1 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 13 Jun 2025 16:46:13 +1000 Subject: [PATCH 059/123] Add more quality control steps. --- .github/workflows/python-package.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3f862acd..5f5cc50d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -28,6 +28,13 @@ jobs: python -m pip install --upgrade pip python -m pip install pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Check style + run: | + black stlearn tests + ruff check stlearn tests + - name: Check types + run: | + mypy stlearn tests - name: Test with pytest run: | pytest \ No newline at end of file From 45cb689fa921b5696e69f18788f0488de1e66839 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 15 Jun 2025 09:45:18 +1000 Subject: [PATCH 060/123] Add some checks and refactor into methods. --- stlearn/image_preprocessing/image_tiling.py | 140 ++++++++++++-------- 1 file changed, 85 insertions(+), 55 deletions(-) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index 302d3f23..547d4877 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -13,33 +13,34 @@ def tiling( crop_size: int = 40, target_size: int = 299, img_fmt: str = "JPEG", - quality: int = 95, + quality: int = 75, verbose: bool = False, copy: bool = False, ) -> AnnData | None: """\ - Tiling H&E images to small tiles based on spot spatial location + Tiling H&E images to small tiles based on spot spatial location. Parameters ---------- - adata: - Annotated data matrix. - out_path: - Path to save spot image tiles - library_id: - Library id stored in AnnData. - crop_size: - Size of tiles - target_size: - Input size for convolutional neuron network - img_fmt: - Image format ('JPEG' or 'PNG') - quality: - JPEG quality 1-100. - verbose: - Verbose output - copy: - Return a copy instead of writing to adata. + adata: AnnData + Annotated data matrix containing spatial information. + out_path: Path or str, default "./tiling" + Path to save spot image tiles. + library_id: str, optional + Library id stored in AnnData. If None, uses first available library. + crop_size: int, default 40 + Size of tiles to crop from original image. + target_size: int, default 299 + Target size for resized tiles (input size for CNN). + img_fmt: str, default "JPEG" + Image format ('JPEG' or 'PNG'). + quality: int, default 75 + JPEG quality (1-100). Only used for JPEG format. + verbose: bool, default False + Enable verbose output. + copy: bool, default False + Return a copy instead of modifying adata in-place. + Returns ------- Depending on `copy`, returns or updates `adata` with the following fields. @@ -47,39 +48,17 @@ def tiling( Saved path for each spot image tiles """ - adata = adata.copy() if copy else adata - - if not isinstance(crop_size, int) or crop_size <= 0: - raise ValueError("crop_size must be a positive integer") - if not isinstance(target_size, int) or target_size <= 0: - raise ValueError("target_size must be a positive integer") - if img_fmt.upper() not in ["JPEG", "PNG"]: - raise ValueError("img_fmt must be 'JPEG' or 'PNG'") + _validate_inputs(crop_size, target_size, img_fmt, quality) - if library_id is None: - library_id = list(adata.uns["spatial"].keys())[0] + adata = adata.copy() if copy else adata out_path = Path(out_path) out_path.mkdir(parents=True, exist_ok=True) - # Load and prepare image - try: - image = adata.uns["spatial"][library_id]["images"][ - adata.uns["spatial"][library_id]["use_quality"] - ] - except KeyError as e: - raise ValueError(f"Could not find image data in adata.uns['spatial']: {e}") + library_id = _get_library_id(adata, library_id) + img_pillow = _load_and_prepare_image(adata, library_id) - if image.dtype in (np.float32, np.float64): - image = np.clip(image, 0, 1) - image = (image * 255).astype(np.uint8) - - img_pillow = Image.fromarray(image) - - if img_pillow.mode == "RGBA": - img_pillow = img_pillow.convert("RGB") - - coordinates = list(zip(adata.obs["imagerow"], adata.obs["imagecol"])) + coordinates = list(zip(adata.obs["image_row"], adata.obs["image_col"])) tile_names = [] @@ -88,20 +67,20 @@ def tiling( desc="Tiling image", bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: - for imagerow, imagecol in coordinates: + for image_row, image_col in coordinates: half_crop = crop_size // 2 - imagerow_down = max(0, imagerow - half_crop) - imagerow_up = imagerow + half_crop - imagecol_left = max(0, imagecol - half_crop) - imagecol_right = imagecol + half_crop + image_row_down = max(0, image_row - half_crop) + image_row_up = image_row + half_crop + image_col_left = max(0, image_col - half_crop) + image_col_right = image_col + half_crop tile = img_pillow.crop( - (imagecol_left, imagerow_down, imagecol_right, imagerow_up) + (image_col_left, image_row_down, image_col_right, image_row_up) ) tile.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) tile = tile.resize((target_size, target_size)) - tile_name = str(imagecol) + "-" + str(imagerow) + "-" + str(crop_size) + tile_name = str(image_col) + "-" + str(image_row) + "-" + str(crop_size) if img_fmt == "JPEG": out_tile = Path(out_path) / (tile_name + ".jpeg") @@ -113,9 +92,60 @@ def tiling( tile.save(out_tile, "PNG") if verbose: - print(f"generate tile at location ({str(imagecol)}, {str(imagerow)})") + print(f"generate tile at location ({str(image_col)}, {str(image_row)})") pbar.update(1) adata.obs["tile_path"] = tile_names return adata if copy else None + + +def _validate_inputs(crop_size: int, target_size: int, img_fmt: str, + quality: int) -> None: + + if not isinstance(crop_size, int) or crop_size <= 0: + raise ValueError("crop_size must be a positive integer") + + if not isinstance(target_size, int) or target_size <= 0: + raise ValueError("target_size must be a positive integer") + + if img_fmt.upper() not in ["JPEG", "PNG"]: + raise ValueError("img_fmt must be 'JPEG' or 'PNG'") + + if img_fmt.upper() == "JPEG" and ( + not isinstance(quality, int) or not 1 <= quality <= 100): + raise ValueError("quality must be an integer between 1 and 100 for JPEG format") + + +def _get_library_id(adata: AnnData, library_id: str | None) -> str: + if library_id is None: + try: + library_id = list(adata.uns["spatial"].keys())[0] + except (KeyError, IndexError): + raise ValueError("No spatial data found in adata.uns['spatial']") + + if library_id not in adata.uns["spatial"]: + raise ValueError(f"Library '{library_id}' not found in spatial data") + + return library_id + + +def _load_and_prepare_image(adata: AnnData, library_id: str) -> Image.Image: + try: + spatial_data = adata.uns["spatial"][library_id] + use_quality = spatial_data["use_quality"] + image = spatial_data["images"][use_quality] + except KeyError as e: + raise ValueError( + f"Could not find image data in adata.uns['spatial']['{library_id}']: {e}") + + if image.dtype in (np.float32, np.float64): + image = np.clip(image, 0, 1) + image = (image * 255).astype(np.uint8) + + img_pillow = Image.fromarray(image) + + if img_pillow.mode == "RGBA": + img_pillow = img_pillow.convert("RGB") + + return img_pillow \ No newline at end of file From a07552f7948b7072f9b9b8adcb9783ecd2e4e320 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 23 Jun 2025 13:54:58 +1000 Subject: [PATCH 061/123] Small fixes. --- stlearn/spatials/clustering/localization.py | 15 ++- stlearn/spatials/trajectory/local_level.py | 14 +- .../spatials/trajectory/pseudotimespace.py | 31 +++-- .../trajectory/weight_optimization.py | 122 ++++++++++-------- 4 files changed, 99 insertions(+), 83 deletions(-) diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index 1fd17129..a599d4e1 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -17,19 +17,20 @@ def localization( Parameters ---------- - adata + adata: AnnData Annotated data matrix. - use_label + use_label: str, default = "louvain" Use label result of cluster method. - eps + eps: The maximum distance between two samples for one to be considered as in the neighborhood of the other. This is not a maximum bound on the distances of points within a cluster. This is the most important DBSCAN parameter to choose appropriately for your data set and distance function. - min_samples + min_samples: The number of samples (or total weight) in a neighborhood for a point to be - considered as a core point. This includes the point itself. - copy + considered as a core point. This includes the point itself. Passed into DBSCAN's + min_samples parameter. + copy: Return a copy instead of writing to adata. Returns ------- @@ -46,7 +47,7 @@ def localization( for i in adata.obs[use_label].unique(): tmp = adata.obs[adata.obs[use_label] == i] - clustering = DBSCAN(eps=eps, min_samples=1, algorithm="kd_tree").fit( + clustering = DBSCAN(eps=eps, min_samples=min_samples, algorithm="kd_tree").fit( tmp[["imagerow", "imagecol"]] ) diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatials/trajectory/local_level.py index a0490b88..56f1b3ec 100644 --- a/stlearn/spatials/trajectory/local_level.py +++ b/stlearn/spatials/trajectory/local_level.py @@ -16,18 +16,18 @@ def local_level( Parameters ---------- - adata + adata: Annotated data matrix. - use_label + use_label: Use label result of cluster method. - cluster + cluster: Choose cluster to perform local spatial trajectory inference. - threshold - Threshold to find the significant connection for PAGA graph. - w + w: float, default=0.5 Pseudo-spatio-temporal distance weight (balance between spatial effect and DPT) - return_matrix + return_matrix: Return PTS matrix for local level + verbose : bool, default=True + Whether to print progress information. Returns ------- np.ndarray: the STDM (spatio-temporal distance matrix) - weighted combination of diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatials/trajectory/pseudotimespace.py index acd52725..60573d10 100644 --- a/stlearn/spatials/trajectory/pseudotimespace.py +++ b/stlearn/spatials/trajectory/pseudotimespace.py @@ -1,3 +1,5 @@ +from typing import Literal + from anndata import AnnData from .global_level import global_level @@ -11,7 +13,7 @@ def pseudotimespace_global( use_rep: str = "X_pca", n_dims: int = 40, list_clusters=None, - model: str = "spatial", + model: Literal["spatial", "gene_expression", "mixed"] = "spatial", step=0.01, k=10, ) -> AnnData | None: @@ -20,23 +22,24 @@ def pseudotimespace_global( Parameters ---------- - adata: + adata: AnnData Annotated data matrix. - use_label: + use_label: str, default = "louvain" Use label result of cluster method. - use_rep: + use_rep: str, default = "X_pca" Which obsm location to use. - n_dims: + n_dims: int, default = 40 Number of dimensions to use in PCA - list_clusters: - List of cluster used to reconstruct spatial trajectory. - model: + list_clusters: list, optional + List of cluster used to reconstruct spatial trajectory. If None, uses all + clusters. + model: Literal["spatial", "gene_expression", "mixed"] = "mixed", Can be mixed, spatial or gene expression. spatial sets weight to 0, gene expression sets weight to 1 and mixed uses the list_clusters, step and k. - step: - Step for screening weighting factor - k - The number of eigenvalues to be compared + step: float, default = 0.01 + Step for screening weighting factor. + k: int, default = 10 + The number of eigenvalues to be compared. Returns ------- Anndata @@ -82,9 +85,9 @@ def pseudotimespace_local( Parameters ---------- - adata: + adata: AnnData Annotated data matrix. - use_label: + use_label: str, default = "louvain" Use label result of cluster method. cluster: Cluster used to reconstruct intra regional spatial trajectory. diff --git a/stlearn/spatials/trajectory/weight_optimization.py b/stlearn/spatials/trajectory/weight_optimization.py index 9787d628..ff4e7385 100644 --- a/stlearn/spatials/trajectory/weight_optimization.py +++ b/stlearn/spatials/trajectory/weight_optimization.py @@ -1,6 +1,7 @@ import networkx as nx import numpy as np import pandas as pd +from anndata import AnnData from tqdm import tqdm from .global_level import global_level @@ -9,40 +10,62 @@ def weight_optimizing_global( - adata, - use_label=None, + adata: AnnData, + use_label: str = "louvain", list_clusters=None, step=0.01, k=10, use_rep="X_pca", n_dims=40, ): + if k <= 0: + raise ValueError(f"k must be positive, got {k}") + + # Determine effective k value based on available sub-clusters + actual_k = k + if use_label and list_clusters: + if "sub_cluster_labels" not in adata.obs.columns: + print("Warning: 'sub_cluster_labels' column not found. Using provided " + + "k value.") + else: + try: + filtered_data = adata.obs[adata.obs[use_label].isin(list_clusters)] + if len(filtered_data) == 0: + raise ValueError(f"No cells found for clusters {list_clusters} " + + "in column '{use_label}'") + + # Minimum 1 cluster, use K or max available sub-clusters + n_subclusters = len(filtered_data["sub_cluster_labels"].unique()) + actual_k = max(1, min(k, n_subclusters)) + + if actual_k != k: + print(f"Adjusted k from {k} to {actual_k} based on available " + + "sub-clusters ({n_subclusters})") + + except Exception as e: + print(f"Warning: Could not determine sub-cluster count: {e}. " + + "Using provided k value.") + actual_k = k + # Screening PTS graph print("Screening PTS global graph...") Gs = [] j = 0 - + total_iterations = int(1 / step + 1) with tqdm( - total=int(1 / step + 1), + total=total_iterations, desc="Screening", bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: - for i in range(0, int(1 / step + 1)): + for i in range(0, total_iterations): + weight = round(i * step, 2) + matrix = global_level(adata, use_label=use_label, + list_clusters=list_clusters, use_rep=use_rep, + n_dims=n_dims, w=weight, return_graph=True, + verbose=False, ) Gs.append( - nx.to_scipy_sparse_array( - global_level( - adata, - use_label=use_label, - list_clusters=list_clusters, - use_rep=use_rep, - n_dims=n_dims, - w=round(j, 2), - return_graph=True, - verbose=False, - ) - ) + nx.to_scipy_sparse_array(matrix) ) - j = j + step pbar.update(1) @@ -51,13 +74,8 @@ def weight_optimizing_global( result = [] a1_list = [] a2_list = [] - indx = [] + index = [] w = 0 - k = len( - adata.obs[adata.obs[use_label].isin(list_clusters)][ - "sub_cluster_labels" - ].unique() - ) with tqdm( total=int(1 / step - 1), desc="Calculating", @@ -65,28 +83,25 @@ def weight_optimizing_global( ) as pbar: for i in range(1, int(1 / step)): w += step - a1 = lambda_dist(Gs[i], Gs[0], k=k) - a2 = lambda_dist(Gs[i], Gs[-1], k=k) + a1 = lambda_dist(Gs[i], Gs[0], k=actual_k) + a2 = lambda_dist(Gs[i], Gs[-1], k=actual_k) a1_list.append(a1) a2_list.append(a2) - indx.append(w) + index.append(w) result.append(np.absolute(1 - a1 / a2)) pbar.update(1) screening_result = pd.DataFrame( - {"w": indx, "A1": a1_list, "A2": a2_list, "Dissmilarity_Score": result} + {"w": index, "A1": a1_list, "A2": a2_list, "Dissmilarity_Score": result} ) adata.uns["screening_result_global"] = screening_result - def NormalizeData(data): - return (data - np.min(data)) / (np.max(data) - np.min(data)) - - result = NormalizeData(result) + normalised_result = normalize_data(result) try: - optimized_ind = np.where(result == np.amin(result))[0][0] - opt_w = round(indx[optimized_ind], 2) + optimized_ind = np.where(normalised_result == np.amin(normalised_result))[0][0] + opt_w = round(index[optimized_ind], 2) print("The optimized weighting is:", str(opt_w)) return opt_w except: @@ -94,7 +109,10 @@ def NormalizeData(data): return 0.5 -def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): +def weight_optimizing_local(adata: AnnData, + use_label: str = "louvain", + cluster=None, + step=0.01): # Screening PTS graph print("Screening PTS local graph...") Gs = [] @@ -105,17 +123,9 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): - Gs.append( - local_level( - adata, - use_label=use_label, - cluster=cluster, - w=round(j, 2), - verbose=False, - return_matrix=True, - ) - ) - + matrix = local_level(adata, use_label=use_label, cluster=cluster, + w=round(j, 2), verbose=False, return_matrix=True) + Gs.append(matrix) j = j + step pbar.update(1) @@ -124,7 +134,7 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): result = [] a1_list = [] a2_list = [] - indx = [] + index = [] w = 0 with tqdm( @@ -138,23 +148,25 @@ def weight_optimizing_local(adata, use_label=None, cluster=None, step=0.01): a2 = resistance_distance(Gs[i], Gs[-1]) a1_list.append(a1) a2_list.append(a2) - indx.append(w) + index.append(w) result.append(np.absolute(1 - a1 / a2)) pbar.update(1) screening_result = pd.DataFrame( - {"w": indx, "A1": a1_list, "A2": a2_list, "Dissmilarity_Score": result} + {"w": index, "A1": a1_list, "A2": a2_list, "Dissmilarity_Score": result} ) adata.uns["screening_result_local"] = screening_result - def NormalizeData(data): - return (data - np.min(data)) / (np.max(data) - np.min(data)) - - result = NormalizeData(result) + normalised_result = normalize_data(result) - optimized_ind = np.where(result == np.amin(result))[0][0] - opt_w = round(indx[optimized_ind], 2) + optimized_ind = np.where(normalised_result == np.amin(normalised_result))[0][0] + opt_w = round(index[optimized_ind], 2) print("The optimized weighting is:", str(opt_w)) return opt_w + + +def normalize_data(data): + return (data - np.min(data)) / (np.max(data) - np.min(data)) + From e705862b1ebda6d8cb1829829161f2715cb99c61 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 23 Jun 2025 16:02:14 +1000 Subject: [PATCH 062/123] Add filter_cells wrapper. --- stlearn/pp.py | 2 + stlearn/preprocessing/filter_cells.py | 62 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 stlearn/preprocessing/filter_cells.py diff --git a/stlearn/pp.py b/stlearn/pp.py index 695efd24..5e5d4df0 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,11 +1,13 @@ from .image_preprocessing.feature_extractor import extract_feature from .image_preprocessing.image_tiling import tiling from .preprocessing.filter_genes import filter_genes +from .preprocessing.filter_cells import filter_cells from .preprocessing.graph import neighbors from .preprocessing.log_scale import log1p, scale from .preprocessing.normalize import normalize_total __all__ = [ + "filter_cells", "filter_genes", "normalize_total", "log1p", diff --git a/stlearn/preprocessing/filter_cells.py b/stlearn/preprocessing/filter_cells.py new file mode 100644 index 00000000..6736c5a0 --- /dev/null +++ b/stlearn/preprocessing/filter_cells.py @@ -0,0 +1,62 @@ +import numpy as np +import scanpy +from anndata import AnnData + + +def filter_cells( + adata: AnnData, + min_counts: int | None = None, + min_genes: int | None = None, + max_counts: int | None = None, + max_genes: int | None = None, + inplace: bool = True, +) -> AnnData | None | tuple[np.ndarray, np.ndarray]: + """\ + Wrap function scanpy.pp.filter_cells + + Filter cell outliers based on counts and numbers of genes expressed. + + For instance, only keep cells with at least `min_counts` counts or + `min_genes` genes expressed. This is to filter measurement outliers, + i.e. “unreliable” observations. + + Only provide one of the optional parameters `min_counts`, `min_genes`, + `max_counts`, `max_genes` per call. + + Parameters + ---------- + adata + The (annotated) data matrix of shape `n_obs` × `n_vars`. + Rows correspond to cells and columns to genes. + min_counts + Minimum number of counts required for a cell to pass filtering. + min_genes + Minimum number of genes expressed required for a cell to pass filtering. + max_counts + Maximum number of counts required for a cell to pass filtering. + max_genes + Maximum number of genes expressed required for a cell to pass filtering. + inplace + Perform computation inplace or return result. + + Returns + ------- + Depending on `inplace`, returns the following arrays or directly subsets + and annotates the data matrix: + + cells_subset + Boolean index mask that does filtering. `True` means that the + cell is kept. `False` means the cell is removed. + number_per_cell + Depending on what was thresholded (`counts` or `genes`), + the array stores `n_counts` or `n_cells` per gene. + """ + + return scanpy.pp.filter_cells( + adata, + min_counts=min_counts, + min_genes=min_genes, + max_counts=max_counts, + max_genes=max_genes, + inplace=inplace, + ) From abad4f5d3edc08c3519ccd91c5aad4823bd9c0e3 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 23 Jun 2025 16:33:55 +1000 Subject: [PATCH 063/123] Fix default parameters and add documentation. --- stlearn/spatials/clustering/localization.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index a599d4e1..f2594454 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -8,8 +8,8 @@ def localization( adata: AnnData, use_label: str = "louvain", - eps: float = 20, - min_samples: int = 0, + eps: float = 20.0, + min_samples: int = 1, copy: bool = False, ) -> AnnData | None: """\ @@ -21,16 +21,16 @@ def localization( Annotated data matrix. use_label: str, default = "louvain" Use label result of cluster method. - eps: + eps: float, default 20.0 The maximum distance between two samples for one to be considered as in the neighborhood of the other. This is not a maximum bound on the distances of points within a cluster. This is the most important DBSCAN parameter to choose appropriately for your data set and distance function. - min_samples: + min_samples: int, default = 1 The number of samples (or total weight) in a neighborhood for a point to be considered as a core point. This includes the point itself. Passed into DBSCAN's min_samples parameter. - copy: + copy: bool, default = False Return a copy instead of writing to adata. Returns ------- From 472e3c7a03eeb54d8aaaf9d49a788dca223d7188 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 1 Jul 2025 11:09:28 +1000 Subject: [PATCH 064/123] Renamed keys by mistake. --- stlearn/image_preprocessing/image_tiling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index 547d4877..34e38ce2 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -58,7 +58,7 @@ def tiling( library_id = _get_library_id(adata, library_id) img_pillow = _load_and_prepare_image(adata, library_id) - coordinates = list(zip(adata.obs["image_row"], adata.obs["image_col"])) + coordinates = list(zip(adata.obs["imagerow"], adata.obs["imagecol"])) tile_names = [] From ffaa7bbad420d542bd6843c66bbb79c35196d073 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 1 Jul 2025 11:18:33 +1000 Subject: [PATCH 065/123] Rename release to 0.5.0 and add to history. --- HISTORY.rst | 11 +++++++++++ pyproject.toml | 2 +- stlearn/__init__.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 39a6759c..5ec9e16e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,17 @@ History ======= +0.5.0 (2025-07-01) +------------------ +* Support Python 3.10.x +* Added quality checks black, ruff and mypy and fixed appropriate source code. +* Copy parameters now work with the same semantics as scanpy. +* Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. + +API and Bug Fixes: +* Consistent with type annotations - mainly missing None annotations. +* pl.cluster_plot - Does not keep colours from previous runs when clustering. + 0.4.11 (2022-11-25) ------------------ 0.4.10 (2022-11-22) diff --git a/pyproject.toml b/pyproject.toml index 3b599795..8478ada2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "stlearn" -version = "0.4.2" +version = "0.5.0" authors = [ {name = "Genomics and Machine Learning lab", email = "andrew.newman@uq.edu.au"}, ] diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 9736f217..3ba30867 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -2,7 +2,7 @@ __author__ = """Genomics and Machine Learning lab""" __email__ = "andrew.newman@uq.edu.au" -__version__ = "0.4.2" +__version__ = "0.5.0" from . import add, datasets, em, pl, pp, spatial, tl from ._settings import settings From 2533fc4119ed4bfcbb0ec7ab4dcaebb5c8b6615e Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 1 Jul 2025 12:08:34 +1000 Subject: [PATCH 066/123] Add check for available clusters and improve documentation. --- stlearn/spatials/trajectory/set_root.py | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index 6d232589..30cf3ae7 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -6,26 +6,38 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False): """\ - Automatically set the root index. + Automatically set the root index for trajectory analysis. Parameters ---------- - adata + adata: AnnData Annotated data matrix. - use_label + use_label: str Use label result of cluster method. - cluster - Choose cluster to use as root - use_raw - Use the raw layer + cluster: str + Cluster identifier to use as the root cluster. Must exist in + `adata.obs[use_label]`. Will be converted to string for comparison. + use_raw: bool, default False + If True, use `adata.raw.X` for calculations; otherwise use `adata.X`. Returns ------- - Root index + int + Index of the selected root cell in the AnnData object + Raises + ------ + ValueError + If the specified cluster is not found in the clustering results. + ZeroDivisionError + If the specified cluster contains no cells. """ tmp_adata = adata.copy() # Subset the data based on the chosen cluster + available_clusters = tmp_adata.obs[use_label].unique() + if str(cluster) not in available_clusters.astype(str): + raise ValueError(f"Cluster '{cluster}' not found in available clusters: " + + "{sorted(available_clusters)}") tmp_adata = tmp_adata[ tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : From 2842609ab0d0738382de763ac6f3476d22b0dc42 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Tue, 1 Jul 2025 15:06:05 +1000 Subject: [PATCH 067/123] If list_clusters is empty just use all clusters. --- stlearn/spatials/trajectory/global_level.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index 21d4a3fc..16308966 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -50,9 +50,13 @@ def global_level( inds_cat = {v: k for (k, v) in cat_inds.items()} # Query cluster - if isinstance(list_clusters[0], str): - list_clusters = [cat_inds[label] for label in list_clusters] - query_nodes = list_clusters + if len(list_clusters) == 0: + print("No clusters specified, using all available clusters") + query_nodes = list(cat_inds.values()) + else: + if isinstance(list_clusters[0], str): + list_clusters = [cat_inds[label] for label in list_clusters] + query_nodes = list_clusters query_nodes = ordering_nodes(query_nodes, use_label, adata) if verbose: From cc3cf66a46322512d95ca07e18edc848b0d2ba80 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 09:07:55 +1000 Subject: [PATCH 068/123] Set boto3 version. --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 4c11dc46..96ae684b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ bokeh==3.7.3 +boto3==1.38.45 click==8.2.1 leidenalg==0.10.2 louvain==0.8.2 From 7065054f6f9bfc74258eec0e9259ecf3bf84db94 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 09:11:15 +1000 Subject: [PATCH 069/123] Remove. --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 96ae684b..4c11dc46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ bokeh==3.7.3 -boto3==1.38.45 click==8.2.1 leidenalg==0.10.2 louvain==0.8.2 From c6ae6b30055ffbcf8d3110391df656640ab80bfc Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 10:52:57 +1000 Subject: [PATCH 070/123] Remove double check of quants being np.array --- stlearn/tools/microenv/cci/perm_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 869e5894..28b33ac4 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -12,7 +12,7 @@ def nonzero_quantile(expr, q, interpolation): """Calculating the non-zero quantiles.""" nonzero_expr = expr[expr > 0] quants = np.quantile(nonzero_expr, q=q, interpolation=interpolation) - if quants is not np.array and quants is not np.ndarray: + if quants is not np.array: quants = np.array([quants]) return quants From 3297fd69703273f256956ff380d8a5002912972c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 11:07:00 +1000 Subject: [PATCH 071/123] Fix type check. --- stlearn/tools/microenv/cci/perm_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 28b33ac4..cd137ba1 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -12,7 +12,7 @@ def nonzero_quantile(expr, q, interpolation): """Calculating the non-zero quantiles.""" nonzero_expr = expr[expr > 0] quants = np.quantile(nonzero_expr, q=q, interpolation=interpolation) - if quants is not np.array: + if not isinstance(quants, np.ndarray) or quants.ndim == 0: quants = np.array([quants]) return quants From f455039dc60ab8d4c9f97cfcd8f983d8a2bf1d6a Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 11:27:28 +1000 Subject: [PATCH 072/123] Fixed up more type checks and created external types. --- stlearn/__init__.py | 3 +- stlearn/image_preprocessing/image_tiling.py | 13 +++-- stlearn/pp.py | 2 +- stlearn/spatials/morphology/adjust.py | 8 +-- stlearn/spatials/trajectory/pseudotime.py | 4 +- stlearn/spatials/trajectory/set_root.py | 6 +- .../trajectory/weight_optimization.py | 58 ++++++++++++------- stlearn/tools/label/label.py | 10 ++-- stlearn/tools/microenv/cci/analysis.py | 2 +- stlearn/tools/microenv/cci/base.py | 5 +- stlearn/tools/microenv/cci/base_grouping.py | 2 +- stlearn/tools/microenv/cci/perm_utils.py | 4 +- stlearn/tools/microenv/cci/permutation.py | 6 +- stlearn/types.py | 6 ++ 14 files changed, 81 insertions(+), 48 deletions(-) create mode 100644 stlearn/types.py diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 3ba30867..6fe7d20f 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -4,7 +4,7 @@ __email__ = "andrew.newman@uq.edu.au" __version__ = "0.5.0" -from . import add, datasets, em, pl, pp, spatial, tl +from . import add, datasets, em, pl, pp, spatial, tl, types from ._settings import settings from .wrapper.concatenate_spatial_adata import concatenate_spatial_adata from .wrapper.convert_scanpy import convert_scanpy @@ -37,6 +37,7 @@ "ReadXenium", "create_stlearn", "settings", + "types", "convert_scanpy", "concatenate_spatial_adata", ] diff --git a/stlearn/image_preprocessing/image_tiling.py b/stlearn/image_preprocessing/image_tiling.py index 34e38ce2..73f8b4b7 100644 --- a/stlearn/image_preprocessing/image_tiling.py +++ b/stlearn/image_preprocessing/image_tiling.py @@ -100,8 +100,9 @@ def tiling( return adata if copy else None -def _validate_inputs(crop_size: int, target_size: int, img_fmt: str, - quality: int) -> None: +def _validate_inputs( + crop_size: int, target_size: int, img_fmt: str, quality: int +) -> None: if not isinstance(crop_size, int) or crop_size <= 0: raise ValueError("crop_size must be a positive integer") @@ -113,7 +114,8 @@ def _validate_inputs(crop_size: int, target_size: int, img_fmt: str, raise ValueError("img_fmt must be 'JPEG' or 'PNG'") if img_fmt.upper() == "JPEG" and ( - not isinstance(quality, int) or not 1 <= quality <= 100): + not isinstance(quality, int) or not 1 <= quality <= 100 + ): raise ValueError("quality must be an integer between 1 and 100 for JPEG format") @@ -137,7 +139,8 @@ def _load_and_prepare_image(adata: AnnData, library_id: str) -> Image.Image: image = spatial_data["images"][use_quality] except KeyError as e: raise ValueError( - f"Could not find image data in adata.uns['spatial']['{library_id}']: {e}") + f"Could not find image data in adata.uns['spatial']['{library_id}']: {e}" + ) if image.dtype in (np.float32, np.float64): image = np.clip(image, 0, 1) @@ -148,4 +151,4 @@ def _load_and_prepare_image(adata: AnnData, library_id: str) -> Image.Image: if img_pillow.mode == "RGBA": img_pillow = img_pillow.convert("RGB") - return img_pillow \ No newline at end of file + return img_pillow diff --git a/stlearn/pp.py b/stlearn/pp.py index 5e5d4df0..9a191237 100644 --- a/stlearn/pp.py +++ b/stlearn/pp.py @@ -1,7 +1,7 @@ from .image_preprocessing.feature_extractor import extract_feature from .image_preprocessing.image_tiling import tiling -from .preprocessing.filter_genes import filter_genes from .preprocessing.filter_cells import filter_cells +from .preprocessing.filter_genes import filter_genes from .preprocessing.graph import neighbors from .preprocessing.log_scale import log1p, scale from .preprocessing.normalize import normalize_total diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatials/morphology/adjust.py index 2c68bcc2..a97ec258 100644 --- a/stlearn/spatials/morphology/adjust.py +++ b/stlearn/spatials/morphology/adjust.py @@ -1,19 +1,17 @@ -from typing import Literal - import numpy as np import scipy.spatial as spatial from anndata import AnnData from tqdm import tqdm -_SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] -_METHOD = Literal["mean", "median", "sum"] +from stlearn.types import _METHOD, _SIMILARITY_MATRIX + def adjust( adata: AnnData, use_data: str = "X_pca", radius: float = 50.0, rates: int = 1, - method: _SIMILARITY_MATRIX = "mean", + method: _METHOD = "mean", similarity_matrix: _SIMILARITY_MATRIX = "cosine", copy: bool = False, ) -> AnnData | None: diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 6eb3f3c2..677941b1 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -4,6 +4,8 @@ import scanpy from anndata import AnnData +from stlearn.types import _METHOD + def pseudotime( adata: AnnData, @@ -13,7 +15,7 @@ def pseudotime( use_rep: str = "X_pca", threshold: float = 0.01, radius: int = 50, - method: str = "mean", + method: _METHOD = "mean", threshold_spots: int = 5, use_sme: bool = False, reverse: bool = False, diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index 30cf3ae7..47ff44f5 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -36,8 +36,10 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False # Subset the data based on the chosen cluster available_clusters = tmp_adata.obs[use_label].unique() if str(cluster) not in available_clusters.astype(str): - raise ValueError(f"Cluster '{cluster}' not found in available clusters: " + - "{sorted(available_clusters)}") + raise ValueError( + f"Cluster '{cluster}' not found in available clusters: " + + "{sorted(available_clusters)}" + ) tmp_adata = tmp_adata[ tmp_adata.obs[tmp_adata.obs[use_label] == str(cluster)].index, : diff --git a/stlearn/spatials/trajectory/weight_optimization.py b/stlearn/spatials/trajectory/weight_optimization.py index ff4e7385..7c47405e 100644 --- a/stlearn/spatials/trajectory/weight_optimization.py +++ b/stlearn/spatials/trajectory/weight_optimization.py @@ -25,26 +25,34 @@ def weight_optimizing_global( actual_k = k if use_label and list_clusters: if "sub_cluster_labels" not in adata.obs.columns: - print("Warning: 'sub_cluster_labels' column not found. Using provided " + - "k value.") + print( + "Warning: 'sub_cluster_labels' column not found. Using provided " + + "k value." + ) else: try: filtered_data = adata.obs[adata.obs[use_label].isin(list_clusters)] if len(filtered_data) == 0: - raise ValueError(f"No cells found for clusters {list_clusters} " + - "in column '{use_label}'") + raise ValueError( + f"No cells found for clusters {list_clusters} " + + "in column '{use_label}'" + ) # Minimum 1 cluster, use K or max available sub-clusters n_subclusters = len(filtered_data["sub_cluster_labels"].unique()) actual_k = max(1, min(k, n_subclusters)) if actual_k != k: - print(f"Adjusted k from {k} to {actual_k} based on available " + - "sub-clusters ({n_subclusters})") + print( + f"Adjusted k from {k} to {actual_k} based on available " + + "sub-clusters ({n_subclusters})" + ) except Exception as e: - print(f"Warning: Could not determine sub-cluster count: {e}. " + - "Using provided k value.") + print( + f"Warning: Could not determine sub-cluster count: {e}. " + + "Using provided k value." + ) actual_k = k # Screening PTS graph @@ -59,13 +67,17 @@ def weight_optimizing_global( ) as pbar: for i in range(0, total_iterations): weight = round(i * step, 2) - matrix = global_level(adata, use_label=use_label, - list_clusters=list_clusters, use_rep=use_rep, - n_dims=n_dims, w=weight, return_graph=True, - verbose=False, ) - Gs.append( - nx.to_scipy_sparse_array(matrix) + matrix = global_level( + adata, + use_label=use_label, + list_clusters=list_clusters, + use_rep=use_rep, + n_dims=n_dims, + w=weight, + return_graph=True, + verbose=False, ) + Gs.append(nx.to_scipy_sparse_array(matrix)) j = j + step pbar.update(1) @@ -109,10 +121,9 @@ def weight_optimizing_global( return 0.5 -def weight_optimizing_local(adata: AnnData, - use_label: str = "louvain", - cluster=None, - step=0.01): +def weight_optimizing_local( + adata: AnnData, use_label: str = "louvain", cluster=None, step=0.01 +): # Screening PTS graph print("Screening PTS local graph...") Gs = [] @@ -123,8 +134,14 @@ def weight_optimizing_local(adata: AnnData, bar_format="{l_bar}{bar} [ time left: {remaining} ]", ) as pbar: for i in range(0, int(1 / step + 1)): - matrix = local_level(adata, use_label=use_label, cluster=cluster, - w=round(j, 2), verbose=False, return_matrix=True) + matrix = local_level( + adata, + use_label=use_label, + cluster=cluster, + w=round(j, 2), + verbose=False, + return_matrix=True, + ) Gs.append(matrix) j = j + step pbar.update(1) @@ -169,4 +186,3 @@ def weight_optimizing_local(adata: AnnData, def normalize_data(data): return (data - np.min(data)) / (np.max(data) - np.min(data)) - diff --git a/stlearn/tools/label/label.py b/stlearn/tools/label/label.py index 8e95039a..92a8f1a5 100644 --- a/stlearn/tools/label/label.py +++ b/stlearn/tools/label/label.py @@ -90,18 +90,20 @@ def run_label_transfer( def get_counts(data): """Gets count data from anndata if available.""" # Standard layer has counts # - if data.X is not np.ndarray and np.all(np.mod(data.X[0, :].todense(), 1) == 0): + if not isinstance(data.X, np.ndarray) and np.all( + np.mod(data.X[0, :].todense(), 1) == 0 + ): counts = data.to_df().transpose() - elif data.X is np.ndarray and np.all(np.mod(data.X[0, :], 1) == 0): + elif isinstance(data.X, np.ndarray) and np.all(np.mod(data.X[0, :], 1) == 0): counts = data.to_df().transpose() elif ( - data.X is not np.ndarray + not isinstance(data.X, np.ndarray) and hasattr(data, "raw") and np.all(np.mod(data.raw.X[0, :].todense(), 1) == 0) ): counts = data.raw.to_adata()[data.obs_names, data.var_names].to_df().transpose() elif ( - data.X is np.ndarray + isinstance(data.X, np.ndarray) and hasattr(data, "raw") and np.all(np.mod(data.raw.X[0, :], 1) == 0) ): diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 13c6ae20..3315d253 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -690,7 +690,7 @@ def run_cci( desc="Counting celltype-celltype interactions per LR and permuting " + f"{n_perms} times.", bar_format="{l_bar}{bar} [ time left: {remaining} ]", - disable=verbose is False, + disable=not verbose, ) as pbar: for i, best_lr in enumerate(best_lrs): ligand, receptor = best_lr.split("_") diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tools/microenv/cci/base.py index 2b668728..7824a526 100644 --- a/stlearn/tools/microenv/cci/base.py +++ b/stlearn/tools/microenv/cci/base.py @@ -193,7 +193,10 @@ def get_spot_lrs( if lr.split("_")[0] in df.columns and lr.split("_")[1] in df.columns ] - lr_cols = [pair.split("_")[int(lr_order is False)] for pair in pairs_wRev] + if lr_order: + lr_cols = [pair.split("_")[0] for pair in pairs_wRev] # Get ligand + else: + lr_cols = [pair.split("_")[1] for pair in pairs_wRev] # Get receptor spot_lrs = df[lr_cols] return spot_lrs diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tools/microenv/cci/base_grouping.py index 24201a71..5e229efe 100644 --- a/stlearn/tools/microenv/cci/base_grouping.py +++ b/stlearn/tools/microenv/cci/base_grouping.py @@ -162,7 +162,7 @@ def hotspot_core( total=len(lrs), desc="Removing background lr scores...", bar_format="{l_bar}{bar}", - disable=verbose is False, + disable=not verbose, ) as pbar: for i, lr_ in enumerate(lrs): lr_score_ = score_copy[i, :] diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index cd137ba1..426512a3 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -119,7 +119,7 @@ def get_similar_genes( ------- similar_genes: np.array Array of strings for gene names. """ - if quantiles is float: + if isinstance(quantiles, float): quantiles = np.array([quantiles]) else: quantiles = np.array(quantiles) @@ -186,7 +186,7 @@ def get_similar_genes_Quantiles( similar_genes: np.array Array of strings for gene names. """ - if quantiles is float: + if isinstance(quantiles, float): quantiles = np.array([quantiles]) else: quantiles = np.array(quantiles) diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tools/microenv/cci/permutation.py index 60bd9bd3..ad9a5f99 100644 --- a/stlearn/tools/microenv/cci/permutation.py +++ b/stlearn/tools/microenv/cci/permutation.py @@ -93,7 +93,7 @@ def perform_spot_testing( total=lr_scores.shape[1], desc="Generating backgrounds & testing each LR pair...", bar_format="{l_bar}{bar} [ time left: {remaining} ]", - disable=verbose is False, + disable=not verbose, ) as pbar: # Keep track of genes which can be used to gen. rand-pairs. gene_bg_genes: dict[str, np.ndarray] = {} @@ -177,7 +177,7 @@ def perform_spot_testing( lr_summary[sig_lrs_in_spot, 1] += 1 lr_summary[sigpval_lrs_in_spot, 2] += 1 - lr_sig_scores[spot_i, sig_lrs_in_spot is False] = 0 + lr_sig_scores[spot_i, ~sig_lrs_in_spot] = 0 # Ordering the results according to number of significant spots per LR# order = np.argsort(-lr_summary[:, 1]) @@ -527,7 +527,7 @@ def get_stats( pvals = np.zeros((1, len(scores)), dtype=np.float)[0, :] nonzero_score_bool = scores > 0 nonzero_score_indices = np.where(nonzero_score_bool)[0] - zero_score_indices = np.where(nonzero_score_bool is False)[0] + zero_score_indices = np.where(~nonzero_score_bool)[0] pvals[zero_score_indices] = (total_bg - len(background)) / total_bg pvals[nonzero_score_indices] = [ len(np.where(background >= scores[i])[0]) / total_bg diff --git a/stlearn/types.py b/stlearn/types.py new file mode 100644 index 00000000..50fe0869 --- /dev/null +++ b/stlearn/types.py @@ -0,0 +1,6 @@ +from typing import Literal + +_SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] +_METHOD = Literal["mean", "median", "sum"] + +__all__ = ["_SIMILARITY_MATRIX", "_METHOD"] From c0f307462cae6f484320dd62608a9354dfe7bfc1 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 11:41:05 +1000 Subject: [PATCH 073/123] Weird renaming issue. --- stlearn/plotting/cci_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 1e544486..1405d6bb 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -761,7 +761,7 @@ def lr_plot( lr_cmap = "default" # This gets ignored due to setting colours below if lr_colors is None: lr_colors = { - ligand: matplotlib.colors.to_hex("receptor"), + ligand: matplotlib.colors.to_hex("r"), receptor: matplotlib.colors.to_hex("limegreen"), lr: matplotlib.colors.to_hex("b"), "": "#836BC6", # Neutral color in H&E images. From d899a5616d93f9f8401ac6d79876784984d24e0d Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 12:33:29 +1000 Subject: [PATCH 074/123] Assume .uns[split_node] is string to list of strings. --- .../plotting/trajectory/pseudotime_plot.py | 22 ++++---- .../trajectory/detect_transition_markers.py | 51 ++++++++++++------- stlearn/spatials/trajectory/pseudotime.py | 3 -- stlearn/spatials/trajectory/set_root.py | 2 +- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 4ee2a115..d6bfe35f 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -1,3 +1,5 @@ +from typing import List + import matplotlib import networkx as nx import numpy as np @@ -12,7 +14,7 @@ def pseudotime_plot( library_id: str | None = None, use_label: str = "louvain", pseudotime_key: str = "pseudotime_key", - list_clusters: str | list | None = None, + list_clusters: str | List[str] | None = None, cell_alpha: float = 1.0, image_alpha: float = 1.0, edge_alpha: float = 0.8, @@ -87,9 +89,8 @@ def pseudotime_plot( imagecol = adata.obs["imagecol"] imagerow = adata.obs["imagerow"] if list_clusters is None: - list_clusters = np.array(range(0, len(adata.obs[use_label].unique()))).astype( - int - ) + unique_labels = adata.obs[use_label].unique() + list_clusters = [str(i) for i in range(len(unique_labels))] tmp = adata.obs G = _read_graph(adata, "global_graph") @@ -157,13 +158,13 @@ def pseudotime_plot( a.text( y[0], y[1], - get_cluster(str(x), adata.uns["split_node"]), + get_cluster(x, adata.uns["split_node"]), color="white", fontsize=node_size, zorder=100, bbox=dict( facecolor=cmap( - int(get_cluster(str(x), adata.uns["split_node"])) + int(get_cluster(x, adata.uns["split_node"])) / (len(used_colors) - 1) ), boxstyle="circle", @@ -220,13 +221,13 @@ def pseudotime_plot( a.text( y[0], y[1], - get_cluster(str(x), adata.uns["split_node"]), + get_cluster(x, adata.uns["split_node"]), color="black", fontsize=8, zorder=100, bbox=dict( facecolor=cmap( - int(get_cluster(str(x), adata.uns["split_node"])) + int(get_cluster(x, adata.uns["split_node"])) / (len(used_colors) - 1) ), boxstyle="circle", @@ -280,5 +281,6 @@ def get_cluster(search, dictionary): def get_node(node_list, split_node): result = np.array([]) for node in node_list: - result = np.append(result, np.array(split_node[int(node)]).astype(int)) - return result.astype(int) + node_ = split_node[node] + result = np.append(result, np.array(node_).astype(int)) + return result diff --git a/stlearn/spatials/trajectory/detect_transition_markers.py b/stlearn/spatials/trajectory/detect_transition_markers.py index b538d147..86ed81c0 100644 --- a/stlearn/spatials/trajectory/detect_transition_markers.py +++ b/stlearn/spatials/trajectory/detect_transition_markers.py @@ -1,7 +1,9 @@ import warnings +from typing import List import numpy as np import pandas as pd +from anndata import AnnData from scipy.stats import spearmanr from ...utils import _read_graph @@ -10,33 +12,46 @@ def detect_transition_markers_clades( - adata, - clade, - cutoff_spearman=0.4, - cutoff_pvalue=0.05, - screening_genes=None, - use_raw_count=False, + adata: AnnData, + clade: int, + cutoff_spearman: float = 0.4, + cutoff_pvalue: float = 0.05, + screening_genes: None | List[str] = None, + use_raw_count: bool = False, ): """\ Transition markers detection of a clade. Parameters ---------- - adata - Annotated data matrix. - clade - Name of a clade user wants to detect transition markers. - cutoff_spearman - The threshold of correlation coefficient. - cutoff_pvalue - The threshold of p-value. - screening_genes - List of customised genes. - use_raw_count + adata : AnnData + Annotated data matrix containing spatial transcriptomics data with + computed pseudotime and clade information. + clade : int + Numeric identifier of the clade for which to detect transition markers. + Should correspond to a clade ID present in the trajectory analysis. + cutoff_spearman : float, default 0.4 + The minimum Spearman correlation coefficient threshold for identifying + significant gene-pseudotime correlations. Must be between 0 and 1. + cutoff_pvalue : float, default 0.05 + The maximum p-value threshold for statistical significance testing. + Must be between 0 and 1. Lower values result in more stringent + statistical filtering. + screening_genes : list of str, optional + Custom list of gene names to restrict the analysis to. If None, + all genes in the dataset will be considered. Useful for focusing + on specific gene sets or reducing computational time. + use_raw_count : bool, default False True if user wants to use raw layer data. Returns ------- - Anndata + AnnData + The input AnnData object with additional information stored in + adata.uns about the detected transition markers, including: + - Correlation coefficients + - P-values + - Gene rankings + - Clade-specific marker information """ print("Detecting the transition markers of clade_" + str(clade) + "...") diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index 677941b1..b2641791 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -99,14 +99,11 @@ def pseudotime( cnt_matrix = adata.uns["paga"]["connectivities"].toarray() # Filter by threshold - cnt_matrix[cnt_matrix < threshold] = 0.0 cnt_matrix = pd.DataFrame(cnt_matrix) # Mapping louvain label to subcluster - cat_ind = adata.uns[use_label + "_index_dict"] - split_node = {} for label in adata.obs[use_label].unique(): meaningful_sub = [] diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatials/trajectory/set_root.py index 47ff44f5..7c9ce806 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatials/trajectory/set_root.py @@ -16,7 +16,7 @@ def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False Use label result of cluster method. cluster: str Cluster identifier to use as the root cluster. Must exist in - `adata.obs[use_label]`. Will be converted to string for comparison. + `adata.obs[use_label]`. use_raw: bool, default False If True, use `adata.raw.X` for calculations; otherwise use `adata.X`. Returns From 3d4b6d1cdb6740e27268a86eead48b069ce2f35f Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 13:09:06 +1000 Subject: [PATCH 075/123] Fix some typing and use ~ instead of not inside selector. --- stlearn/plotting/cci_plot.py | 404 +++++++++--------- .../plotting/trajectory/pseudotime_plot.py | 88 ++-- .../trajectory/detect_transition_markers.py | 3 +- 3 files changed, 250 insertions(+), 245 deletions(-) diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 1405d6bb..33917421 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -43,14 +43,14 @@ def lr_diagnostics( - adata, - highlight_lrs: list | None = None, - n_top: int | None = None, - color0: str = "turquoise", - color1: str = "plum", - figsize: tuple = (10, 4), - lr_text_fp: dict | None = None, - show: bool = True, + adata, + highlight_lrs: list | None = None, + n_top: int | None = None, + color0: str = "turquoise", + color1: str = "plum", + figsize: tuple = (10, 4), + lr_text_fp: dict | None = None, + show: bool = True, ): """Diagnostic plot looking at relationship between technical features of lrs and lr rank. Two plots generated: left is the average of the median for nonzero @@ -108,17 +108,17 @@ def lr_diagnostics( def lr_summary( - adata, - n_top: int = 50, - highlight_lrs: list | None = None, - y: str = "n_spots_sig", - color: str = "gold", - figsize: tuple | None = None, - highlight_color: str = "red", - max_text: int = 50, - lr_text_fp: dict | None = None, - ax: plt_axis.Axes | None = None, - show: bool = True, + adata, + n_top: int = 50, + highlight_lrs: list | None = None, + y: str = "n_spots_sig", + color: str = "gold", + figsize: tuple | None = None, + highlight_color: str = "red", + max_text: int = 50, + lr_text_fp: dict | None = None, + ax: plt_axis.Axes | None = None, + show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -176,17 +176,17 @@ def lr_summary( def lr_n_spots( - adata, - n_top: int = 100, - font_dict: dict | None = None, - xtick_dict: dict | None = None, - bar_width: float = 1, - max_text: int = 50, - non_sig_color: str = "dodgerblue", - sig_color: str = "springgreen", - figsize: tuple = (6, 4), - show_title: bool = True, - show: bool = True, + adata, + n_top: int = 100, + font_dict: dict | None = None, + xtick_dict: dict | None = None, + bar_width: float = 1, + max_text: int = 50, + non_sig_color: str = "dodgerblue", + sig_color: str = "springgreen", + figsize: tuple = (6, 4), + show_title: bool = True, + show: bool = True, ): """Bar plot showing for each LR no. of sig versus non-sig spots. @@ -258,15 +258,15 @@ def lr_n_spots( def lr_go( - adata, - n_top: int = 20, - highlight_go: list | None = None, - figsize=(6, 4), - rot: float = 50, - lr_text_fp: dict | None = None, - highlight_color: str = "yellow", - max_text: int = 50, - show: bool = True, + adata, + n_top: int = 20, + highlight_go: list | None = None, + figsize=(6, 4), + rot: float = 50, + lr_text_fp: dict | None = None, + highlight_color: str = "yellow", + max_text: int = 50, + show: bool = True, ): """Plots the results from the LR GO analysis. @@ -322,13 +322,13 @@ def lr_go( def cci_check( - adata: AnnData, - use_label: str, - figsize=(16, 10), - cell_label_size=20, - axis_text_size=18, - tick_size=14, - show=True, + adata: AnnData, + use_label: str, + figsize=(16, 10), + cell_label_size=20, + axis_text_size=18, + tick_size=14, + show=True, ): """Checks relationship between no. of significant CCI-LR interactions and cell type frequency. @@ -420,32 +420,32 @@ def cci_check( # Functions for visualisation the LR results per spot. def lr_result_plot( - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: str | None = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - ax: plt_axis.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - zoom_coord: tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: str | None = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + ax: plt_axis.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, ): """Plots the per spot statistics for given LR. @@ -538,35 +538,35 @@ def lr_result_plot( # @_docs_params(het_plot=doc_lr_plot) def lr_plot( - adata: AnnData, - lr: str, - min_expr: float = 0, - sig_spots=True, - use_label: str | None = None, - outer_mode: str = "continuous", - l_cmap=None, - r_cmap=None, - lr_cmap=None, - inner_cmap=None, - inner_size_prop: float = 0.25, - middle_size_prop: float = 0.5, - outer_size_prop: float = 1, - pt_scale: int = 100, - title="", - show_image: bool = True, - show_arrows: bool = False, - fig_or_none: plt_figure.Figure | None = None, - ax_or_none: plt_axis.Axes | None = None, - arrow_head_width: float = 4, - arrow_width: float = 0.001, - arrow_cmap: str | None = None, - arrow_vmax: float | None = None, - sig_cci: bool = False, - lr_colors: dict | None = None, - figsize: tuple = (6.4, 4.8), - use_mix: bool | None = None, - # plotting params - **kwargs, + adata: AnnData, + lr: str, + min_expr: float = 0, + sig_spots=True, + use_label: str | None = None, + outer_mode: str = "continuous", + l_cmap=None, + r_cmap=None, + lr_cmap=None, + inner_cmap=None, + inner_size_prop: float = 0.25, + middle_size_prop: float = 0.5, + outer_size_prop: float = 1, + pt_scale: int = 100, + title="", + show_image: bool = True, + show_arrows: bool = False, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, + arrow_head_width: float = 4, + arrow_width: float = 0.001, + arrow_cmap: str | None = None, + arrow_vmax: float | None = None, + sig_cci: bool = False, + lr_colors: dict | None = None, + figsize: tuple = (6.4, 4.8), + use_mix: bool | None = None, + # plotting params + **kwargs, ) -> None: """Creates different kinds of spatial visualisations for the LR analysis results. To see combinations of parameters refer to stLearn CCI tutorial. @@ -672,10 +672,10 @@ def lr_plot( # Making sure have run_cci first with respective labelling # if ( - show_arrows - and sig_cci - and use_label - and f"per_lr_cci_{use_label}" not in adata.uns + show_arrows + and sig_cci + and use_label + and f"per_lr_cci_{use_label}" not in adata.uns ): raise Exception( "Cannot subset arrow interactions to significant ccis " @@ -701,16 +701,18 @@ def lr_plot( "to adata.uns matching the use_mix ({use_mix}) key." ) elif ( - use_label is not None and use_label in lr_use_labels and ran_sig and not lr_sig + use_label is not None + and use_label in lr_use_labels + and ran_sig and not lr_sig ): raise Exception( "Since use_label refers to lr stats & ran permutation testing, " "LR needs to be significant to view stats." ) elif ( - use_label is not None - and use_label not in adata.obs.keys() - and use_label not in lr_use_labels + use_label is not None + and use_label not in adata.obs.keys() + and use_label not in lr_use_labels ): raise Exception( f"use_label must be in adata.obs or one of lr stats: {lr_use_labels}." @@ -890,34 +892,34 @@ def lr_plot( #### from old data structure when only test individual LRs. @_docs_params(spatial_base_plot=doc_spatial_base_plot, het_plot=doc_het_plot) def het_plot( - adata: AnnData, - # plotting param - title: str | None = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: plt_axis.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - zoom_coord: tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # cci_rank param - use_het: str = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + # plotting param + title: str | None = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: plt_axis.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + use_het: str = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, ) -> None: """\ Allows the visualization of significant cell-cell interaction @@ -974,22 +976,22 @@ def het_plot( def ccinet_plot( - adata: AnnData, - use_label: str, - lr: str | None = None, - pos: dict | None = None, - return_pos: bool = False, - cmap: str = "default", - font_size: int = 12, - node_size_exp: int = 1, - node_size_scaler: int = 1, - min_counts: int = 0, - sig_interactions: bool = True, - fig_or_none: plt_figure.Figure | None = None, - ax_or_none: plt_axis.Axes | None = None, - pad=0.25, - title_or_none: str | None = None, - figsize: tuple = (10, 10), + adata: AnnData, + use_label: str, + lr: str | None = None, + pos: dict | None = None, + return_pos: bool = False, + cmap: str = "default", + font_size: int = 12, + node_size_exp: int = 1, + node_size_scaler: int = 1, + min_counts: int = 0, + sig_interactions: bool = True, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, + pad=0.25, + title_or_none: str | None = None, + figsize: tuple = (10, 10), ): """Circular celltype-celltype interaction network based on LR-CCI analysis. The size of the nodes drawn for each cell type indicates the total no. of @@ -1071,9 +1073,10 @@ def ccinet_plot( node_sizes = np.array( [ ( - ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[i, i]) / total) - * 10000 - * node_size_scaler + ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[ + i, i]) / total) + * 10000 + * node_size_scaler ) ** (node_size_exp) for i in node_indices @@ -1087,8 +1090,8 @@ def ccinet_plot( trans_i = np.where(all_set == edge[0][0])[0][0] receive_i = np.where(all_set == edge[0][1])[0][0] e_total = ( - sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) - - int_matrix[trans_i, receive_i] + sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) + - int_matrix[trans_i, receive_i] ) # so don't double count e_totals.append(e_total) edge_weights = [edge[1]["weight"] / e_totals[i] for i, edge in enumerate(edges)] @@ -1157,15 +1160,15 @@ def ccinet_plot( def cci_map( - adata: AnnData, - use_label: str, - lr_or_none: str | None = None, - ax_or_none: plt_axis.Axes | None = None, - show: bool = False, - figsize_or_none: tuple | None = None, - cmap: str = "Spectral_r", - sig_interactions: bool = True, - title=None, + adata: AnnData, + use_label: str, + lr_or_none: str | None = None, + ax_or_none: plt_axis.Axes | None = None, + show: bool = False, + figsize_or_none: tuple | None = None, + cmap: str = "Spectral_r", + sig_interactions: bool = True, + title=None, ): """Heatmap visualising sender->receivers of cell type interactions. @@ -1239,18 +1242,18 @@ def cci_map( def lr_cci_map( - adata: AnnData, - use_label: str, - lrs: list | np.ndarray | None = None, - n_top_lrs: int = 5, - n_top_ccis: int = 15, - min_total: int = 0, - ax_or_none: plt_axis.Axes | None = None, - figsize: tuple = (6.48, 4.8), - show: bool = False, - cmap: str = "Spectral_r", - square_scaler: int = 700, - sig_interactions: bool = True, + adata: AnnData, + use_label: str, + lrs: list | np.ndarray | None = None, + n_top_lrs: int = 5, + n_top_ccis: int = 15, + min_total: int = 0, + ax_or_none: plt_axis.Axes | None = None, + figsize: tuple = (6.48, 4.8), + show: bool = False, + cmap: str = "Spectral_r", + square_scaler: int = 700, + sig_interactions: bool = True, ): """Heatmap of interaction counts. Rows are lrs and columns are celltype->celltype interactions. @@ -1357,18 +1360,18 @@ def lr_cci_map( def lr_chord_plot( - adata: AnnData, - use_label: str, - lr: str | None = None, - min_ints: int = 2, - n_top_ccis: int = 10, - cmap: str = "default", - sig_interactions: bool = True, - label_size: int = 10, - label_rotation: float = 0, - title: str = "", - figsize: tuple = (8, 8), - show: bool = True, + adata: AnnData, + use_label: str, + lr: str | None = None, + min_ints: int = 2, + n_top_ccis: int = 10, + cmap: str = "default", + sig_interactions: bool = True, + label_size: int = 10, + label_rotation: float = 0, + title: str = "", + figsize: tuple = (8, 8), + show: bool = True, ): """Chord diagram of interactions between cell types. Note that interaction is measured as the total no. of edges connecting @@ -1440,7 +1443,7 @@ def lr_chord_plot( all_zero = np.array( [np.all(np.logical_and(flux[i, keep] == 0, flux[keep, i] == 0)) for i in keep] ) - keep = keep[not all_zero] + keep = keep[~all_zero] if len(keep) == 0: # If we don't keep anything, warn the user print( f"Warning: for {lr} at the current min_ints ({min_ints}), there " @@ -1476,7 +1479,7 @@ def lr_chord_plot( rotation = nodePos[i][2] # Prevent text going upside down at certain rotations if (rotation < 90 and rotation > 18 and label_rotation != 0) or ( - rotation < 120 and rotation > 90 + rotation < 120 and rotation > 90 ): label_rotation_ = -label_rotation else: @@ -1492,13 +1495,13 @@ def lr_chord_plot( def grid_plot( - adata, - use_label: str | None = None, - n_row: int = 10, - n_col: int = 10, - size: int = 1, - figsize=(4.5, 4.5), - show: bool = False, + adata, + use_label: str | None = None, + n_row: int = 10, + n_col: int = 10, + size: int = 1, + figsize=(4.5, 4.5), + show: bool = False, ): """Plots grid over the top of spatial data to show how cells will be grouped if gridded. @@ -1576,7 +1579,6 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) - # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index d6bfe35f..6ce2af9a 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -1,40 +1,39 @@ -from typing import List - import matplotlib import networkx as nx import numpy as np from anndata import AnnData from matplotlib import pyplot as plt +from numpy._typing import NDArray from stlearn.utils import _read_graph def pseudotime_plot( - adata: AnnData, - library_id: str | None = None, - use_label: str = "louvain", - pseudotime_key: str = "pseudotime_key", - list_clusters: str | List[str] | None = None, - cell_alpha: float = 1.0, - image_alpha: float = 1.0, - edge_alpha: float = 0.8, - node_alpha: float = 1.0, - spot_size: float | int = 6.5, - node_size: float = 5, - show_color_bar: bool = True, - show_axis: bool = False, - show_graph: bool = True, - show_trajectories: bool = False, - reverse: bool = False, - show_node: bool = True, - show_plot: bool = True, - cropped: bool = True, - margin: int = 100, - dpi: int = 150, - output: str | None = None, - name: str | None = None, - copy: bool = False, - ax=None, + adata: AnnData, + library_id: str | None = None, + use_label: str = "louvain", + pseudotime_key: str = "pseudotime_key", + list_clusters: str | list[str] | None = None, + cell_alpha: float = 1.0, + image_alpha: float = 1.0, + edge_alpha: float = 0.8, + node_alpha: float = 1.0, + spot_size: float | int = 6.5, + node_size: float = 5, + show_color_bar: bool = True, + show_axis: bool = False, + show_graph: bool = True, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = True, + show_plot: bool = True, + cropped: bool = True, + margin: int = 100, + dpi: int = 150, + output: str | None = None, + name: str | None = None, + copy: bool = False, + ax=None, ) -> AnnData | None: """\ Global trajectory inference plot (Only DPT). @@ -86,18 +85,22 @@ def pseudotime_plot( Nothing """ - imagecol = adata.obs["imagecol"] - imagerow = adata.obs["imagerow"] + checked_list_clusters: list[str] if list_clusters is None: unique_labels = adata.obs[use_label].unique() - list_clusters = [str(i) for i in range(len(unique_labels))] + checked_list_clusters = [str(i) for i in range(len(unique_labels))] + elif isinstance(list_clusters, str): + checked_list_clusters = [list_clusters] + + imagecol = adata.obs["imagecol"] + imagerow = adata.obs["imagerow"] tmp = adata.obs G = _read_graph(adata, "global_graph") labels = nx.get_edge_attributes(G, "weight") result = [] - query_node = get_node(list_clusters, adata.uns["split_node"]) + query_node = get_node(checked_list_clusters, adata.uns["split_node"]) for edge in G.edges(query_node): if (edge[0] in query_node) and (edge[1] in query_node): result.append(edge) @@ -112,7 +115,7 @@ def pseudotime_plot( fig, a = plt.subplots() if ax is not None: a = ax - centroid_dict = adata.uns["centroid_dict"] + centroid_dict: dict[int, NDArray[np.float64]] = adata.uns["centroid_dict"] centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} dpt = adata.obs[pseudotime_key] vmin = min(dpt) @@ -154,7 +157,7 @@ def pseudotime_plot( ) for x, y in centroid_dict.items(): - if x in get_node(list_clusters, adata.uns["split_node"]): + if x in get_node(checked_list_clusters, adata.uns["split_node"]): a.text( y[0], y[1], @@ -217,7 +220,7 @@ def pseudotime_plot( if show_node: for x, y in centroid_dict.items(): - if x in get_node(list_clusters, adata.uns["split_node"]): + if x in get_node(checked_list_clusters, adata.uns["split_node"]): a.text( y[0], y[1], @@ -272,15 +275,16 @@ def pseudotime_plot( # get name of cluster by subcluster -def get_cluster(search, dictionary): - for cl, sub in dictionary.items(): - if search in sub: +def get_cluster(search: int, split_node: dict[str, list[str]]): + for cl, sub in split_node.items(): + if str(search) in sub: return cl -def get_node(node_list, split_node): - result = np.array([]) +def get_node( + node_list: list[str], split_node: dict[str, list[str]] +) -> NDArray[np.int64]: + all_values = [] for node in node_list: - node_ = split_node[node] - result = np.append(result, np.array(node_).astype(int)) - return result + all_values.extend(split_node[node]) + return np.array([int(val) for val in all_values], dtype=np.int64) diff --git a/stlearn/spatials/trajectory/detect_transition_markers.py b/stlearn/spatials/trajectory/detect_transition_markers.py index 86ed81c0..d41d493e 100644 --- a/stlearn/spatials/trajectory/detect_transition_markers.py +++ b/stlearn/spatials/trajectory/detect_transition_markers.py @@ -1,5 +1,4 @@ import warnings -from typing import List import numpy as np import pandas as pd @@ -16,7 +15,7 @@ def detect_transition_markers_clades( clade: int, cutoff_spearman: float = 0.4, cutoff_pvalue: float = 0.05, - screening_genes: None | List[str] = None, + screening_genes: None | list[str] = None, use_raw_count: bool = False, ): """\ From 39e4d3caafb0632ba50f63337ac8074dbf6620e7 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 13:10:46 +1000 Subject: [PATCH 076/123] Oops. --- stlearn/plotting/trajectory/pseudotime_plot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 6ce2af9a..1cc59c01 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -91,6 +91,8 @@ def pseudotime_plot( checked_list_clusters = [str(i) for i in range(len(unique_labels))] elif isinstance(list_clusters, str): checked_list_clusters = [list_clusters] + else: + checked_list_clusters = list_clusters imagecol = adata.obs["imagecol"] imagerow = adata.obs["imagerow"] From 5196a3e6a4fd338b37553f71e2f3642f6e7825f4 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 15:30:27 +1000 Subject: [PATCH 077/123] Update copyright. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 272a059b..d83827a8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -76,7 +76,7 @@ # General information about the project. project = "stLearn" -copyright = "2022, Genomics and Machine Learning lab" +copyright = "2022-2025, Genomics and Machine Learning lab" author = "Genomics and Machine Learning lab" # The version info for the project you're documenting, acts as replacement From 697a12a1146b68ead56a8e2e8ec55c71ed63a10b Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Wed, 2 Jul 2025 16:58:12 +1000 Subject: [PATCH 078/123] Update versions. --- HISTORY.rst | 14 +++++++++++++- docs/index.rst | 2 ++ docs/installation.rst | 31 ++++++------------------------- docs/release_notes/1.1.0.rst | 15 +++++++++++++++ pyproject.toml | 2 +- stlearn/__init__.py | 2 +- 6 files changed, 38 insertions(+), 28 deletions(-) create mode 100644 docs/release_notes/1.1.0.rst diff --git a/HISTORY.rst b/HISTORY.rst index 5ec9e16e..e1752d99 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,7 +2,7 @@ History ======= -0.5.0 (2025-07-01) +1.1.0 (2025-07-02) ------------------ * Support Python 3.10.x * Added quality checks black, ruff and mypy and fixed appropriate source code. @@ -12,25 +12,37 @@ History API and Bug Fixes: * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. +* pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. 0.4.11 (2022-11-25) ------------------ + 0.4.10 (2022-11-22) ------------------ + 0.4.8 (2022-06-15) ------------------ + 0.4.7 (2022-03-28) ------------------ + 0.4.6 (2022-03-09) ------------------ + 0.4.5 (2022-03-02) ------------------ + 0.4.0 (2022-02-03) ------------------ + 0.3.2 (2021-03-29) ------------------ + 0.3.1 (2020-12-24) ------------------ + 0.2.7 (2020-09-12) ------------------ + 0.2.6 (2020-08-04) +------------------ diff --git a/docs/index.rst b/docs/index.rst index c8e2630c..2d3df2e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,8 @@ In the new release, we provide the interactive plots: Latest additions ---------------- +.. include:: release_notes/1.1.0.rst + .. include:: release_notes/0.4.11.rst .. include:: release_notes/0.4.6.rst diff --git a/docs/installation.rst b/docs/installation.rst index 26ac7387..b27a8f3a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -13,15 +13,15 @@ Install by Anaconda Prepare conda environment for stLearn :: - conda create -n stlearn python=3.8 - conda activate stlearn + conda create -n stlearn python=3.10 --y + conda activate stlearn **Step 2:** You can directly install stlearn in the anaconda by: :: - conda install -c conda-forge stlearn + conda install -c conda-forge stlearn Install by PyPi --------------- @@ -31,31 +31,12 @@ Install by PyPi Prepare conda environment for stLearn :: - conda create -n stlearn python=3.8 - conda activate stlearn + conda create -n stlearn python=3.10 --y + conda activate stlearn **Step 2:** Install stlearn using `pip` :: - pip install -U stlearn - - - -Popular bugs ---------------- - -- `DLL load failed while importing utilsextension: The specified module could not be found.` - -You need to uninstall package `tables` and install it again -:: - - pip uninstall tables - conda install pytables - -If conda version does not work, you can access to this site and download the .whl file: `https://www.lfd.uci.edu/~gohlke/pythonlibs/#pytables` - -:: - - pip install tables-3.7.0-cp38-cp38-win_amd64.whl + pip install -U stlearn diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst new file mode 100644 index 00000000..d8674508 --- /dev/null +++ b/docs/release_notes/1.1.0.rst @@ -0,0 +1,15 @@ +1.1.0 `2025-07-02` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. rubric:: Feature + +* Support Python 3.10.x +* Added quality checks black, ruff and mypy and fixed appropriate source code. +* Copy parameters now work with the same semantics as scanpy. +* Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. + +.. rubric:: Bug fixes + +* Consistent with type annotations - mainly missing None annotations. +* pl.cluster_plot - Does not keep colours from previous runs when clustering. +* pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8478ada2..74d75b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "stlearn" -version = "0.5.0" +version = "1.1.0" authors = [ {name = "Genomics and Machine Learning lab", email = "andrew.newman@uq.edu.au"}, ] diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 6fe7d20f..51f1e6f3 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -2,7 +2,7 @@ __author__ = """Genomics and Machine Learning lab""" __email__ = "andrew.newman@uq.edu.au" -__version__ = "0.5.0" +__version__ = "1.1.0" from . import add, datasets, em, pl, pp, spatial, tl, types from ._settings import settings From 3ce46842bd8794ad542db4772de0840ff88c0289 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 10:40:19 +1000 Subject: [PATCH 079/123] Add downloading xenium artifacts. Reformat. --- stlearn/_datasets/_datasets.py | 52 ++- stlearn/plotting/cci_plot.py | 402 +++++++++--------- .../plotting/trajectory/pseudotime_plot.py | 52 +-- 3 files changed, 277 insertions(+), 229 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index a637aed3..56fc93ca 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -1,3 +1,5 @@ +import zipfile as zf + import scanpy as sc from anndata import AnnData @@ -10,10 +12,58 @@ def example_bcba() -> AnnData: Reference: https://support.10xgenomics.com/spatial-gene-expression/datasets/1.1.0/V1_Breast_Cancer_Block_A_Section_1 """ - settings.datasetdir.mkdir(exist_ok=True) + settings.datasetdir.mkdir(parents=True, exist_ok=True) filename = settings.datasetdir / "example_bcba.h5" url = "https://www.dropbox.com/s/u3m2f16mvdom1am/example_bcba.h5ad?dl=1" if not filename.is_file(): sc.readwrite._download(url=url, path=filename) adata = sc.read_h5ad(filename) return adata + + +def xenium_sge( + base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", + image_filename="he_image.ome.tif", + zip_filename="outs.zip", + sample_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + include_hires_tiff: bool = False, +): + """ + Download and extract Xenium SGE data files. + + Args: + base_url: Base URL for downloads + image_filename: Name of the image file to download + zip_filename: Name of the zip file to download + sample_id: Sample identifier + include_hires_tiff: Whether to download the high-res TIFF image + """ + sample_dir = settings.datasetdir / sample_id + sample_dir.mkdir(parents=True, exist_ok=True) + + files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz"] + all_sge_files_exist = all( + (sample_dir / sge_file).exists() for sge_file in files_to_extract + ) + + download_filenames = [] + if not all_sge_files_exist: + download_filenames.append(zip_filename) + if include_hires_tiff and not (sample_dir / image_filename).exists(): + download_filenames.append(image_filename) + + for file_name in download_filenames: + file_path = sample_dir / file_name + url = f"{base_url}/{sample_id}/{sample_id}_{file_name}" + if not file_path.is_file(): + sc.readwrite._download(url=url, path=file_path) + + if not all_sge_files_exist: + try: + zip_file_path = sample_dir / zip_filename + with zf.ZipFile(zip_file_path, "r") as zip_ref: + for zip_filename in files_to_extract: + with open(sample_dir / zip_filename, "wb") as file_name: + file_name.write(zip_ref.read(f"outs/{zip_filename}")) + except zf.BadZipFile: + raise ValueError(f"Invalid zip file: {zip_file_path}") diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 33917421..33fa4d84 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -43,14 +43,14 @@ def lr_diagnostics( - adata, - highlight_lrs: list | None = None, - n_top: int | None = None, - color0: str = "turquoise", - color1: str = "plum", - figsize: tuple = (10, 4), - lr_text_fp: dict | None = None, - show: bool = True, + adata, + highlight_lrs: list | None = None, + n_top: int | None = None, + color0: str = "turquoise", + color1: str = "plum", + figsize: tuple = (10, 4), + lr_text_fp: dict | None = None, + show: bool = True, ): """Diagnostic plot looking at relationship between technical features of lrs and lr rank. Two plots generated: left is the average of the median for nonzero @@ -108,17 +108,17 @@ def lr_diagnostics( def lr_summary( - adata, - n_top: int = 50, - highlight_lrs: list | None = None, - y: str = "n_spots_sig", - color: str = "gold", - figsize: tuple | None = None, - highlight_color: str = "red", - max_text: int = 50, - lr_text_fp: dict | None = None, - ax: plt_axis.Axes | None = None, - show: bool = True, + adata, + n_top: int = 50, + highlight_lrs: list | None = None, + y: str = "n_spots_sig", + color: str = "gold", + figsize: tuple | None = None, + highlight_color: str = "red", + max_text: int = 50, + lr_text_fp: dict | None = None, + ax: plt_axis.Axes | None = None, + show: bool = True, ): """Plotting the top LRs ranked by number of significant spots. @@ -176,17 +176,17 @@ def lr_summary( def lr_n_spots( - adata, - n_top: int = 100, - font_dict: dict | None = None, - xtick_dict: dict | None = None, - bar_width: float = 1, - max_text: int = 50, - non_sig_color: str = "dodgerblue", - sig_color: str = "springgreen", - figsize: tuple = (6, 4), - show_title: bool = True, - show: bool = True, + adata, + n_top: int = 100, + font_dict: dict | None = None, + xtick_dict: dict | None = None, + bar_width: float = 1, + max_text: int = 50, + non_sig_color: str = "dodgerblue", + sig_color: str = "springgreen", + figsize: tuple = (6, 4), + show_title: bool = True, + show: bool = True, ): """Bar plot showing for each LR no. of sig versus non-sig spots. @@ -258,15 +258,15 @@ def lr_n_spots( def lr_go( - adata, - n_top: int = 20, - highlight_go: list | None = None, - figsize=(6, 4), - rot: float = 50, - lr_text_fp: dict | None = None, - highlight_color: str = "yellow", - max_text: int = 50, - show: bool = True, + adata, + n_top: int = 20, + highlight_go: list | None = None, + figsize=(6, 4), + rot: float = 50, + lr_text_fp: dict | None = None, + highlight_color: str = "yellow", + max_text: int = 50, + show: bool = True, ): """Plots the results from the LR GO analysis. @@ -322,13 +322,13 @@ def lr_go( def cci_check( - adata: AnnData, - use_label: str, - figsize=(16, 10), - cell_label_size=20, - axis_text_size=18, - tick_size=14, - show=True, + adata: AnnData, + use_label: str, + figsize=(16, 10), + cell_label_size=20, + axis_text_size=18, + tick_size=14, + show=True, ): """Checks relationship between no. of significant CCI-LR interactions and cell type frequency. @@ -420,32 +420,32 @@ def cci_check( # Functions for visualisation the LR results per spot. def lr_result_plot( - adata: AnnData, - use_lr: Optional["str"] = None, - use_result: Optional["str"] = "lr_sig_scores", - # plotting param - title: str | None = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - ax: plt_axis.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - zoom_coord: tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + use_lr: Optional["str"] = None, + use_result: Optional["str"] = "lr_sig_scores", + # plotting param + title: str | None = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + ax: plt_axis.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, ): """Plots the per spot statistics for given LR. @@ -538,35 +538,35 @@ def lr_result_plot( # @_docs_params(het_plot=doc_lr_plot) def lr_plot( - adata: AnnData, - lr: str, - min_expr: float = 0, - sig_spots=True, - use_label: str | None = None, - outer_mode: str = "continuous", - l_cmap=None, - r_cmap=None, - lr_cmap=None, - inner_cmap=None, - inner_size_prop: float = 0.25, - middle_size_prop: float = 0.5, - outer_size_prop: float = 1, - pt_scale: int = 100, - title="", - show_image: bool = True, - show_arrows: bool = False, - fig_or_none: plt_figure.Figure | None = None, - ax_or_none: plt_axis.Axes | None = None, - arrow_head_width: float = 4, - arrow_width: float = 0.001, - arrow_cmap: str | None = None, - arrow_vmax: float | None = None, - sig_cci: bool = False, - lr_colors: dict | None = None, - figsize: tuple = (6.4, 4.8), - use_mix: bool | None = None, - # plotting params - **kwargs, + adata: AnnData, + lr: str, + min_expr: float = 0, + sig_spots=True, + use_label: str | None = None, + outer_mode: str = "continuous", + l_cmap=None, + r_cmap=None, + lr_cmap=None, + inner_cmap=None, + inner_size_prop: float = 0.25, + middle_size_prop: float = 0.5, + outer_size_prop: float = 1, + pt_scale: int = 100, + title="", + show_image: bool = True, + show_arrows: bool = False, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, + arrow_head_width: float = 4, + arrow_width: float = 0.001, + arrow_cmap: str | None = None, + arrow_vmax: float | None = None, + sig_cci: bool = False, + lr_colors: dict | None = None, + figsize: tuple = (6.4, 4.8), + use_mix: bool | None = None, + # plotting params + **kwargs, ) -> None: """Creates different kinds of spatial visualisations for the LR analysis results. To see combinations of parameters refer to stLearn CCI tutorial. @@ -672,10 +672,10 @@ def lr_plot( # Making sure have run_cci first with respective labelling # if ( - show_arrows - and sig_cci - and use_label - and f"per_lr_cci_{use_label}" not in adata.uns + show_arrows + and sig_cci + and use_label + and f"per_lr_cci_{use_label}" not in adata.uns ): raise Exception( "Cannot subset arrow interactions to significant ccis " @@ -701,18 +701,16 @@ def lr_plot( "to adata.uns matching the use_mix ({use_mix}) key." ) elif ( - use_label is not None - and use_label in lr_use_labels - and ran_sig and not lr_sig + use_label is not None and use_label in lr_use_labels and ran_sig and not lr_sig ): raise Exception( "Since use_label refers to lr stats & ran permutation testing, " "LR needs to be significant to view stats." ) elif ( - use_label is not None - and use_label not in adata.obs.keys() - and use_label not in lr_use_labels + use_label is not None + and use_label not in adata.obs.keys() + and use_label not in lr_use_labels ): raise Exception( f"use_label must be in adata.obs or one of lr stats: {lr_use_labels}." @@ -892,34 +890,34 @@ def lr_plot( #### from old data structure when only test individual LRs. @_docs_params(spatial_base_plot=doc_spatial_base_plot, het_plot=doc_het_plot) def het_plot( - adata: AnnData, - # plotting param - title: str | None = None, - figsize: tuple[float, float] | None = None, - cmap: str = "Spectral_r", - use_label: str | None = None, - list_clusters: list | None = None, - ax: plt_axis.Axes | None = None, - fig: matplotlib.figure.Figure | None = None, - show_plot: bool = True, - show_axis: bool = False, - show_image: bool = True, - show_color_bar: bool = True, - zoom_coord: tuple[float, float, float, float] | None = None, - crop: bool = True, - margin: float = 100, - size: float = 7, - image_alpha: float = 1.0, - cell_alpha: float = 1.0, - use_raw: bool = False, - fname: str | None = None, - dpi: int = 120, - # cci_rank param - use_het: str = "het", - contour: bool = False, - step_size: int | None = None, - vmin: float | None = None, - vmax: float | None = None, + adata: AnnData, + # plotting param + title: str | None = None, + figsize: tuple[float, float] | None = None, + cmap: str = "Spectral_r", + use_label: str | None = None, + list_clusters: list | None = None, + ax: plt_axis.Axes | None = None, + fig: matplotlib.figure.Figure | None = None, + show_plot: bool = True, + show_axis: bool = False, + show_image: bool = True, + show_color_bar: bool = True, + zoom_coord: tuple[float, float, float, float] | None = None, + crop: bool = True, + margin: float = 100, + size: float = 7, + image_alpha: float = 1.0, + cell_alpha: float = 1.0, + use_raw: bool = False, + fname: str | None = None, + dpi: int = 120, + # cci_rank param + use_het: str = "het", + contour: bool = False, + step_size: int | None = None, + vmin: float | None = None, + vmax: float | None = None, ) -> None: """\ Allows the visualization of significant cell-cell interaction @@ -976,22 +974,22 @@ def het_plot( def ccinet_plot( - adata: AnnData, - use_label: str, - lr: str | None = None, - pos: dict | None = None, - return_pos: bool = False, - cmap: str = "default", - font_size: int = 12, - node_size_exp: int = 1, - node_size_scaler: int = 1, - min_counts: int = 0, - sig_interactions: bool = True, - fig_or_none: plt_figure.Figure | None = None, - ax_or_none: plt_axis.Axes | None = None, - pad=0.25, - title_or_none: str | None = None, - figsize: tuple = (10, 10), + adata: AnnData, + use_label: str, + lr: str | None = None, + pos: dict | None = None, + return_pos: bool = False, + cmap: str = "default", + font_size: int = 12, + node_size_exp: int = 1, + node_size_scaler: int = 1, + min_counts: int = 0, + sig_interactions: bool = True, + fig_or_none: plt_figure.Figure | None = None, + ax_or_none: plt_axis.Axes | None = None, + pad=0.25, + title_or_none: str | None = None, + figsize: tuple = (10, 10), ): """Circular celltype-celltype interaction network based on LR-CCI analysis. The size of the nodes drawn for each cell type indicates the total no. of @@ -1073,10 +1071,9 @@ def ccinet_plot( node_sizes = np.array( [ ( - ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[ - i, i]) / total) - * 10000 - * node_size_scaler + ((sum(int_matrix[i, :] + int_matrix[:, i]) - int_matrix[i, i]) / total) + * 10000 + * node_size_scaler ) ** (node_size_exp) for i in node_indices @@ -1090,8 +1087,8 @@ def ccinet_plot( trans_i = np.where(all_set == edge[0][0])[0][0] receive_i = np.where(all_set == edge[0][1])[0][0] e_total = ( - sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) - - int_matrix[trans_i, receive_i] + sum(list(int_matrix[trans_i, :]) + list(int_matrix[:, receive_i])) + - int_matrix[trans_i, receive_i] ) # so don't double count e_totals.append(e_total) edge_weights = [edge[1]["weight"] / e_totals[i] for i, edge in enumerate(edges)] @@ -1160,15 +1157,15 @@ def ccinet_plot( def cci_map( - adata: AnnData, - use_label: str, - lr_or_none: str | None = None, - ax_or_none: plt_axis.Axes | None = None, - show: bool = False, - figsize_or_none: tuple | None = None, - cmap: str = "Spectral_r", - sig_interactions: bool = True, - title=None, + adata: AnnData, + use_label: str, + lr_or_none: str | None = None, + ax_or_none: plt_axis.Axes | None = None, + show: bool = False, + figsize_or_none: tuple | None = None, + cmap: str = "Spectral_r", + sig_interactions: bool = True, + title=None, ): """Heatmap visualising sender->receivers of cell type interactions. @@ -1242,18 +1239,18 @@ def cci_map( def lr_cci_map( - adata: AnnData, - use_label: str, - lrs: list | np.ndarray | None = None, - n_top_lrs: int = 5, - n_top_ccis: int = 15, - min_total: int = 0, - ax_or_none: plt_axis.Axes | None = None, - figsize: tuple = (6.48, 4.8), - show: bool = False, - cmap: str = "Spectral_r", - square_scaler: int = 700, - sig_interactions: bool = True, + adata: AnnData, + use_label: str, + lrs: list | np.ndarray | None = None, + n_top_lrs: int = 5, + n_top_ccis: int = 15, + min_total: int = 0, + ax_or_none: plt_axis.Axes | None = None, + figsize: tuple = (6.48, 4.8), + show: bool = False, + cmap: str = "Spectral_r", + square_scaler: int = 700, + sig_interactions: bool = True, ): """Heatmap of interaction counts. Rows are lrs and columns are celltype->celltype interactions. @@ -1360,18 +1357,18 @@ def lr_cci_map( def lr_chord_plot( - adata: AnnData, - use_label: str, - lr: str | None = None, - min_ints: int = 2, - n_top_ccis: int = 10, - cmap: str = "default", - sig_interactions: bool = True, - label_size: int = 10, - label_rotation: float = 0, - title: str = "", - figsize: tuple = (8, 8), - show: bool = True, + adata: AnnData, + use_label: str, + lr: str | None = None, + min_ints: int = 2, + n_top_ccis: int = 10, + cmap: str = "default", + sig_interactions: bool = True, + label_size: int = 10, + label_rotation: float = 0, + title: str = "", + figsize: tuple = (8, 8), + show: bool = True, ): """Chord diagram of interactions between cell types. Note that interaction is measured as the total no. of edges connecting @@ -1479,7 +1476,7 @@ def lr_chord_plot( rotation = nodePos[i][2] # Prevent text going upside down at certain rotations if (rotation < 90 and rotation > 18 and label_rotation != 0) or ( - rotation < 120 and rotation > 90 + rotation < 120 and rotation > 90 ): label_rotation_ = -label_rotation else: @@ -1495,13 +1492,13 @@ def lr_chord_plot( def grid_plot( - adata, - use_label: str | None = None, - n_row: int = 10, - n_col: int = 10, - size: int = 1, - figsize=(4.5, 4.5), - show: bool = False, + adata, + use_label: str | None = None, + n_row: int = 10, + n_col: int = 10, + size: int = 1, + figsize=(4.5, 4.5), + show: bool = False, ): """Plots grid over the top of spatial data to show how cells will be grouped if gridded. @@ -1579,6 +1576,7 @@ def spatialcci_plot_interactive(adata: AnnData): output_notebook() show(bokeh_object.app, notebook_handle=True) + # def het_plot_interactive(adata: AnnData): # bokeh_object = BokehCciPlot(adata) # output_notebook() diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 1cc59c01..802d0463 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -9,31 +9,31 @@ def pseudotime_plot( - adata: AnnData, - library_id: str | None = None, - use_label: str = "louvain", - pseudotime_key: str = "pseudotime_key", - list_clusters: str | list[str] | None = None, - cell_alpha: float = 1.0, - image_alpha: float = 1.0, - edge_alpha: float = 0.8, - node_alpha: float = 1.0, - spot_size: float | int = 6.5, - node_size: float = 5, - show_color_bar: bool = True, - show_axis: bool = False, - show_graph: bool = True, - show_trajectories: bool = False, - reverse: bool = False, - show_node: bool = True, - show_plot: bool = True, - cropped: bool = True, - margin: int = 100, - dpi: int = 150, - output: str | None = None, - name: str | None = None, - copy: bool = False, - ax=None, + adata: AnnData, + library_id: str | None = None, + use_label: str = "louvain", + pseudotime_key: str = "pseudotime_key", + list_clusters: str | list[str] | None = None, + cell_alpha: float = 1.0, + image_alpha: float = 1.0, + edge_alpha: float = 0.8, + node_alpha: float = 1.0, + spot_size: float | int = 6.5, + node_size: float = 5, + show_color_bar: bool = True, + show_axis: bool = False, + show_graph: bool = True, + show_trajectories: bool = False, + reverse: bool = False, + show_node: bool = True, + show_plot: bool = True, + cropped: bool = True, + margin: int = 100, + dpi: int = 150, + output: str | None = None, + name: str | None = None, + copy: bool = False, + ax=None, ) -> AnnData | None: """\ Global trajectory inference plot (Only DPT). @@ -284,7 +284,7 @@ def get_cluster(search: int, split_node: dict[str, list[str]]): def get_node( - node_list: list[str], split_node: dict[str, list[str]] + node_list: list[str], split_node: dict[str, list[str]] ) -> NDArray[np.int64]: all_values = [] for node in node_list: From cf7cd055296ca7d9690523cb0fc2c0f1af42ad1d Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 10:41:46 +1000 Subject: [PATCH 080/123] Expose. --- stlearn/datasets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stlearn/datasets.py b/stlearn/datasets.py index a8c0721e..de92a5f1 100644 --- a/stlearn/datasets.py +++ b/stlearn/datasets.py @@ -1,5 +1,6 @@ -from ._datasets._datasets import example_bcba +from ._datasets._datasets import example_bcba, xenium_sge __all__ = [ "example_bcba", + "xenium_sge" ] From 615febd42f30f1e52358929c359faebea8e4e2b2 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 11:01:46 +1000 Subject: [PATCH 081/123] Fix documentation. --- stlearn/_datasets/_datasets.py | 23 +++--- stlearn/wrapper/read.py | 143 ++++++++++++++++----------------- 2 files changed, 83 insertions(+), 83 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 56fc93ca..9be892f8 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -25,45 +25,46 @@ def xenium_sge( base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", image_filename="he_image.ome.tif", zip_filename="outs.zip", - sample_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", include_hires_tiff: bool = False, ): """ - Download and extract Xenium SGE data files. + Download and extract Xenium SGE data files. Unlike scanpy this current does not + load the data. Data is located in `settings.datasetdir` / `library_id`. Args: base_url: Base URL for downloads image_filename: Name of the image file to download zip_filename: Name of the zip file to download - sample_id: Sample identifier + library_id: Identifier for the library include_hires_tiff: Whether to download the high-res TIFF image """ - sample_dir = settings.datasetdir / sample_id - sample_dir.mkdir(parents=True, exist_ok=True) + library_dir = settings.datasetdir / library_id + library_dir.mkdir(parents=True, exist_ok=True) files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz"] all_sge_files_exist = all( - (sample_dir / sge_file).exists() for sge_file in files_to_extract + (library_dir / sge_file).exists() for sge_file in files_to_extract ) download_filenames = [] if not all_sge_files_exist: download_filenames.append(zip_filename) - if include_hires_tiff and not (sample_dir / image_filename).exists(): + if include_hires_tiff and not (library_dir / image_filename).exists(): download_filenames.append(image_filename) for file_name in download_filenames: - file_path = sample_dir / file_name - url = f"{base_url}/{sample_id}/{sample_id}_{file_name}" + file_path = library_dir / file_name + url = f"{base_url}/{library_id}/{library_id}_{file_name}" if not file_path.is_file(): sc.readwrite._download(url=url, path=file_path) if not all_sge_files_exist: try: - zip_file_path = sample_dir / zip_filename + zip_file_path = library_dir / zip_filename with zf.ZipFile(zip_file_path, "r") as zip_ref: for zip_filename in files_to_extract: - with open(sample_dir / zip_filename, "wb") as file_name: + with open(library_dir / zip_filename, "wb") as file_name: file_name.write(zip_ref.read(f"outs/{zip_filename}")) except zf.BadZipFile: raise ValueError(f"Invalid zip file: {zip_file_path}") diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 730666c2..4d21802b 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -11,9 +11,9 @@ import numpy as np import pandas as pd import scanpy +from PIL import Image from anndata import AnnData from matplotlib.image import imread -from PIL import Image import stlearn @@ -22,21 +22,20 @@ def Read10X( - path: str | Path, - genome: str | None = None, - count_file: str = "filtered_feature_bc_matrix.h5", - library_id: str | None = None, - load_images: bool = True, - quality: _Quality = "hires", - image_path: str | Path | None = None, + path: str | Path, + genome: str | None = None, + count_file: str = "filtered_feature_bc_matrix.h5", + library_id: str | None = None, + load_images: bool = True, + quality: _Quality = "hires", + image_path: str | Path | None = None, ) -> AnnData: """\ - Read Visium data from 10X (wrap read_visium from scanpy) + Read data from 10X. - In addition to reading regular 10x output, - this looks for the `spatial` folder and loads images, - coordinates and scale factors. - Based on the `Space Ranger output docs`_. + In addition to reading regular 10x output, this looks for the `spatial` folder + and loads images, coordinates and scale factors. Based on the + `Space Ranger output docs`_. _Space Ranger output docs: https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/output/overview @@ -44,14 +43,14 @@ def Read10X( Parameters ---------- path - The path to directory for Visium datafiles. + The path to directory for the datafiles. genome Filter expression to genes within this genome. count_file Which file in the directory to use as the count file. Typically, it would be one of: 'filtered_feature_bc_matrix.h5' or 'raw_feature_bc_matrix.h5'. library_id - Identifier for the Visium library. Can be modified when concatenating multiple + Identifier for the library. Can be modified when concatenating multiple adata objects. load_images Load image or not. @@ -187,7 +186,7 @@ def Read10X( else: scale = adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] + ] image_coor = adata.obsm["spatial"] * scale adata.obs["imagecol"] = image_coor[:, 0] @@ -204,13 +203,13 @@ def Read10X( def ReadOldST( - count_matrix_file: PathLike[str] | str | Iterator[str], - spatial_file: int | str | bytes | PathLike[str] | PathLike[bytes], - image_file: str | Path | None = None, - library_id: str = "OldST", - scale: float = 1.0, - quality: str = "hires", - spot_diameter_fullres: float = 50, + count_matrix_file: PathLike[str] | str | Iterator[str], + spatial_file: int | str | bytes | PathLike[str] | PathLike[bytes], + image_file: str | Path | None = None, + library_id: str = "OldST", + scale: float = 1.0, + quality: str = "hires", + spot_diameter_fullres: float = 50, ) -> AnnData: """\ Read Old Spatial Transcriptomics data @@ -224,7 +223,7 @@ def ReadOldST( image_file Path to the tissue image file library_id - Identifier for the Visium library. Can be modified when concatenating multiple + Identifier for the library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -254,13 +253,13 @@ def ReadOldST( def ReadSlideSeq( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _Background = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _Background = "white", ) -> AnnData: """\ Read Slide-seq data @@ -272,7 +271,7 @@ def ReadSlideSeq( spatial_file Path to the spatial location file. library_id - Identifier for the Visium library. Can be modified when concatenating + Identifier for the library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -326,7 +325,7 @@ def ReadSlideSeq( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" @@ -337,13 +336,13 @@ def ReadSlideSeq( def ReadMERFISH( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _Background = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _Background = "white", ) -> AnnData: """\ Read MERFISH data @@ -355,7 +354,7 @@ def ReadMERFISH( spatial_file Path to the spatial location file. library_id - Identifier for the Visium library. Can be modified when concatenating + Identifier for the library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -411,7 +410,7 @@ def ReadMERFISH( adata_merfish.uns["spatial"][library_id]["scalefactors"] = {} adata_merfish.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata_merfish.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -420,14 +419,14 @@ def ReadMERFISH( def ReadSeqFish( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float = 1.0, - quality: str = "hires", - field: int = 0, - spot_diameter_fullres: float = 50, - background_color: _Background = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float = 1.0, + quality: str = "hires", + field: int = 0, + spot_diameter_fullres: float = 50, + background_color: _Background = "white", ) -> AnnData: """\ Read SeqFish data @@ -439,7 +438,7 @@ def ReadSeqFish( spatial_file Path to spatial location file. library_id - Identifier for the visium library. Can be modified when concatenating multiple + Identifier for the library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -499,7 +498,7 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -508,14 +507,14 @@ def ReadSeqFish( def ReadXenium( - feature_cell_matrix_file: str | Path, - cell_summary_file: str | Path, - image_path: Path | None = None, - library_id: str | None = None, - scale: float = 1.0, - quality: str = "hires", - spot_diameter_fullres: float = 15, - background_color: _Background = "white", + feature_cell_matrix_file: str | Path, + cell_summary_file: str | Path, + image_path: Path | None = None, + library_id: str | None = None, + scale: float = 1.0, + quality: str = "hires", + spot_diameter_fullres: float = 15, + background_color: _Background = "white", ) -> AnnData: """\ Read Xenium data @@ -529,7 +528,7 @@ def ReadXenium( image_path Path to image. Only need when loading full resolution image. library_id - Identifier for the visium library. Can be modified when concatenating multiple + Identifier for the Xenium library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -592,7 +591,7 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -601,14 +600,14 @@ def ReadXenium( def create_stlearn( - count: pd.DataFrame, - spatial: pd.DataFrame, - library_id: str, - image_path: Path | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _Background = "white", + count: pd.DataFrame, + spatial: pd.DataFrame, + library_id: str, + image_path: Path | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _Background = "white", ): """\ Create AnnData object for stLearn @@ -620,7 +619,7 @@ def create_stlearn( spatial Pandas Dataframe of spatial location of cells/spots. library_id - Identifier for the visium library. Can be modified when concatenating multiple + Identifier for the library. Can be modified when concatenating multiple adata objects. scale Set scale factor. @@ -674,7 +673,7 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres From f4321322ece628ceb335bbf537f5d4b75eba4989 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:07:08 +1000 Subject: [PATCH 082/123] Fix types, read experiment file and apply coordinate translation. --- stlearn/_datasets/_datasets.py | 13 +++---- stlearn/types.py | 9 ++++- stlearn/wrapper/read.py | 53 ++++++++++++++++++++++------- stlearn/wrapper/xenium_alignment.py | 38 +++++++++++++++++++++ 4 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 stlearn/wrapper/xenium_alignment.py diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 9be892f8..67005541 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -22,11 +22,11 @@ def example_bcba() -> AnnData: def xenium_sge( - base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", - image_filename="he_image.ome.tif", - zip_filename="outs.zip", - library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", - include_hires_tiff: bool = False, + base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", + image_filename="he_image.ome.tif", + zip_filename="outs.zip", + library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + include_hires_tiff: bool = False, ): """ Download and extract Xenium SGE data files. Unlike scanpy this current does not @@ -42,7 +42,8 @@ def xenium_sge( library_dir = settings.datasetdir / library_id library_dir.mkdir(parents=True, exist_ok=True) - files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz"] + files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz", "imagealignment.csv", + "experiment.xenium"] all_sge_files_exist = all( (library_dir / sge_file).exists() for sge_file in files_to_extract ) diff --git a/stlearn/types.py b/stlearn/types.py index 50fe0869..87ad2447 100644 --- a/stlearn/types.py +++ b/stlearn/types.py @@ -2,5 +2,12 @@ _SIMILARITY_MATRIX = Literal["cosine", "euclidean", "pearson", "spearman"] _METHOD = Literal["mean", "median", "sum"] +_QUALITY = Literal["fulres", "hires", "lowres"] +_BACKGROUND = Literal["black", "white"] -__all__ = ["_SIMILARITY_MATRIX", "_METHOD"] +__all__ = [ + "_SIMILARITY_MATRIX", + "_METHOD", + "_QUALITY", + "_BACKGROUND" +] diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 4d21802b..5050504b 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -5,7 +5,6 @@ from collections.abc import Iterator from os import PathLike from pathlib import Path -from typing import Literal import matplotlib.pyplot as plt import numpy as np @@ -16,9 +15,8 @@ from matplotlib.image import imread import stlearn - -_Quality = Literal["fulres", "hires", "lowres"] -_Background = Literal["black", "white"] +from stlearn.types import _QUALITY, _BACKGROUND +from stlearn.wrapper.xenium_alignment import apply_alignment_transformation def Read10X( @@ -27,7 +25,7 @@ def Read10X( count_file: str = "filtered_feature_bc_matrix.h5", library_id: str | None = None, load_images: bool = True, - quality: _Quality = "hires", + quality: _QUALITY = "hires", image_path: str | Path | None = None, ) -> AnnData: """\ @@ -259,7 +257,7 @@ def ReadSlideSeq( scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _Background = "white", + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read Slide-seq data @@ -342,7 +340,7 @@ def ReadMERFISH( scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _Background = "white", + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read MERFISH data @@ -426,7 +424,7 @@ def ReadSeqFish( quality: str = "hires", field: int = 0, spot_diameter_fullres: float = 50, - background_color: _Background = "white", + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read SeqFish data @@ -514,7 +512,10 @@ def ReadXenium( scale: float = 1.0, quality: str = "hires", spot_diameter_fullres: float = 15, - background_color: _Background = "white", + background_color: _BACKGROUND = "white", + alignment_matrix_file: str | Path | None = None, + experiment_xenium_file: str | Path | None = None, + default_pixel_size_microns: float = 0.2125, ) -> AnnData: """\ Read Xenium data @@ -539,6 +540,14 @@ def ReadXenium( Diameter of spot in full resolution background_color Color of the background. Only `black` or `white` is allowed. + alignment_matrix_file + Path to transformation matrix CSV file exported from Xenium Explorer. + If provided, coordinates will be transformed according to coordinate_space. + experiment_xenium_file + Path to experiment.xenium JSON file. If provided, pixel_size will be read from + here. + default_pixel_size_microns + Pixel size in microns (default 0.2125 for Xenium data). Returns ------- AnnData @@ -548,9 +557,29 @@ def ReadXenium( adata = scanpy.read_10x_h5(feature_cell_matrix_file) - spatial = metadata[["x_centroid", "y_centroid"]] - spatial.columns = ["imagecol", "imagerow"] + # Get original spatial coordinates + spatial = metadata[["x_centroid", "y_centroid"]].copy() + + # Get pixel size from experiment.xenium file or use parameter + if experiment_xenium_file is not None: + with open(experiment_xenium_file, 'r') as f: + experiment_data = json.load(f) + pixel_size_microns = experiment_data.get('pixel_size') + else: + pixel_size_microns = default_pixel_size_microns + print(f"Warning: Using default pixel size of {pixel_size_microns} microns. " + "Consider providing experiment_xenium_file for accurate pixel size.") + + # Get and apply alignment transformation if provided + if alignment_matrix_file is not None: + transform_mat = pd.read_csv(alignment_matrix_file, header=None).values + spatial = apply_alignment_transformation( + spatial, + transform_mat, + pixel_size_microns, + ) + spatial.columns = ["imagecol", "imagerow"] adata.obsm["spatial"] = spatial.values if scale is None: @@ -607,7 +636,7 @@ def create_stlearn( scale: float | None = None, quality: str = "hires", spot_diameter_fullres: float = 50, - background_color: _Background = "white", + background_color: _BACKGROUND = "white", ): """\ Create AnnData object for stLearn diff --git a/stlearn/wrapper/xenium_alignment.py b/stlearn/wrapper/xenium_alignment.py new file mode 100644 index 00000000..1d045afd --- /dev/null +++ b/stlearn/wrapper/xenium_alignment.py @@ -0,0 +1,38 @@ +from pathlib import Path +import numpy as np +import pandas as pd + +def apply_alignment_transformation( + coordinates: pd.DataFrame, + transform_mat: np.ndarray, + pixel_size_microns: float = 0.2125, +) -> pd.DataFrame: + """ + Apply transformation matrix to convert coordinates between spaces. + + From https://kb.10xgenomics.com/hc/en-us/articles/35386990499853-How-can-I-convert-coordinates-between-H-E-image-and-Xenium-data + + Parameters + ---------- + coordinates + DataFrame with columns ['x_centroid', 'y_centroid'] in microns + transform_mat + Transformation matrix from Xenium project. + pixel_size_microns + Pixel size in microns + + Returns + ------- + pd.DataFrame + Transformed coordinates + """ + + # Microns to pixels and use inverse transformation matrix + coords_pixels = coordinates.values / pixel_size_microns + transform_mat_inv = np.linalg.inv(transform_mat) + coords_homogeneous = np.column_stack([coords_pixels, np.ones(len(coords_pixels))]) + transformed_coords = np.dot(coords_homogeneous, transform_mat_inv.T) + + # Extract x, y coordinates (ignore homogeneous coordinate) + result_coords = transformed_coords[:, :2] + return pd.DataFrame(result_coords, columns=coordinates.columns) From 86806ccdbd07712a011c13666bb49b55b90ebe05 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:12:46 +1000 Subject: [PATCH 083/123] Fix. --- stlearn/_datasets/_datasets.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 67005541..7969d689 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -42,8 +42,7 @@ def xenium_sge( library_dir = settings.datasetdir / library_id library_dir.mkdir(parents=True, exist_ok=True) - files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz", "imagealignment.csv", - "experiment.xenium"] + files_to_extract = ["cell_feature_matrix.h5", "cells.csv.gz", "experiment.xenium"] all_sge_files_exist = all( (library_dir / sge_file).exists() for sge_file in files_to_extract ) @@ -52,7 +51,7 @@ def xenium_sge( if not all_sge_files_exist: download_filenames.append(zip_filename) if include_hires_tiff and not (library_dir / image_filename).exists(): - download_filenames.append(image_filename) + download_filenames += ["imagealignment.csv", image_filename] for file_name in download_filenames: file_path = library_dir / file_name @@ -68,4 +67,4 @@ def xenium_sge( with open(library_dir / zip_filename, "wb") as file_name: file_name.write(zip_ref.read(f"outs/{zip_filename}")) except zf.BadZipFile: - raise ValueError(f"Invalid zip file: {zip_file_path}") + raise ValueError(f"Invalid zip file: {library_dir / zip_filename}") From 8873c263003baf705f672ef12d93bc6631db8854 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:23:50 +1000 Subject: [PATCH 084/123] Fix. --- stlearn/_datasets/_datasets.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 7969d689..8c7fc0ac 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -24,6 +24,7 @@ def example_bcba() -> AnnData: def xenium_sge( base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", image_filename="he_image.ome.tif", + alignment_filename="imagealignment.csv", zip_filename="outs.zip", library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", include_hires_tiff: bool = False, @@ -35,6 +36,7 @@ def xenium_sge( Args: base_url: Base URL for downloads image_filename: Name of the image file to download + alignment_filename: Name of the affine transformation file to download zip_filename: Name of the zip file to download library_id: Identifier for the library include_hires_tiff: Whether to download the high-res TIFF image @@ -50,8 +52,11 @@ def xenium_sge( download_filenames = [] if not all_sge_files_exist: download_filenames.append(zip_filename) - if include_hires_tiff and not (library_dir / image_filename).exists(): - download_filenames += ["imagealignment.csv", image_filename] + if (include_hires_tiff + and not (library_dir / alignment_filename).exists() + and not (library_dir / image_filename).exists() + ): + download_filenames += [alignment_filename, image_filename] for file_name in download_filenames: file_path = library_dir / file_name From 99e820e1ca321f5d5bd2a4b352d73f833506527e Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:31:40 +1000 Subject: [PATCH 085/123] Fix logic and filename. --- stlearn/_datasets/_datasets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 8c7fc0ac..da9d36e2 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -24,7 +24,7 @@ def example_bcba() -> AnnData: def xenium_sge( base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", image_filename="he_image.ome.tif", - alignment_filename="imagealignment.csv", + alignment_filename="he_imagealignment.csv", zip_filename="outs.zip", library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", include_hires_tiff: bool = False, @@ -53,8 +53,8 @@ def xenium_sge( if not all_sge_files_exist: download_filenames.append(zip_filename) if (include_hires_tiff - and not (library_dir / alignment_filename).exists() - and not (library_dir / image_filename).exists() + and (not (library_dir / alignment_filename).exists() + or not (library_dir / image_filename).exists()) ): download_filenames += [alignment_filename, image_filename] From c5b01606c1fefdb3f9d91c4bc97d36b660f28a20 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:36:41 +1000 Subject: [PATCH 086/123] Add to docs. --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 19568d0a..324ff0e5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -208,3 +208,4 @@ Tools: `datasets` :toctree: . datasets.example_bcba() + datasets.xenium_sge() From c6413a5f6da82a9645f49b682dc6bcc6635b30e6 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 13:58:31 +1000 Subject: [PATCH 087/123] Update changes. --- HISTORY.rst | 2 ++ docs/release_notes/1.1.0.rst | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index e1752d99..d9fe79b3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -8,8 +8,10 @@ History * Added quality checks black, ruff and mypy and fixed appropriate source code. * Copy parameters now work with the same semantics as scanpy. * Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. +* .datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy's visium_sge call. API and Bug Fixes: +* Xenium TIFF and cell positions are now aligned. * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst index d8674508..a08714b6 100644 --- a/docs/release_notes/1.1.0.rst +++ b/docs/release_notes/1.1.0.rst @@ -1,15 +1,17 @@ 1.1.0 `2025-07-02` ~~~~~~~~~~~~~~~~~~~~~~~~~ -.. rubric:: Feature +.. rubric:: Features * Support Python 3.10.x * Added quality checks black, ruff and mypy and fixed appropriate source code. * Copy parameters now work with the same semantics as scanpy. * Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. +* .datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy's visium_sge call. .. rubric:: Bug fixes +* Xenium TIFF and cell positions are now aligned. * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. \ No newline at end of file From 37f48237d3c89fd7f31c9cd3f10e0c2bbca2b4c5 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 15:22:48 +1000 Subject: [PATCH 088/123] Fix formatting --- stlearn/_datasets/_datasets.py | 18 ++-- stlearn/datasets.py | 5 +- stlearn/types.py | 7 +- stlearn/wrapper/read.py | 136 ++++++++++++++-------------- stlearn/wrapper/xenium_alignment.py | 2 +- 5 files changed, 81 insertions(+), 87 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index da9d36e2..b7ae9d14 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -22,12 +22,12 @@ def example_bcba() -> AnnData: def xenium_sge( - base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", - image_filename="he_image.ome.tif", - alignment_filename="he_imagealignment.csv", - zip_filename="outs.zip", - library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", - include_hires_tiff: bool = False, + base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", + image_filename="he_image.ome.tif", + alignment_filename="he_imagealignment.csv", + zip_filename="outs.zip", + library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + include_hires_tiff: bool = False, ): """ Download and extract Xenium SGE data files. Unlike scanpy this current does not @@ -52,9 +52,9 @@ def xenium_sge( download_filenames = [] if not all_sge_files_exist: download_filenames.append(zip_filename) - if (include_hires_tiff - and (not (library_dir / alignment_filename).exists() - or not (library_dir / image_filename).exists()) + if include_hires_tiff and ( + not (library_dir / alignment_filename).exists() + or not (library_dir / image_filename).exists() ): download_filenames += [alignment_filename, image_filename] diff --git a/stlearn/datasets.py b/stlearn/datasets.py index de92a5f1..f5f99e4a 100644 --- a/stlearn/datasets.py +++ b/stlearn/datasets.py @@ -1,6 +1,3 @@ from ._datasets._datasets import example_bcba, xenium_sge -__all__ = [ - "example_bcba", - "xenium_sge" -] +__all__ = ["example_bcba", "xenium_sge"] diff --git a/stlearn/types.py b/stlearn/types.py index 87ad2447..3006b748 100644 --- a/stlearn/types.py +++ b/stlearn/types.py @@ -5,9 +5,4 @@ _QUALITY = Literal["fulres", "hires", "lowres"] _BACKGROUND = Literal["black", "white"] -__all__ = [ - "_SIMILARITY_MATRIX", - "_METHOD", - "_QUALITY", - "_BACKGROUND" -] +__all__ = ["_SIMILARITY_MATRIX", "_METHOD", "_QUALITY", "_BACKGROUND"] diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 5050504b..6e982e67 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -10,23 +10,23 @@ import numpy as np import pandas as pd import scanpy -from PIL import Image from anndata import AnnData from matplotlib.image import imread +from PIL import Image import stlearn -from stlearn.types import _QUALITY, _BACKGROUND +from stlearn.types import _BACKGROUND, _QUALITY from stlearn.wrapper.xenium_alignment import apply_alignment_transformation def Read10X( - path: str | Path, - genome: str | None = None, - count_file: str = "filtered_feature_bc_matrix.h5", - library_id: str | None = None, - load_images: bool = True, - quality: _QUALITY = "hires", - image_path: str | Path | None = None, + path: str | Path, + genome: str | None = None, + count_file: str = "filtered_feature_bc_matrix.h5", + library_id: str | None = None, + load_images: bool = True, + quality: _QUALITY = "hires", + image_path: str | Path | None = None, ) -> AnnData: """\ Read data from 10X. @@ -184,7 +184,7 @@ def Read10X( else: scale = adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] + ] image_coor = adata.obsm["spatial"] * scale adata.obs["imagecol"] = image_coor[:, 0] @@ -201,13 +201,13 @@ def Read10X( def ReadOldST( - count_matrix_file: PathLike[str] | str | Iterator[str], - spatial_file: int | str | bytes | PathLike[str] | PathLike[bytes], - image_file: str | Path | None = None, - library_id: str = "OldST", - scale: float = 1.0, - quality: str = "hires", - spot_diameter_fullres: float = 50, + count_matrix_file: PathLike[str] | str | Iterator[str], + spatial_file: int | str | bytes | PathLike[str] | PathLike[bytes], + image_file: str | Path | None = None, + library_id: str = "OldST", + scale: float = 1.0, + quality: str = "hires", + spot_diameter_fullres: float = 50, ) -> AnnData: """\ Read Old Spatial Transcriptomics data @@ -251,13 +251,13 @@ def ReadOldST( def ReadSlideSeq( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _BACKGROUND = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read Slide-seq data @@ -323,7 +323,7 @@ def ReadSlideSeq( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" @@ -334,13 +334,13 @@ def ReadSlideSeq( def ReadMERFISH( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _BACKGROUND = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read MERFISH data @@ -408,7 +408,7 @@ def ReadMERFISH( adata_merfish.uns["spatial"][library_id]["scalefactors"] = {} adata_merfish.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata_merfish.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -417,14 +417,14 @@ def ReadMERFISH( def ReadSeqFish( - count_matrix_file: str | Path, - spatial_file: str | Path, - library_id: str | None = None, - scale: float = 1.0, - quality: str = "hires", - field: int = 0, - spot_diameter_fullres: float = 50, - background_color: _BACKGROUND = "white", + count_matrix_file: str | Path, + spatial_file: str | Path, + library_id: str | None = None, + scale: float = 1.0, + quality: str = "hires", + field: int = 0, + spot_diameter_fullres: float = 50, + background_color: _BACKGROUND = "white", ) -> AnnData: """\ Read SeqFish data @@ -496,7 +496,7 @@ def ReadSeqFish( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -505,17 +505,17 @@ def ReadSeqFish( def ReadXenium( - feature_cell_matrix_file: str | Path, - cell_summary_file: str | Path, - image_path: Path | None = None, - library_id: str | None = None, - scale: float = 1.0, - quality: str = "hires", - spot_diameter_fullres: float = 15, - background_color: _BACKGROUND = "white", - alignment_matrix_file: str | Path | None = None, - experiment_xenium_file: str | Path | None = None, - default_pixel_size_microns: float = 0.2125, + feature_cell_matrix_file: str | Path, + cell_summary_file: str | Path, + image_path: Path | None = None, + library_id: str | None = None, + scale: float = 1.0, + quality: str = "hires", + spot_diameter_fullres: float = 15, + background_color: _BACKGROUND = "white", + alignment_matrix_file: str | Path | None = None, + experiment_xenium_file: str | Path | None = None, + default_pixel_size_microns: float = 0.2125, ) -> AnnData: """\ Read Xenium data @@ -562,13 +562,15 @@ def ReadXenium( # Get pixel size from experiment.xenium file or use parameter if experiment_xenium_file is not None: - with open(experiment_xenium_file, 'r') as f: + with open(experiment_xenium_file) as f: experiment_data = json.load(f) - pixel_size_microns = experiment_data.get('pixel_size') + pixel_size_microns = experiment_data.get("pixel_size") else: pixel_size_microns = default_pixel_size_microns - print(f"Warning: Using default pixel size of {pixel_size_microns} microns. " - "Consider providing experiment_xenium_file for accurate pixel size.") + print( + f"Warning: Using default pixel size of {pixel_size_microns} microns. " + "Consider providing experiment_xenium_file for accurate pixel size." + ) # Get and apply alignment transformation if provided if alignment_matrix_file is not None: @@ -620,7 +622,7 @@ def ReadXenium( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres @@ -629,14 +631,14 @@ def ReadXenium( def create_stlearn( - count: pd.DataFrame, - spatial: pd.DataFrame, - library_id: str, - image_path: Path | None = None, - scale: float | None = None, - quality: str = "hires", - spot_diameter_fullres: float = 50, - background_color: _BACKGROUND = "white", + count: pd.DataFrame, + spatial: pd.DataFrame, + library_id: str, + image_path: Path | None = None, + scale: float | None = None, + quality: str = "hires", + spot_diameter_fullres: float = 50, + background_color: _BACKGROUND = "white", ): """\ Create AnnData object for stLearn @@ -702,7 +704,7 @@ def create_stlearn( adata.uns["spatial"][library_id]["scalefactors"] = {} adata.uns["spatial"][library_id]["scalefactors"][ "tissue_" + quality + "_scalef" - ] = scale + ] = scale adata.uns["spatial"][library_id]["scalefactors"][ "spot_diameter_fullres" ] = spot_diameter_fullres diff --git a/stlearn/wrapper/xenium_alignment.py b/stlearn/wrapper/xenium_alignment.py index 1d045afd..368565db 100644 --- a/stlearn/wrapper/xenium_alignment.py +++ b/stlearn/wrapper/xenium_alignment.py @@ -1,7 +1,7 @@ -from pathlib import Path import numpy as np import pandas as pd + def apply_alignment_transformation( coordinates: pd.DataFrame, transform_mat: np.ndarray, From b95aa129a876946495d17185df8634ee612f4d47 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Thu, 3 Jul 2025 15:31:09 +1000 Subject: [PATCH 089/123] Fix parameter. --- stlearn/tools/clustering/kmeans.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tools/clustering/kmeans.py index 822130af..e055b15a 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tools/clustering/kmeans.py @@ -15,7 +15,7 @@ def kmeans( tol: float = 0.0001, random_state: int | np.random.RandomState = None, copy_x: bool = True, - algorithm: str = "auto", + algorithm: str = "lloyd", key_added: str = "kmeans", copy: bool = False, ) -> AnnData | None: @@ -37,7 +37,7 @@ def kmeans( Maximum number of iterations of the k-means algorithm for a single run. tol - Relative tolerance with regards to inertia to declare convergence. + Relative tolerance with regard to inertia to declare convergence. random_state Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. @@ -50,10 +50,9 @@ def kmeans( the data mean, in this case it will also not ensure that data is C-contiguous which may cause a significant slowdown. algorithm - K-means algorithm to use. The classical EM-style algorithm is "full". - The "elkan" variation is more efficient by using the triangle - inequality, but currently doesn't support sparse data. "auto" chooses - "elkan" for dense data and "full" for sparse data. + K-means algorithm to use. The classical EM-style algorithm is "lloyd". + The "elkan" variation can be more efficient on some datasets with + well-defined clusters, by using the triangle inequality. key_added Key add to adata.obs copy From d6a3cfa4dff46a2bdaae0407822a0af37d7af242 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Fri, 4 Jul 2025 16:30:38 +1000 Subject: [PATCH 090/123] Set location of colorbar to be center left rather than best. --- stlearn/plotting/classes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 343bdc93..89604f93 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -190,6 +190,7 @@ def _add_color_bar(self, plot, color_bar_label: str = ""): shrink=0.5, cmap=self.cmap, label=color_bar_label, + loc="center left" ) cb.outline.set_visible(False) From 5a30d3150e18056c270cf581938c9ff0604eaa2c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:27:07 +1000 Subject: [PATCH 091/123] Use scanpy to get data instead of one bc sample in dropbox. --- docs/api.rst | 2 +- stlearn/_datasets/_datasets.py | 36 ++++++++++++++++++----------- stlearn/datasets.py | 4 ++-- stlearn/plotting/cci_plot.py | 2 +- stlearn/plotting/cluster_plot.py | 2 +- stlearn/plotting/feat_plot.py | 2 +- stlearn/plotting/gene_plot.py | 2 +- stlearn/plotting/subcluster_plot.py | 2 +- 8 files changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 324ff0e5..0251f8b2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -207,5 +207,5 @@ Tools: `datasets` .. autosummary:: :toctree: . - datasets.example_bcba() + datasets.visium_sge() datasets.xenium_sge() diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index b7ae9d14..c1fdec45 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -2,24 +2,34 @@ import scanpy as sc from anndata import AnnData +from scanpy.datasets._datasets import VisiumSampleID from .._settings import settings +def visium_sge( + sample_id: VisiumSampleID = "V1_Breast_Cancer_Block_A_Section_1", + *, + include_hires_tiff: bool = False, +) -> AnnData: + """Processed Visium Spatial Gene Expression data from 10x Genomics’ database. -def example_bcba() -> AnnData: - """\ - Download processed BCBA data (10X genomics published data). - Reference: - https://support.10xgenomics.com/spatial-gene-expression/datasets/1.1.0/V1_Breast_Cancer_Block_A_Section_1 - """ - settings.datasetdir.mkdir(parents=True, exist_ok=True) - filename = settings.datasetdir / "example_bcba.h5" - url = "https://www.dropbox.com/s/u3m2f16mvdom1am/example_bcba.h5ad?dl=1" - if not filename.is_file(): - sc.readwrite._download(url=url, path=filename) - adata = sc.read_h5ad(filename) - return adata + The database_ can be browsed online to find the ``sample_id`` you want. + + .. _database: https://support.10xgenomics.com/spatial-gene-expression/datasets + Parameters + ---------- + sample_id + The ID of the data sample in 10x’s spatial database. + include_hires_tiff + Download and include the high-resolution tissue image (tiff) in + `adata.uns["spatial"][sample_id]["metadata"]["source_image_path"]`. + + Returns + ------- + Annotated data matrix. + """ + return sc.datasets.visium_sge(sample_id, include_hires_tiff=include_hires_tiff) def xenium_sge( base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", diff --git a/stlearn/datasets.py b/stlearn/datasets.py index f5f99e4a..34a6ffd7 100644 --- a/stlearn/datasets.py +++ b/stlearn/datasets.py @@ -1,3 +1,3 @@ -from ._datasets._datasets import example_bcba, xenium_sge +from ._datasets._datasets import visium_sge, xenium_sge -__all__ = ["example_bcba", "xenium_sge"] +__all__ = ["visium_sge", "xenium_sge"] diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 33fa4d84..06e6271e 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -933,7 +933,7 @@ def het_plot( Examples ------------------------------------- >>> import stlearn as st - >>> adata = st.datasets.example_bcba() + >>> adata = st.datasets.visium_sge(sample_id="V1_Breast_Cancer_Block_A_Section_1") >>> pvalues = "lr_pvalues" >>> st.pl.gene_plot(adata, use_het = pvalues) diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index 2eb6a66c..b4db26c3 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -67,7 +67,7 @@ def cluster_plot( Examples ------------------------------------- >>> import stlearn as st - >>> adata = st.datasets.example_bcba() + >>> adata = st.datasets.visium_sge(sample_id="V1_Breast_Cancer_Block_A_Section_1") >>> label = "louvain" >>> st.pl.cluster_plot(adata, use_label = label, show_trajectories = True) diff --git a/stlearn/plotting/feat_plot.py b/stlearn/plotting/feat_plot.py index e47450e9..7a3d4bf7 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/plotting/feat_plot.py @@ -56,7 +56,7 @@ def feat_plot( Examples ------------------------------------- >>> import stlearn as st - >>> adata = st.datasets.example_bcba() + >>> adata = st.datasets.visium_sge(sample_id="V1_Breast_Cancer_Block_A_Section_1") >>> st.pl.gene_plot(adata, 'dpt_pseudotime') """ diff --git a/stlearn/plotting/gene_plot.py b/stlearn/plotting/gene_plot.py index cca713d0..8f4244c8 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/plotting/gene_plot.py @@ -54,7 +54,7 @@ def gene_plot( Examples ------------------------------------- >>> import stlearn as st - >>> adata = st.datasets.example_bcba() + >>> adata = st.datasets.visium_sge(sample_id="V1_Breast_Cancer_Block_A_Section_1") >>> genes = ["BRCA1","BRCA2"] >>> st.pl.gene_plot(adata, gene_symbols = genes) diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/plotting/subcluster_plot.py index 0e8c66e5..3f5eff3d 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/plotting/subcluster_plot.py @@ -50,7 +50,7 @@ def subcluster_plot( Examples ------------------------------------- >>> import stlearn as st - >>> adata = st.datasets.example_bcba() + >>> adata = st.datasets.visium_sge(sample_id="V1_Breast_Cancer_Block_A_Section_1") >>> label = "louvain" >>> cluster = 6 >>> st.pl.cluster_plot(adata, use_label = label, cluster = cluster) From 73eb7ba47f4a2c2c6856f3576b0c21a08796e55a Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:27:28 +1000 Subject: [PATCH 092/123] Use scanpy to get data instead of one bc sample in dropbox. --- stlearn/_datasets/_datasets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index c1fdec45..b5e5ef60 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -6,6 +6,7 @@ from .._settings import settings +# TODO - Add scanpy and covert this over. def visium_sge( sample_id: VisiumSampleID = "V1_Breast_Cancer_Block_A_Section_1", *, From c815d24fdfe31fc93cc454b4c47600e4123f05d1 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:33:27 +1000 Subject: [PATCH 093/123] Use scanpy to get data instead of one bc sample in dropbox. --- HISTORY.rst | 3 ++- docs/release_notes/1.1.0.rst | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index d9fe79b3..a6c64446 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -8,13 +8,14 @@ History * Added quality checks black, ruff and mypy and fixed appropriate source code. * Copy parameters now work with the same semantics as scanpy. * Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. -* .datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy's visium_sge call. +* datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy.visium_sge. API and Bug Fixes: * Xenium TIFF and cell positions are now aligned. * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. +* Removed datasets.example_bcba - Replaces with wrapper for scanpy.visium_sge. 0.4.11 (2022-11-25) ------------------ diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst index a08714b6..fc7d97dc 100644 --- a/docs/release_notes/1.1.0.rst +++ b/docs/release_notes/1.1.0.rst @@ -7,11 +7,12 @@ * Added quality checks black, ruff and mypy and fixed appropriate source code. * Copy parameters now work with the same semantics as scanpy. * Library upgrades for leidenalg, louvain, numba, numpy, scanpy, and tensorflow. -* .datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy's visium_sge call. +* datasets.xenium_sge - loads Xenium data (and caches it) similar to scanpy.visium_sge. .. rubric:: Bug fixes * Xenium TIFF and cell positions are now aligned. * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. -* pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. \ No newline at end of file +* pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. +* Removed datasets.example_bcba - Replaces with wrapper for scanpy.visium_sge. \ No newline at end of file From ab3bd6fad24e1615231199d4fd478c39184f9409 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:35:26 +1000 Subject: [PATCH 094/123] Use scanpy to get data instead of one bc sample in dropbox. --- stlearn/_datasets/_datasets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index b5e5ef60..79cb19e7 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -8,7 +8,7 @@ # TODO - Add scanpy and covert this over. def visium_sge( - sample_id: VisiumSampleID = "V1_Breast_Cancer_Block_A_Section_1", + sample_id = "V1_Breast_Cancer_Block_A_Section_1", *, include_hires_tiff: bool = False, ) -> AnnData: From 7c572a608c5aa170e5cb9bf9b92abe33bb01fd30 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:37:58 +1000 Subject: [PATCH 095/123] Fix datasetdir. --- stlearn/_datasets/_datasets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 79cb19e7..392f1630 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -2,8 +2,6 @@ import scanpy as sc from anndata import AnnData -from scanpy.datasets._datasets import VisiumSampleID - from .._settings import settings # TODO - Add scanpy and covert this over. @@ -30,6 +28,7 @@ def visium_sge( ------- Annotated data matrix. """ + sc.settings.datasetdir = settings.datasetdir return sc.datasets.visium_sge(sample_id, include_hires_tiff=include_hires_tiff) def xenium_sge( @@ -52,6 +51,7 @@ def xenium_sge( library_id: Identifier for the library include_hires_tiff: Whether to download the high-res TIFF image """ + sc.settings.datasetdir = settings.datasetdir library_dir = settings.datasetdir / library_id library_dir.mkdir(parents=True, exist_ok=True) From 5f4f3a20d3c3d2cdc9fe4d48ab0737bdc498e4b4 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 10:45:50 +1000 Subject: [PATCH 096/123] No loc in colorbar. --- stlearn/plotting/classes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 89604f93..343bdc93 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -190,7 +190,6 @@ def _add_color_bar(self, plot, color_bar_label: str = ""): shrink=0.5, cmap=self.cmap, label=color_bar_label, - loc="center left" ) cb.outline.set_visible(False) From 1f9424e9624023723a131d40e4a31d91dbfb0162 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 11:15:41 +1000 Subject: [PATCH 097/123] Fix error message. --- stlearn/plotting/classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 343bdc93..1eb81c3a 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -786,7 +786,7 @@ def _add_cluster_labels(self): def _add_sub_clusters(self): if "sub_cluster_labels" not in self.query_adata.obs.columns: - raise ValueError("Please run stlearn.spatial.cluster.localization") + raise ValueError("Please run stlearn.spatial.clustering.localization") for i, label in enumerate(self.list_clusters): label_index = list( From 3fc4f60aa46cae69a19af38827420ac0bbda5a67 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 11:26:08 +1000 Subject: [PATCH 098/123] Fix calling to centroidpython. --- stlearn/plotting/classes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 1eb81c3a..87b8d22b 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -1041,7 +1041,9 @@ def _plot_subclusters(self, threshold_spots): def _add_subclusters_label(self, subset): if len(subset["sub_cluster_labels"].unique()) < 2: print("lower than 2") - centroids = [centroidpython(subset[["imagecol", "imagerow"]].values)] + imgcol = subset["imagecol"].values + imgrow = subset["imagerow"].values + centroids = [centroidpython(imgcol, imgrow)] classes = np.array([subset["sub_cluster_labels"][0]]) else: From 24c402f03536b4f5bb16e80a2f7f0fb4698aca34 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 11:52:45 +1000 Subject: [PATCH 099/123] Fix bug. --- stlearn/plotting/classes.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index 87b8d22b..eb5528ed 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -1039,8 +1039,11 @@ def _plot_subclusters(self, threshold_spots): return subset def _add_subclusters_label(self, subset): - if len(subset["sub_cluster_labels"].unique()) < 2: - print("lower than 2") + unique_subcluster_labels = len(subset["sub_cluster_labels"].unique()) + if unique_subcluster_labels == 1: + print("No unique labels found") + return + elif unique_subcluster_labels == 1: imgcol = subset["imagecol"].values imgrow = subset["imagerow"].values centroids = [centroidpython(imgcol, imgrow)] From afb8a9f6070b4352d4eacb65ab3988d06b49ac16 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sat, 5 Jul 2025 16:25:54 +1000 Subject: [PATCH 100/123] Fixup use of categories from cluster names in plotting and make consistent in pseudotime implementations - especially uns["split_node"]. --- stlearn/_datasets/_datasets.py | 25 +++++---- stlearn/plotting/classes.py | 44 ++++++++------- stlearn/plotting/cluster_plot.py | 2 +- .../plotting/trajectory/pseudotime_plot.py | 55 +++++++------------ stlearn/plotting/utils.py | 20 +++---- stlearn/spatials/clustering/localization.py | 2 +- stlearn/spatials/trajectory/global_level.py | 12 ++-- stlearn/spatials/trajectory/pseudotime.py | 38 ++++++------- .../trajectory/shortest_path_spatial_PAGA.py | 18 +----- stlearn/tools/microenv/cci/analysis.py | 2 +- 10 files changed, 92 insertions(+), 126 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index 392f1630..a3a4d054 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -2,13 +2,15 @@ import scanpy as sc from anndata import AnnData + from .._settings import settings + # TODO - Add scanpy and covert this over. def visium_sge( - sample_id = "V1_Breast_Cancer_Block_A_Section_1", - *, - include_hires_tiff: bool = False, + sample_id="V1_Breast_Cancer_Block_A_Section_1", + *, + include_hires_tiff: bool = False, ) -> AnnData: """Processed Visium Spatial Gene Expression data from 10x Genomics’ database. @@ -31,13 +33,14 @@ def visium_sge( sc.settings.datasetdir = settings.datasetdir return sc.datasets.visium_sge(sample_id, include_hires_tiff=include_hires_tiff) + def xenium_sge( - base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", - image_filename="he_image.ome.tif", - alignment_filename="he_imagealignment.csv", - zip_filename="outs.zip", - library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", - include_hires_tiff: bool = False, + base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", + image_filename="he_image.ome.tif", + alignment_filename="he_imagealignment.csv", + zip_filename="outs.zip", + library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + include_hires_tiff: bool = False, ): """ Download and extract Xenium SGE data files. Unlike scanpy this current does not @@ -64,8 +67,8 @@ def xenium_sge( if not all_sge_files_exist: download_filenames.append(zip_filename) if include_hires_tiff and ( - not (library_dir / alignment_filename).exists() - or not (library_dir / image_filename).exists() + not (library_dir / alignment_filename).exists() + or not (library_dir / image_filename).exists() ): download_filenames += [alignment_filename, image_filename] diff --git a/stlearn/plotting/classes.py b/stlearn/plotting/classes.py index eb5528ed..53eff406 100644 --- a/stlearn/plotting/classes.py +++ b/stlearn/plotting/classes.py @@ -32,7 +32,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -54,6 +54,7 @@ def __init__( super().__init__( adata, ) + self.title = title self.figsize = figsize self.image_alpha = image_alpha @@ -76,17 +77,16 @@ def __init__( ), "Please choose the right label in `adata.obs.columns`!" self.use_label = use_label + unique_categories = np.array(self.adata[0].obs[use_label].cat.categories) + if self.list_clusters is None: - self.list_clusters = np.array( - self.adata[0].obs[use_label].cat.categories - ) + self.list_clusters = unique_categories else: if not isinstance(self.list_clusters, list): self.list_clusters = [self.list_clusters] clusters_indexes = [ - np.where(adata.obs[use_label].cat.categories == i)[0][0] - for i in self.list_clusters + np.where(unique_categories == i)[0][0] for i in self.list_clusters ] self.list_clusters = np.array(self.list_clusters)[ np.argsort(clusters_indexes) @@ -229,7 +229,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -437,7 +437,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -598,7 +598,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "default", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -708,9 +708,7 @@ def _plot_clusters(self): ] if self.use_label + "_colors" in self.adata[0].uns: - label_set = ( - self.adata[0].obs[self.use_label].cat.categories.values.astype(str) - ) + label_set = self.adata[0].obs[self.use_label].cat.categories.values col_index = np.where(label_set == cluster[0])[0][0] color = self.adata[0].uns[self.use_label + "_colors"][col_index] else: @@ -907,19 +905,23 @@ def _add_trajectories(self): ) if self.show_node: - for x, y in centroid_dict.items(): - if x in get_node(self.list_clusters, self.adata[0].uns["split_node"]): + for node, pos in centroid_dict.items(): + if str(node) in get_node( + self.list_clusters, self.adata[0].uns["split_node"] + ): self.ax.text( - y[0], - y[1], - get_cluster(str(x), self.adata[0].uns["split_node"]), + pos[0], + pos[1], + get_cluster(str(node), self.adata[0].uns["split_node"]), color="black", fontsize=8, zorder=100, bbox=dict( facecolor=cmap( int( - get_cluster(str(x), self.adata[0].uns["split_node"]) + get_cluster( + str(node), self.adata[0].uns["split_node"] + ) ) / (len(used_colors) - 1) ), @@ -945,7 +947,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "jet", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -1104,7 +1106,7 @@ def __init__( figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, @@ -1174,7 +1176,7 @@ def __init__( title: Optional["str"] = None, figsize: tuple[float, float] | None = None, cmap: str = "Spectral_r", - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/plotting/cluster_plot.py index b4db26c3..1293dab6 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/plotting/cluster_plot.py @@ -21,7 +21,7 @@ def cluster_plot( figsize: tuple[float, float] | None = None, cmap: str = "default", use_label: str | None = None, - list_clusters: list | None = None, + list_clusters: str | list[str] | None = None, ax: matplotlib.axes.Axes | None = None, fig: matplotlib.figure.Figure | None = None, show_plot: bool = True, diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/plotting/trajectory/pseudotime_plot.py index 802d0463..ee72a426 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/plotting/trajectory/pseudotime_plot.py @@ -5,6 +5,7 @@ from matplotlib import pyplot as plt from numpy._typing import NDArray +from stlearn.plotting.utils import get_cluster, get_node from stlearn.utils import _read_graph @@ -12,7 +13,7 @@ def pseudotime_plot( adata: AnnData, library_id: str | None = None, use_label: str = "louvain", - pseudotime_key: str = "pseudotime_key", + pseudotime_key: str = "dpt_pseudotime", list_clusters: str | list[str] | None = None, cell_alpha: float = 1.0, image_alpha: float = 1.0, @@ -32,7 +33,6 @@ def pseudotime_plot( dpi: int = 150, output: str | None = None, name: str | None = None, - copy: bool = False, ax=None, ) -> AnnData | None: """\ @@ -78,8 +78,6 @@ def pseudotime_plot( The output folder of the plot. name The filename of the plot. - copy - Return a copy instead of writing to adata. Returns ------- Nothing @@ -87,8 +85,7 @@ def pseudotime_plot( checked_list_clusters: list[str] if list_clusters is None: - unique_labels = adata.obs[use_label].unique() - checked_list_clusters = [str(i) for i in range(len(unique_labels))] + checked_list_clusters = adata.obs[use_label].cat.categories elif isinstance(list_clusters, str): checked_list_clusters = [list_clusters] else: @@ -102,7 +99,9 @@ def pseudotime_plot( labels = nx.get_edge_attributes(G, "weight") result = [] - query_node = get_node(checked_list_clusters, adata.uns["split_node"]) + query_node = list( + map(int, get_node(checked_list_clusters, adata.uns["split_node"])) + ) for edge in G.edges(query_node): if (edge[0] in query_node) and (edge[1] in query_node): result.append(edge) @@ -158,18 +157,18 @@ def pseudotime_plot( edge_color="#333333", ) - for x, y in centroid_dict.items(): - if x in get_node(checked_list_clusters, adata.uns["split_node"]): + for node, pos in centroid_dict.items(): + if str(node) in get_node(checked_list_clusters, adata.uns["split_node"]): a.text( - y[0], - y[1], - get_cluster(x, adata.uns["split_node"]), + pos[0], + pos[1], + get_cluster(str(node), adata.uns["split_node"]), color="white", fontsize=node_size, zorder=100, bbox=dict( facecolor=cmap( - int(get_cluster(x, adata.uns["split_node"])) + int(get_cluster(str(node), adata.uns["split_node"])) / (len(used_colors) - 1) ), boxstyle="circle", @@ -221,18 +220,20 @@ def pseudotime_plot( ) if show_node: - for x, y in centroid_dict.items(): - if x in get_node(checked_list_clusters, adata.uns["split_node"]): + for node, pos in centroid_dict.items(): + if str(node) in get_node( + checked_list_clusters, adata.uns["split_node"] + ): a.text( - y[0], - y[1], - get_cluster(x, adata.uns["split_node"]), + pos[0], + pos[1], + str(get_cluster(str(node), adata.uns["split_node"])), color="black", fontsize=8, zorder=100, bbox=dict( facecolor=cmap( - int(get_cluster(x, adata.uns["split_node"])) + get_cluster(str(node), adata.uns["split_node"]) / (len(used_colors) - 1) ), boxstyle="circle", @@ -274,19 +275,3 @@ def pseudotime_plot( plt.show() return adata - - -# get name of cluster by subcluster -def get_cluster(search: int, split_node: dict[str, list[str]]): - for cl, sub in split_node.items(): - if str(search) in sub: - return cl - - -def get_node( - node_list: list[str], split_node: dict[str, list[str]] -) -> NDArray[np.int64]: - all_values = [] - for node in node_list: - all_values.extend(split_node[node]) - return np.array([int(val) for val in all_values], dtype=np.int64) diff --git a/stlearn/plotting/utils.py b/stlearn/plotting/utils.py index e819a8be..a1380899 100644 --- a/stlearn/plotting/utils.py +++ b/stlearn/plotting/utils.py @@ -31,22 +31,18 @@ def centroidpython(x, y): return sum(x) / length_of_x, sum(y) / length_of_x -def get_cluster(search, dictionary): - for ( - cl, - sub, - ) in ( - dictionary.items() - ): # for name, age in dictionary.iteritems(): (for Python 2.x) - if search in sub: +# get name of cluster by subcluster +def get_cluster(search: str, split_node: dict[str, list[str]]): + for cl, sub in split_node.items(): + if str(search) in sub: return cl -def get_node(node_list, split_node): - result = np.array([]) +def get_node(node_list: list[str], split_node: dict[str, list[str]]) -> list[str]: + all_values = [] for node in node_list: - result = np.append(result, np.array(split_node[node]).astype(int)) - return result.astype(int) + all_values.extend(split_node[node]) + return all_values def check_sublist(full, sub): diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatials/clustering/localization.py index f2594454..c45757e9 100644 --- a/stlearn/spatials/clustering/localization.py +++ b/stlearn/spatials/clustering/localization.py @@ -81,7 +81,7 @@ def localization( ), ) - labels_cat = adata.obs[use_label].cat.categories + labels_cat = list(map(int, adata.obs[use_label].cat.categories)) cat_ind = {labels_cat[i]: i for i in range(len(labels_cat))} adata.uns[use_label + "_index_dict"] = cat_ind diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatials/trajectory/global_level.py index 16308966..1a77985b 100644 --- a/stlearn/spatials/trajectory/global_level.py +++ b/stlearn/spatials/trajectory/global_level.py @@ -55,7 +55,7 @@ def global_level( query_nodes = list(cat_inds.values()) else: if isinstance(list_clusters[0], str): - list_clusters = [cat_inds[label] for label in list_clusters] + list_clusters = [cat_inds[int(label)] for label in list_clusters] query_nodes = list_clusters query_nodes = ordering_nodes(query_nodes, use_label, adata) @@ -75,19 +75,19 @@ def global_level( ].unique(): query_dict[int(j)] = int(i) order_dict[int(j)] = int(order) - order += 1 dm_list = [] sdm_list = [] order_big_dict = {} edge_list = [] + split_node = adata.uns["split_node"] for i, j in enumerate(query_nodes): order_big_dict[j] = int(i) if i == len(query_nodes) - 1: break - for j in adata.uns["split_node"][query_nodes[i]]: - for k in adata.uns["split_node"][query_nodes[i + 1]]: + for j in split_node[str(query_nodes[i])]: + for k in split_node[str(query_nodes[i + 1])]: edge_list.append((int(j), int(k))) # Calculate DPT distance matrix @@ -123,7 +123,7 @@ def global_level( ) H_sub = nx.DiGraph(H_sub) prepare_root = [] - for node in adata.uns["split_node"][query_nodes[0]]: + for node in split_node[str(query_nodes[0])]: H_sub.add_edge(9999, int(node)) prepare_root.append(centroid_dict[int(node)]) @@ -141,7 +141,7 @@ def global_level( H_sub = nx.DiGraph(H_sub) prepare_root = [] - for node in adata.uns["split_node"][query_nodes[0]]: + for node in split_node[str(query_nodes[0])]: H_sub.add_edge(9999, int(node)) prepare_root.append(centroid_dict[int(node)]) diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatials/trajectory/pseudotime.py index b2641791..f70e2a8f 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatials/trajectory/pseudotime.py @@ -1,15 +1,19 @@ import networkx as nx import numpy as np import pandas as pd -import scanpy +import scanpy as sc from anndata import AnnData +from sklearn.neighbors import NearestCentroid +from stlearn.pp import neighbors +from stlearn.spatials.clustering import localization +from stlearn.spatials.morphology import adjust from stlearn.types import _METHOD def pseudotime( adata: AnnData, - use_label: str | None = None, + use_label: str = "louvain", eps: float = 20, n_neighbors: int = 25, use_rep: str = "X_pca", @@ -68,30 +72,21 @@ def pseudotime( except: pass - assert use_label is not None, "Please choose the right `use_label`!" - - # Localize - from stlearn.spatials.clustering import localization - - if "sub_clusters_laber" not in adata.obs.columns: + if "sub_cluster_labels" not in adata.obs.columns: localization(adata, use_label=use_label, eps=eps) # Running knn if run_knn: - from stlearn.pp import neighbors - neighbors(adata, n_neighbors=n_neighbors, use_rep=use_rep, random_state=0) # Running paga - scanpy.tl.paga(adata, groups=use_label) + sc.tl.paga(adata, groups=use_label) # Denoising the graph - scanpy.tl.diffmap(adata) + sc.tl.diffmap(adata) if use_sme: - from stlearn.spatials.morphology import adjust - adjust(adata, use_data="X_diffmap", radius=radius, method=method) adata.obsm["X_diffmap"] = adata.obsm["X_diffmap_morphology"] @@ -103,9 +98,9 @@ def pseudotime( cnt_matrix = pd.DataFrame(cnt_matrix) # Mapping louvain label to subcluster - cat_ind = adata.uns[use_label + "_index_dict"] + cat_inds = adata.uns[use_label + "_index_dict"] split_node = {} - for label in adata.obs[use_label].unique(): + for label in adata.obs[use_label].cat.categories: meaningful_sub = [] for i in adata.obs[adata.obs[use_label] == label][ "sub_cluster_labels" @@ -116,10 +111,12 @@ def pseudotime( ): meaningful_sub.append(i) - split_node[cat_ind[label]] = meaningful_sub + label = cat_inds[int(label)] + split_node[label] = meaningful_sub adata.uns["threshold_spots"] = threshold_spots - adata.uns["split_node"] = split_node + # split_node has string keys for rest of code/plotting (names a strings) + adata.uns["split_node"] = {str(k): v for k, v in split_node.items()} # Replicate louvain label row to prepare for subcluster connection # matrix construction @@ -155,8 +152,6 @@ def pseudotime( adata.uns["global_graph"]["node_dict"] = node_convert # Create centroid dict for subclusters - from sklearn.neighbors import NearestCentroid - clf = NearestCentroid() clf.fit(adata.obs[["imagecol", "imagerow"]].values, adata.obs["sub_cluster_labels"]) centroid_dict = dict(zip(clf.classes_.astype(int), clf.centroids_)) @@ -174,10 +169,9 @@ def closest_node(node, nodes): centroid_dict[int(cl)] = new_centroid adata.uns["centroid_dict"] = centroid_dict - centroid_dict = {int(key): centroid_dict[key] for key in centroid_dict} # Running diffusion pseudo-time - scanpy.tl.dpt(adata) + sc.tl.dpt(adata) if reverse: adata.obs[pseudotime_key] = 1 - adata.obs[pseudotime_key] diff --git a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py b/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py index 7d7ea2ae..8daef15b 100644 --- a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py +++ b/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py @@ -1,6 +1,6 @@ import networkx as nx -import numpy as np +from stlearn.plotting.utils import get_node from stlearn.utils import _read_graph @@ -26,7 +26,7 @@ def shortest_path_spatial_PAGA( key ].max() - # Force original PAGA to directed PAGA based on pseudotime + # Force original PAGA to a directed PAGA based on pseudotime edge_to_remove = [] for edge in H.edges: if node_pseudotime[edge[0]] - node_pseudotime[edge[1]] > 0: @@ -72,20 +72,6 @@ def shortest_path_spatial_PAGA( return shortest_path.split(",") -# get name of cluster by subcluster -def get_cluster(search, dictionary): - for cl, sub in dictionary.items(): - if search in sub: - return cl - - -def get_node(node_list, split_node): - result = np.array([]) - for node in node_list: - result = np.append(result, np.array(split_node[int(node)]).astype(int)) - return result.astype(int) - - def find_min_max_node(adata, key="dpt_pseudotime", use_label="leiden"): min_cluster = int(adata.obs[adata.obs[key] == 0][use_label].values[0]) max_cluster = int(adata.obs[adata.obs[key] == 1][use_label].values[0]) diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tools/microenv/cci/analysis.py index 3315d253..0905202c 100644 --- a/stlearn/tools/microenv/cci/analysis.py +++ b/stlearn/tools/microenv/cci/analysis.py @@ -174,7 +174,7 @@ def grid( grid_data.obs[use_label] = [cell_set[index] for index in max_indices] grid_data.obs[use_label] = grid_data.obs[use_label].astype("category") grid_data.obs[use_label] = grid_data.obs[use_label].cat.set_categories( - list(adata.obs[use_label].cat.categories) + adata.obs[use_label].cat.categories ) if f"{use_label}_colors" in adata.uns: grid_data.uns[f"{use_label}_colors"] = adata.uns[f"{use_label}_colors"] From e6f4109cfc54bebbfb08db13f33cd73baeecd202 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 13:27:00 +1000 Subject: [PATCH 101/123] Change format of lrfeatures (so that it persists) and cleanup. --- stlearn/__init__.py | 2 +- stlearn/plotting/cci_plot.py | 85 ++---------------------- stlearn/plotting/cci_plot_helpers.py | 53 ++++----------- stlearn/tl.py | 2 + stlearn/tools/cache/__init__.py | 6 ++ stlearn/tools/cache/anndata.py | 51 ++++++++++++++ stlearn/tools/microenv/cci/perm_utils.py | 16 +++-- 7 files changed, 87 insertions(+), 128 deletions(-) create mode 100644 stlearn/tools/cache/__init__.py create mode 100644 stlearn/tools/cache/anndata.py diff --git a/stlearn/__init__.py b/stlearn/__init__.py index 51f1e6f3..213fe82f 100644 --- a/stlearn/__init__.py +++ b/stlearn/__init__.py @@ -1,6 +1,6 @@ """Top-level package for stLearn.""" -__author__ = """Genomics and Machine Learning lab""" +__author__ = """Genomics and Machine Learning Lab""" __email__ = "andrew.newman@uq.edu.au" __version__ = "1.1.0" diff --git a/stlearn/plotting/cci_plot.py b/stlearn/plotting/cci_plot.py index 06e6271e..95fde072 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/plotting/cci_plot.py @@ -132,7 +132,7 @@ def lr_summary( A list of LRs to highlight on the plot, will added text and change color of points for these LRs. Useful for highlighting LRs of interest. y: str - The way to rank the LRs, default is by the no. of signifcant spots, + The way to rank the LRs, default is by the no. of significant spots, but can be any column in adata.uns['lr_summary']. color: str The color of the points. @@ -1379,13 +1379,13 @@ def lr_chord_plot( Each cell type has a labelled edge taking up a proportion of the outter circle. Chords connecting cell type edges are coloured by the dominant sending cell. - Each chord linking cell types has an assymetric shape. + Each chord linking cell types has an asymmetric shape. For two cell types, A and B, the side of the chord attached to edge A is sized by the total interactions from B->A, where B is expressing the ligand & A is expressing the receptor. - Hence, the proportion of a cell type's edge in the chordplot circle + Hence, the proportion of a cell type's edge in the chord plot circle represents the total input signals to that cell type; while the - area of the chordplot circle taken up by the outputted chords from a given + area of the chord plot circle taken up by the outputted chords from a given cell type represents the total output signals from that cell type. Parameters @@ -1550,7 +1550,7 @@ def grid_plot( return fig, ax -####################### Bokeh Interactive Plots ################################ +# Bokeh Interactive Plots def lr_plot_interactive(adata: AnnData): """Plots the LR scores for significant spots interatively using Bokeh. @@ -1575,78 +1575,3 @@ def spatialcci_plot_interactive(adata: AnnData): bokeh_object = BokehSpatialCciPlot(adata) output_notebook() show(bokeh_object.app, notebook_handle=True) - - -# def het_plot_interactive(adata: AnnData): -# bokeh_object = BokehCciPlot(adata) -# output_notebook() -# show(bokeh_object.app, notebook_handle=True) - - -# Bokeh & old grid plots; -# has not been tested since multi-LR testing implimentation. - -# def het_plot_interactive(adata: AnnData): -# bokeh_object = BokehCciPlot(adata) -# output_notebook() -# show(bokeh_object.app, notebook_handle=True) - - -# def grid_plot( -# adata: AnnData, -# use_het: str = None, -# num_row: int = 10, -# num_col: int = 10, -# vmin: float = None, -# vmax: float = None, -# cropped: bool = True, -# margin: int = 100, -# dpi: int = 100, -# name: str = None, -# output: str = None, -# copy: bool = False, -# ) -> Optional[AnnData]: -# -# """ -# Cell diversity plot for sptial transcriptomics data. -# -# Parameters -# ---------- -# adata: Annotated data matrix. -# use_het: Cluster heterogeneity count results from tl.cci_rank.het -# num_row: int Number of grids on height -# num_col: int Number of grids on width -# cropped crop image or not. -# margin margin used in cropping. -# dpi: Set dpi as the resolution for the plot. -# name: Name of the output figure file. -# output: Save the figure as file or not. -# copy: Return a copy instead of writing to adata. -# -# Returns -# ------- -# Nothing -# """ -# -# try: -# import seaborn as sns -# except: -# raise ImportError("Please run `pip install seaborn`") -# plt.subplots() -# -# sns.heatmap( -# pd.DataFrame(np.array(adata.obsm[use_het]).reshape(num_col, num_row)).T, -# vmin=vmin, -# vmax=vmax, -# ) -# plt.axis("equal") -# -# if output is not None: -# plt.savefig( -# output + "/" + name + "_heatmap.pdf", -# dpi=dpi, -# bbox_inches="tight", -# pad_inches=0, -# ) -# -# plt.show() diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index 85efe6ae..ead0b2bc 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -35,19 +35,25 @@ def lr_scatter( figsize: tuple | None = None, show_all: bool = False, ): - """General plotting of the LR features.""" - highlight = highlight_lrs is not None - if not highlight: + lr_df = data.uns["lr_summary"] + + if max_text > len(lr_df): + print(f"Note: max_text ({max_text}) exceeds available LRs ({len(lr_df)})") + + if highlight_lrs is None: show_text = show_text if n_top <= max_text else False else: + missing_lrs = [lr for lr in highlight_lrs if lr not in lr_df.index] + if missing_lrs: + raise ValueError( + f"The following highlight_lrs are not found in lr_summary index: {missing_lrs}") highlight_lrs = highlight_lrs[0:max_text] - lr_df = data.uns["lr_summary"] lrs = lr_df.index.values.astype(str)[0:n_top] lr_features = data.uns["lrfeatures"] lr_df = pd.concat([lr_df, lr_features], axis=1).loc[lrs, :] if feature not in lr_df.columns: - raise Exception(f"Inputted {feature}; must be one of {list(lr_df.columns)}") + raise ValueError(f"Inputted {feature}; must be one of {list(lr_df.columns)}") rot = 90 if feature != "n_spots_sig" else 70 @@ -72,39 +78,6 @@ def lr_scatter( pad=0, show_all=show_all, ) - # ranks = np.array(list(range(len(n_spots)))) - # - # if type(lr_text_fp)==type(None): - # lr_text_fp = {'weight': 'bold', 'size': 8} - # if type(axis_text_fp)==type(None): - # axis_text_fp = {'weight': 'bold', 'size': 12} - # - # if type(ax)==type(None): - # width = (7.5 / 50) * n_top if show_text and not highlight else 7.5 - # if width > 20: - # width = 20 - # fig, ax = plt.subplots(figsize=(width, 4)) - # - # # Plotting the points # - # ax.scatter(ranks, n_spots, alpha=alpha, c=color) - # - # if show_text: - # if highlight: - # ranks = ranks[[np.where(lrs==lr)[0][0] for lr in highlight_lrs]] - # ax.scatter(ranks, n_spots[ranks], alpha=alpha, c=highlight_color) - # - # for i in ranks: - # ax.text(i-.2, n_spots[i], lrs[i], rotation=rot, fontdict=lr_text_fp) - # - # ax.spines['top'].set_visible(False) - # ax.spines['right'].set_visible(False) - # ax.set_xlabel('LR Rank', axis_text_fp) - # ax.set_ylabel(feature, axis_text_fp) - # - # if show: - # plt.show() - # else: - # return ax def rank_scatter( @@ -161,7 +134,7 @@ def rank_scatter( y_max = y_max + y_max * pad ax.set_ylim(y_min, y_max) if point_sizes is not None: - # produce a legend with a cross section of sizes from the scatter + # produce a legend with a cross-section of sizes from the scatter handles, labels = scatter.legend_elements(prop="sizes", alpha=0.6, num=4) [handle.set_markeredgecolor("none") for handle in handles] starts = [label.find("{") for label in labels] @@ -257,7 +230,7 @@ def add_arrows( # in the base plotting function class. # Reason why is because scale_factor refers to scaling the # image to match the spot spatial coordinates, not the - # the spots to match the image coordinates!!! + # spots to match the image coordinates!!! L_bool = l_expr > min_expr R_bool = r_expr > min_expr diff --git a/stlearn/tl.py b/stlearn/tl.py index 3a3c0d9e..5aa3002f 100644 --- a/stlearn/tl.py +++ b/stlearn/tl.py @@ -1,8 +1,10 @@ +from .tools import cache from .tools import clustering from .tools.label import label from .tools.microenv import cci __all__ = [ + "cache", "clustering", "cci", "label", diff --git a/stlearn/tools/cache/__init__.py b/stlearn/tools/cache/__init__.py new file mode 100644 index 00000000..42d33af2 --- /dev/null +++ b/stlearn/tools/cache/__init__.py @@ -0,0 +1,6 @@ +from .anndata import write_subset_h5ad, merge_h5ad_into_adata + +__all__ = [ + "write_subset_h5ad", + "merge_h5ad_into_adata", +] diff --git a/stlearn/tools/cache/anndata.py b/stlearn/tools/cache/anndata.py new file mode 100644 index 00000000..8916aa74 --- /dev/null +++ b/stlearn/tools/cache/anndata.py @@ -0,0 +1,51 @@ +import anndata as ad +import numpy as np +import pandas as pd + + +def write_subset_h5ad(adata, filename, obsm_keys=None, uns_keys=None): + """Write only specific obsm and uns components to H5AD""" + + # Create a minimal AnnData object with the same structure + minimal_adata = ad.AnnData( + X=np.zeros((adata.n_obs, 1)), + obs=adata.obs.index.to_frame(name='cell_id'), + var=pd.DataFrame(index=['placeholder']) + ) + + if obsm_keys: + for key in obsm_keys: + if key in adata.obsm: + value = adata.obsm[key] + if isinstance(value, list): + value = np.array(value) + minimal_adata.obsm[key] = value + print(f"Added obsm['{key}'] with shape {value.shape}") + else: + print(f"Warning: obsm['{key}'] not found") + + if uns_keys: + for key in uns_keys: + if key in adata.uns: + minimal_adata.uns[key] = adata.uns[key] + print(f"Added uns['{key}']") + else: + print(f"Warning: uns['{key}'] not found") + + minimal_adata.write_h5ad(filename, compression='gzip', compression_opts=9) + print(f"Wrote subset to {filename}") + + +def merge_h5ad_into_adata(adata_main, h5ad_file): + adata_subset = ad.read_h5ad(h5ad_file) + print(f"Reading {h5ad_file}") + + for key, value in adata_subset.obsm.items(): + adata_main.obsm[key] = value + print(f"Added obsm['{key}'] with shape {value.shape}") + + for key, value in adata_subset.uns.items(): + adata_main.uns[key] = value + print(f"Added uns['{key}']") + + return adata_main diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 426512a3..53a473a0 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -319,17 +319,19 @@ def get_lr_features(adata, lr_expr, lrs, quantiles): # Saving the lrfeatures... cols = ["nonzero-median", "zero-prop", "median_rank", "prop_rank", "mean_rank"] - lr_features = pd.DataFrame(index=lrs, columns=cols) - lr_features.iloc[:, 0] = lr_median_means - lr_features.iloc[:, 1] = lr_prop_means - lr_features.iloc[:, 2] = np.array(median_ranks) - lr_features.iloc[:, 3] = np.array(prop_ranks) - lr_features.iloc[:, 4] = np.array(mean_ranks) + lr_features_data = { + cols[0]: np.array(lr_median_means, dtype=np.float64), + cols[1]: np.array(lr_prop_means, dtype=np.float64), + cols[2]: np.array(median_ranks, dtype=np.float64), + cols[3]: np.array(prop_ranks, dtype=np.float64), + cols[4]: np.array(mean_ranks, dtype=np.float64) + } + lr_features = pd.DataFrame(lr_features_data, index=lrs) lr_features = lr_features.iloc[np.argsort(mean_ranks), :] lr_cols = [f"L_{quant}" for quant in quantiles] + [ f"R_{quant}" for quant in quantiles ] - quant_df = pd.DataFrame(lr_quants, columns=lr_cols, index=lrs) + quant_df = pd.DataFrame(lr_quants, columns=lr_cols, index=lrs, dtype=np.float64) lr_features = pd.concat((lr_features, quant_df), axis=1) adata.uns["lrfeatures"] = lr_features From cae052e2ffd3836633474539e36df81ebb880a95 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 17:00:54 +1000 Subject: [PATCH 102/123] Fix formatting. --- stlearn/_datasets/_datasets.py | 22 +++++++++++----------- stlearn/plotting/cci_plot_helpers.py | 4 +++- stlearn/tl.py | 3 +-- stlearn/tools/cache/__init__.py | 2 +- stlearn/tools/cache/anndata.py | 6 +++--- stlearn/tools/microenv/cci/perm_utils.py | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/stlearn/_datasets/_datasets.py b/stlearn/_datasets/_datasets.py index a3a4d054..56f17fd5 100644 --- a/stlearn/_datasets/_datasets.py +++ b/stlearn/_datasets/_datasets.py @@ -8,9 +8,9 @@ # TODO - Add scanpy and covert this over. def visium_sge( - sample_id="V1_Breast_Cancer_Block_A_Section_1", - *, - include_hires_tiff: bool = False, + sample_id="V1_Breast_Cancer_Block_A_Section_1", + *, + include_hires_tiff: bool = False, ) -> AnnData: """Processed Visium Spatial Gene Expression data from 10x Genomics’ database. @@ -35,12 +35,12 @@ def visium_sge( def xenium_sge( - base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", - image_filename="he_image.ome.tif", - alignment_filename="he_imagealignment.csv", - zip_filename="outs.zip", - library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", - include_hires_tiff: bool = False, + base_url="https://cf.10xgenomics.com/samples/xenium/1.0.1", + image_filename="he_image.ome.tif", + alignment_filename="he_imagealignment.csv", + zip_filename="outs.zip", + library_id="Xenium_FFPE_Human_Breast_Cancer_Rep1", + include_hires_tiff: bool = False, ): """ Download and extract Xenium SGE data files. Unlike scanpy this current does not @@ -67,8 +67,8 @@ def xenium_sge( if not all_sge_files_exist: download_filenames.append(zip_filename) if include_hires_tiff and ( - not (library_dir / alignment_filename).exists() - or not (library_dir / image_filename).exists() + not (library_dir / alignment_filename).exists() + or not (library_dir / image_filename).exists() ): download_filenames += [alignment_filename, image_filename] diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/plotting/cci_plot_helpers.py index ead0b2bc..25b2a25e 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/plotting/cci_plot_helpers.py @@ -46,7 +46,9 @@ def lr_scatter( missing_lrs = [lr for lr in highlight_lrs if lr not in lr_df.index] if missing_lrs: raise ValueError( - f"The following highlight_lrs are not found in lr_summary index: {missing_lrs}") + "The following highlight_lrs are not found in lr_summary index: " + + ",".join(missing_lrs) + ) highlight_lrs = highlight_lrs[0:max_text] lrs = lr_df.index.values.astype(str)[0:n_top] diff --git a/stlearn/tl.py b/stlearn/tl.py index 5aa3002f..b7bb3cb7 100644 --- a/stlearn/tl.py +++ b/stlearn/tl.py @@ -1,5 +1,4 @@ -from .tools import cache -from .tools import clustering +from .tools import cache, clustering from .tools.label import label from .tools.microenv import cci diff --git a/stlearn/tools/cache/__init__.py b/stlearn/tools/cache/__init__.py index 42d33af2..9fe80973 100644 --- a/stlearn/tools/cache/__init__.py +++ b/stlearn/tools/cache/__init__.py @@ -1,4 +1,4 @@ -from .anndata import write_subset_h5ad, merge_h5ad_into_adata +from .anndata import merge_h5ad_into_adata, write_subset_h5ad __all__ = [ "write_subset_h5ad", diff --git a/stlearn/tools/cache/anndata.py b/stlearn/tools/cache/anndata.py index 8916aa74..a208feb8 100644 --- a/stlearn/tools/cache/anndata.py +++ b/stlearn/tools/cache/anndata.py @@ -9,8 +9,8 @@ def write_subset_h5ad(adata, filename, obsm_keys=None, uns_keys=None): # Create a minimal AnnData object with the same structure minimal_adata = ad.AnnData( X=np.zeros((adata.n_obs, 1)), - obs=adata.obs.index.to_frame(name='cell_id'), - var=pd.DataFrame(index=['placeholder']) + obs=adata.obs.index.to_frame(name="cell_id"), + var=pd.DataFrame(index=["placeholder"]), ) if obsm_keys: @@ -32,7 +32,7 @@ def write_subset_h5ad(adata, filename, obsm_keys=None, uns_keys=None): else: print(f"Warning: uns['{key}'] not found") - minimal_adata.write_h5ad(filename, compression='gzip', compression_opts=9) + minimal_adata.write_h5ad(filename, compression="gzip", compression_opts=9) print(f"Wrote subset to {filename}") diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tools/microenv/cci/perm_utils.py index 53a473a0..6bc84d4d 100644 --- a/stlearn/tools/microenv/cci/perm_utils.py +++ b/stlearn/tools/microenv/cci/perm_utils.py @@ -324,7 +324,7 @@ def get_lr_features(adata, lr_expr, lrs, quantiles): cols[1]: np.array(lr_prop_means, dtype=np.float64), cols[2]: np.array(median_ranks, dtype=np.float64), cols[3]: np.array(prop_ranks, dtype=np.float64), - cols[4]: np.array(mean_ranks, dtype=np.float64) + cols[4]: np.array(mean_ranks, dtype=np.float64), } lr_features = pd.DataFrame(lr_features_data, index=lrs) lr_features = lr_features.iloc[np.argsort(mean_ranks), :] From e27cf0a84772e66c765d2398e75d0223c632595d Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 18:45:11 +1000 Subject: [PATCH 103/123] Fixup documentation. --- .gitignore | 3 +- AUTHORS.rst | 7 +- docs/Makefile | 8 +- docs/_temp/example_cci.py | 180 --------------------- docs/_templates/autosummary/base.rst | 4 - docs/_templates/autosummary/class.rst | 5 - docs/api.rst | 211 ------------------------ docs/conf.py | 221 +++----------------------- docs/images/logo.png | Bin 0 -> 484189 bytes docs/index.rst | 51 +++--- docs/installation.rst | 2 +- docs/interactive.rst | 10 +- docs/list_tutorial.txt | 11 -- docs/make.bat | 21 ++- docs/release_notes/0.3.2.rst | 2 +- docs/release_notes/0.4.6.rst | 2 +- docs/release_notes/index.rst | 7 +- docs/requirements.txt | 15 -- docs/tutorials.rst | 22 +-- pyproject.toml | 4 + stlearn/wrapper/read.py | 4 +- 21 files changed, 84 insertions(+), 706 deletions(-) delete mode 100644 docs/_temp/example_cci.py delete mode 100644 docs/_templates/autosummary/base.rst delete mode 100644 docs/_templates/autosummary/class.rst delete mode 100644 docs/api.rst create mode 100644 docs/images/logo.png delete mode 100644 docs/list_tutorial.txt delete mode 100644 docs/requirements.txt diff --git a/.gitignore b/.gitignore index b5495d15..6fd78a11 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ # Distribution / packaging .Python build/ +_build/ data/samples develop-eggs/ dist/ @@ -59,7 +60,7 @@ cover/ .idea/ # Sphinx documentation -docs/_build +docs.bk/_build # Distribution/package/temporary files data/ diff --git a/AUTHORS.rst b/AUTHORS.rst index d30eaa6e..a024f3f5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -5,9 +5,12 @@ Credits Development Lead ---------------- -* Genomics and Machine Learning lab +* Genomics and Machine Learning Lab Contributors ------------ -None yet. Why not be the first? +* Brad Balderson +* Andrew Newman +* Duy Pham +* Xiao Tan diff --git a/docs/Makefile b/docs/Makefile index 96688bf3..d4bb2cbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,10 +1,10 @@ # Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = python -msphinx -SPHINXPROJ = stlearn +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build diff --git a/docs/_temp/example_cci.py b/docs/_temp/example_cci.py deleted file mode 100644 index 15fe9a84..00000000 --- a/docs/_temp/example_cci.py +++ /dev/null @@ -1,180 +0,0 @@ -# """ Example code for running CCI analysis using new interface/approach. - -# Tested: * Within-spot mode -# * Between-spot mode - -# TODO tests: * Above with cell heterogeneity information -# """ - -################################################################################ -# Environment setup # -################################################################################ -import stlearn as st -import matplotlib.pyplot as plt - -################################################################################ -# Load your data # -################################################################################ -# TODO - load as an AnnData & perform usual pre-processing. -data = None # replace with your code - -# """ # Adding cell heterogeneity information if you have it. -# st.add.labels(data, 'tutorials/label_transfer_bc.csv', sep='\t') -# st.pl.cluster_plot(data, use_label="predictions") -# """ - -################################################################################ -# Performing cci_rank analysis # -################################################################################ -# Load the NATMI literature-curated database of LR pairs, data formatted # -lrs = st.tl.cci.load_lrs(["connectomeDB2020_lit"]) - -st.tl.cci.run( - data, - lrs, - use_label=None, # Need to add the label transfer results to object first, above code puts into 'label_transfer' - use_het="cell_het", # Slot for cell het. results in adata.obsm, only if use_label specified - min_spots=6, # Filter out any LR pairs with no scores for less than 6 spots - distance=None, # distance=0 for within-spot mode, None to auto-select distance to nearest neighbourhood. - n_pairs=1000, # Number of random pairs to generate - adj_method="fdr_bh", # MHT correction method - min_expr=0, # min expression for gene to be considered expressed. - pval_adj_cutoff=0.05, -) -# """ -# Example output: - -# Calculating neighbours... -# 0 spots with no neighbours, 6 median spot neighbours. -# Spot neighbour indices stored in adata.uns['spot_neighbours'] -# Altogether 1393 valid L-R pairs -# Generating random gene pairs... -# Generating the background... -# Calculating p-values for each LR pair in each spot...: 100%|██████████ [ time left: 00:00 ] - -# Storing results: - -# lr_scores stored in adata.obsm['lr_scores']. -# p_vals stored in adata.obsm['p_vals']. -# p_adjs stored in adata.obsm['p_adjs']. -# -log10(p_adjs) stored in adata.obsm['-log10(p_adjs)']. -# lr_sig_scores stored in adata.obsm['lr_sig_scores']. - -# Per-spot results in adata.obsm have columns in same order as rows in adata.uns['lr_summary']. -# Summary of LR results in adata.uns['lr_summary']. -# """ - -################################################################################ -# Visualising results # -################################################################################ -# Plotting the -log10(p_adjs) for the lr with the highest number of spots. -# Set use_lr to any listed in data.uns['lr_summary'] to visualise alternate lrs. -st.pl.lr_result_plot( - data, - use_lr=None, # Which LR to use, if None then uses top resuls from data.uns['lr_results'] - use_result="-log10(p_adjs)", # Which result to visualise, must be one of - # p_vals, p_adjs, -log10(p_adjs), lr_sig_scores -) -plt.show() - -################################################################################ -# Extra diagnostic plots for results # -################################################################################ -# TODO: -# Below needs to be updated with new way of storing results. - -# Looking at which LR pairs were significant across the most spots # -print(data.uns["lr_summary"]) # Rank-ordered by pairs with most significant spots - -# Now looking at the LR pair with the highest number of sig. spots # -best_lr = data.uns["lr_summary"].index.values[0] - -# Binary LR coexpression plot for all spots # -st.pl.lr_plot( - data, - best_lr, - inner_size_prop=0.1, - outer_mode="binary", - pt_scale=10, - use_label=None, - show_image=True, - sig_spots=False, -) -plt.show() - -# Significance scores for all spots # -st.pl.lr_plot( - data, - best_lr, - inner_size_prop=1, - outer_mode=None, - pt_scale=20, - use_label="lr_scores", - show_image=True, - sig_spots=False, -) -plt.show() - -# Binary LR coexpression plot for significant spots # -st.pl.lr_plot( - data, - best_lr, - outter_size_prop=1, - outer_mode="binary", - pt_scale=20, - use_label=None, - show_image=True, - sig_spots=True, -) -plt.show() - -# Continuous LR coexpression for signficant spots # -st.pl.lr_plot( - data, - best_lr, - inner_size_prop=0.1, - middle_size_prop=0.2, - outter_size_prop=0.4, - outer_mode="continuous", - pt_scale=150, - use_label=None, - show_image=True, - sig_spots=True, -) -plt.show() - -# Continous LR coexpression for significant spots with tissue_type information # -st.pl.lr_plot( - data, - best_lr, - inner_size_prop=0.08, - middle_size_prop=0.3, - outter_size_prop=0.5, - outer_mode="continuous", - pt_scale=150, - use_label="tissue_type", - show_image=True, - sig_spots=True, -) -plt.show() - - -# # Old version of visualisation # -# """ -# # LR enrichment scores -# data.obsm[f'{best_lr}_scores'] = data.uns['per_lr_results'][best_lr].loc[:, -# 'lr_scores'].values -# # -log10(p_adj) of LR enrichment scores -# data.obsm[f'{best_lr}_log-p_adj'] = data.uns['per_lr_results'][best_lr].loc[:, -# '-log10(p_adj)'].values -# # Significant LR enrichment scores -# data.obsm[f'{best_lr}_sig-scores'] = data.uns['per_lr_results'][best_lr].loc[:, -# 'lr_sig_scores'].values - -# # Visualising these results # -# st.pl.het_plot(data, use_het=f'{best_lr}_scores', cell_alpha=0.7) -# plt.show() - -# st.pl.het_plot(data, use_het=f'{best_lr}_sig-scores', cell_alpha=0.7) -# plt.show() -# """ diff --git a/docs/_templates/autosummary/base.rst b/docs/_templates/autosummary/base.rst deleted file mode 100644 index 7a780868..00000000 --- a/docs/_templates/autosummary/base.rst +++ /dev/null @@ -1,4 +0,0 @@ - -{% extends "!autosummary/base.rst" %} - -.. http://www.sphinx-doc.org/en/stable/ext/autosummary.html#customizing-templates diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst deleted file mode 100644 index 42c37f16..00000000 --- a/docs/_templates/autosummary/class.rst +++ /dev/null @@ -1,5 +0,0 @@ -{{ fullname | escape | underline}} - -.. currentmodule:: {{ module }} - -.. add toctree option to make autodoc generate the pages diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 0251f8b2..00000000 --- a/docs/api.rst +++ /dev/null @@ -1,211 +0,0 @@ -.. module:: stlearn -.. automodule:: stlearn - :noindex: - -API -====================================== - -Import stLearn as:: - - import stlearn as st - - -Wrapper functions: `wrapper` ------------------------------- - -.. module:: stlearn.wrapper -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - Read10X - ReadOldST - ReadSlideSeq - ReadMERFISH - ReadSeqFish - convert_scanpy - create_stlearn - - -Add: `add` -------------------- - -.. module:: stlearn.add -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - add.image - add.positions - add.parsing - add.lr - add.labels - add.annotation - add.add_loupe_clusters - add.add_mask - add.apply_mask - add.add_deconvolution - - -Preprocessing: `pp` -------------------- - -.. module:: stlearn.pp -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - pp.filter_genes - pp.log1p - pp.normalize_total - pp.scale - pp.neighbors - pp.tiling - pp.extract_feature - - - -Embedding: `em` -------------------- - -.. module:: stlearn.em -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - em.run_pca - em.run_umap - em.run_ica - em.run_fa - em.run_diffmap - - -Spatial: `spatial` -------------------- - -.. module:: stlearn.spatial.clustering -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - spatial.clustering.localization - -.. module:: stlearn.spatial.trajectory -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - spatial.trajectory.pseudotime - spatial.trajectory.pseudotimespace_global - spatial.trajectory.pseudotimespace_local - spatial.trajectory.compare_transitions - spatial.trajectory.detect_transition_markers_clades - spatial.trajectory.detect_transition_markers_branches - spatial.trajectory.set_root - -.. module:: stlearn.spatial.morphology -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - spatial.morphology.adjust - -.. module:: stlearn.spatial.SME -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - spatial.SME.SME_impute0 - spatial.SME.pseudo_spot - spatial.SME.SME_normalize - -Tools: `tl` -------------------- - -.. module:: stlearn.tl.clustering -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - tl.clustering.kmeans - tl.clustering.louvain - - -.. module:: stlearn.tl.cci -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - tl.cci.load_lrs - tl.cci.grid - tl.cci.run - tl.cci.adj_pvals - tl.cci.run_lr_go - tl.cci.run_cci - -Plot: `pl` -------------------- - -.. module:: stlearn.pl -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - pl.QC_plot - pl.gene_plot - pl.gene_plot_interactive - pl.cluster_plot - pl.cluster_plot_interactive - pl.subcluster_plot - pl.subcluster_plot - pl.non_spatial_plot - pl.deconvolution_plot - pl.plot_mask - pl.lr_summary - pl.lr_diagnostics - pl.lr_n_spots - pl.lr_go - pl.lr_result_plot - pl.lr_plot - pl.cci_check - pl.ccinet_plot - pl.lr_chord_plot - pl.lr_cci_map - pl.cci_map - pl.lr_plot_interactive - pl.spatialcci_plot_interactive - -.. module:: stlearn.pl.trajectory -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - pl.trajectory.pseudotime_plot - pl.trajectory.local_plot - pl.trajectory.tree_plot - pl.trajectory.transition_markers_plot - pl.trajectory.DE_transition_plot - -Tools: `datasets` -------------------- - -.. module:: stlearn.datasets -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: . - - datasets.visium_sge() - datasets.xenium_sge() diff --git a/docs/conf.py b/docs/conf.py index d83827a8..4d08b253 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,216 +1,39 @@ -#!/usr/bin/env python -# -# stlearn documentation build configuration file, created by -# sphinx-quickstart on Fri Jun 9 13:47:02 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another -# directory, add these directories to sys.path here. If the directory is -# relative to the documentation root, use os.path.abspath to make it -# absolute, like shown here. -# import os import sys - sys.path.insert(0, os.path.abspath("..")) import stlearn -# Setup files -import os - -if not os.path.isdir("./_static"): - url = "https://www.dropbox.com/s/3bb749fk68h0lwh/download.zip?dl=1" - os.system("wget " + url) - os.system("mv download.zip?dl=1 download.zip") - os.system("unzip download.zip") - -# -- General configuration --------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. +# Configuration file for the Sphinx documentation builder. # -# needs_sphinx = '1.0' +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.viewcode", - "recommonmark", - "sphinx.ext.napoleon", - "sphinx.ext.autosummary", - "nbsphinx", - "jupyter_sphinx", - "sphinx_gallery.load_style", -] - -# Generate the API documentation when building -autosummary_generate = True -autodoc_member_order = "bysource" -# autodoc_default_flags = ['members'] -napoleon_google_docstring = False -napoleon_numpy_docstring = True -napoleon_include_init_with_doc = False -napoleon_use_rtype = True # having a separate entry generally helps readability -napoleon_use_param = True -napoleon_custom_sections = [("Params", "Parameters")] -todo_include_todos = False -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# General information about the project. -project = "stLearn" -copyright = "2022-2025, Genomics and Machine Learning lab" -author = "Genomics and Machine Learning lab" -# The version info for the project you're documenting, acts as replacement -# for |version| and |release|, also used in various other places throughout -# the built documents. -# -# The short X.Y version. -version = stlearn.__version__ -# The full version, including alpha/beta/rc tags. +project = 'stLearn' +copyright = '2022-2025, Genomics and Machine Learning Lab' +author = 'Genomics and Machine Learning Lab' release = stlearn.__version__ +html_logo = "images/logo.png" -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# -- Options for HTML output ------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# - - -def setup(app): - app.add_css_file("css/theme_override.css") - - -html_theme = "sphinx_rtd_theme" -html_css_files = [ - "css/custom.css", -] -# Theme options are theme-specific and customize the look and feel of a -# theme further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - - -# -- Options for HTMLHelp output --------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = "stlearndoc" - - -# -- Options for LaTeX output ------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass -# [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "stlearn.tex", - "stLearn Documentation", - "Genomics and Machine Learning lab", - "manual", - ), +extensions = [ + 'nbsphinx', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] -# -- Options for manual page output ------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "stlearn", "stLearn Documentation", [author], 1)] - - -# -- Options for Texinfo output ---------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "stlearn", - "stLearn Documentation", - author, - "stlearn", - "One line description of project.", - "Miscellaneous", - ), -] +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +html_theme = 'furo' +html_static_path = ['_static'] -nbsphinx_thumbnails = { - "tutorials/stSME_clustering": "_static/img/thumbnail/sme.png", - "tutorials/stSME_comparison": "_static/img/thumbnail/com.png", - "tutorials/Pseudo-time-space-tutorial": "_static/img/thumbnail/psts.png", - "tutorials/stLearn-CCI": "_static/img/thumbnail/cci.png", - "tutorials/Read_MERFISH": "_static/img/thumbnail/mer.png", - "tutorials/Read_seqfish": "_static/img/thumbnail/seq.png", - "tutorials/Working-with-Old-Spatial-Transcriptomics-data": "_static/img/thumbnail/legacy.png", - "tutorials/Read_slideseq": "_static/img/thumbnail/slide.png", - "tutorials/ST_deconvolution_visualization": "_static/img/thumbnail/decon.png", - "tutorials/Interactive_plot": "_static/img/thumbnail/bokeh.gif", - "tutorials/Working_with_scanpy": "_static/img/thumbnail/scanpy.png", - "tutorials/Core_plots": "_static/img/thumbnail/core_plots.png", - "tutorials/Read_any_data": "_static/img/thumbnail/any.png", - "tutorials/Integration_multiple_datasets": "_static/img/thumbnail/integrate.png", - "tutorials/Xenium_PSTS": "_static/img/thumbnail/xenium_psts.png", - "tutorials/Xenium_CCI": "_static/img/thumbnail/xenium_cci.png", -} +# Configure nbsphinx +nbsphinx_execute = 'never' # Don't re-execute notebooks +nbsphinx_allow_errors = True # Allow notebooks with errors \ No newline at end of file diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23808d9128e3118d8fb6daf83aabb5e670d77fa0 GIT binary patch literal 484189 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 h$}K{pHZ(k#ppM&xDr^c=|mFsq<}m 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 z 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�uMvguLtF()>sO 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

0EG&&P?HWdbg6t`=Z>M9dMqjq}F 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%0D3#%a=uhcZmE{imt2+zx_JZx6%3ZudSZ>p3gUe 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 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 2d3df2e0..b240546e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,49 +1,45 @@ -stLearn - a downstream analysis toolkit for Spatial Transcriptomics data +stLearn - Spatial Transcriptomics Toolkit ============================================================================ |PyPI| |PyPIDownloads| |Docs| .. |PyPI| image:: https://img.shields.io/pypi/v/stlearn?logo=PyPI :target: https://pypi.org/project/stlearn + .. |PyPIDownloads| image:: https://pepy.tech/badge/stlearn + .. |Docs| image:: https://readthedocs.org/projects/stlearn/badge/?version=latest :target: https://stlearn.readthedocs.io -.. image:: https://i.imgur.com/yfXlCYO.png +.. image:: images/logo.png :width: 300px :align: left -stLearn is designed to comprehensively analyse Spatial Transcriptomics (ST) data to investigate complex biological processes within an undissociated tissue. ST is emerging as the “next generation” of single-cell RNA sequencing because it adds spatial and morphological context to the transcriptional profile of cells in an intact tissue section. However, existing ST analysis methods typically use the captured spatial and/or morphological data as a visualisation tool rather than as informative features for model development. We have developed an analysis method that exploits all three data types: Spatial distance, tissue Morphology, and gene Expression measurements (SME) from ST data. This combinatorial approach allows us to more accurately model underlying tissue biology, and allows researchers to address key questions in three major research areas: cell type identification, cell trajectory reconstruction, and the study of cell-cell interactions within an undissociated tissue sample. - -We also published stLearn-interactive which is a python-based interactive website for working with all the functions from stLearn and upgrade with some bokeh-based plots. - -To run the stlearn interaction webapp in your local, run: -:: - - stlearn launch - - -New features -********************** - -In the new release, we provide the interactive plots: - -.. image:: https://media.giphy.com/media/hUHAZcbVMm5pdUKMq4/giphy.gif - :width: 600px - - - -Latest additions +stLearn is designed to comprehensively analyse Spatial Transcriptomics (ST) +data to investigate complex biological processes within an undissociated +tissue. ST is emerging as the “next generation” of single-cell RNA sequencing +because it adds spatial and morphological context to the transcriptional +profile of cells in an intact tissue section. However, existing ST analysis +methods typically use the captured spatial and/or morphological data as a +visualisation tool rather than as informative features for model development. +We have developed an analysis method that exploits all three data types: +Spatial distance, tissue Morphology, and gene Expression measurements (SME) +from ST data. This combinatorial approach allows us to more accurately model +underlying tissue biology, and allows researchers to address key questions in +three major research areas: cell type identification, cell trajectory +reconstruction, and the study of cell-cell interactions within an +undissociated tissue sample. + + +Latest Additions ---------------- .. include:: release_notes/1.1.0.rst -.. include:: release_notes/0.4.11.rst - .. include:: release_notes/0.4.6.rst .. include:: release_notes/0.3.2.rst @@ -57,9 +53,10 @@ Latest additions installation - interactive tutorials - api + interactive release_notes/index authors references + +.. api diff --git a/docs/installation.rst b/docs/installation.rst index b27a8f3a..4e18ad51 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -6,7 +6,7 @@ Installation Install by Anaconda ---------------- +------------------- **Step 1:** diff --git a/docs/interactive.rst b/docs/interactive.rst index 459d213d..54e85f49 100644 --- a/docs/interactive.rst +++ b/docs/interactive.rst @@ -1,12 +1,12 @@ .. highlight:: shell -============ -Interactive web application -============ +=================== +Interactive Web App +=================== Launch stlearn in your local ---------------- +---------------------------- Run the launch command in the terminal: :: @@ -14,5 +14,3 @@ Run the launch command in the terminal: stlearn launch After that, you can access `https://:5000` in your web browser. - -Check the detail tutorial in this pdf file: `Link `_ diff --git a/docs/list_tutorial.txt b/docs/list_tutorial.txt deleted file mode 100644 index 53badb09..00000000 --- a/docs/list_tutorial.txt +++ /dev/null @@ -1,11 +0,0 @@ -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Pseudo-time-space-tutorial.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Read_MERFISH.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Read_seqfish.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Read_slideseq.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/ST_deconvolution_visualization.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Working-with-Old-Spatial-Transcriptomics-data.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/stLearn-CCI.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/stSME_clustering.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/stSME_comparison.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Xenium_PSTS.ipynb -https://raw.githubusercontent.com/BiomedicalMachineLearning/stLearn/master/tutorials/Xenium_CCI.ipynb diff --git a/docs/make.bat b/docs/make.bat index 2afd47f0..954237b9 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -5,32 +5,31 @@ pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python -msphinx + set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build -set SPHINXPROJ=stlearn - -if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. - echo.The Sphinx module was not found. Make sure you have Sphinx installed, - echo.then set the SPHINXBUILD environment variable to point to the full - echo.path of the 'sphinx-build' executable. Alternatively you may add the - echo.Sphinx directory to PATH. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://www.sphinx-doc.org/ exit /b 1 ) -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd diff --git a/docs/release_notes/0.3.2.rst b/docs/release_notes/0.3.2.rst index 9b141ff5..d8e459c7 100644 --- a/docs/release_notes/0.3.2.rst +++ b/docs/release_notes/0.3.2.rst @@ -1,7 +1,7 @@ 0.3.2 `2021-03-29` ~~~~~~~~~~~~~~~~~~~~~~~~~ -.. rubric:: Feature +.. rubric:: Features - Add interactive plotting functions: :func:`~stlearn.pl.gene_plot_interactive`, :func:`~stlearn.pl.cluster_plot_interactive`, :func:`~stlearn.pl.het_plot_interactive` - Add basic unittest (will add more in the future). diff --git a/docs/release_notes/0.4.6.rst b/docs/release_notes/0.4.6.rst index b2f08dd6..b8ee0324 100644 --- a/docs/release_notes/0.4.6.rst +++ b/docs/release_notes/0.4.6.rst @@ -1,7 +1,7 @@ 0.4.0 `2022-02-03` ~~~~~~~~~~~~~~~~~~~~~~~~~ -.. rubric:: Feature +.. rubric:: Features - Upgrade stSME, PSTS and CCI analysis methods. diff --git a/docs/release_notes/index.rst b/docs/release_notes/index.rst index 48c9c3be..6194c62c 100644 --- a/docs/release_notes/index.rst +++ b/docs/release_notes/index.rst @@ -1,10 +1,7 @@ -Release notes +Release Notes =================================================== -Version 0.4.9 ---------------------------- - -.. include:: 0.4.10.rst +.. include:: 1.1.0.rst .. include:: 0.4.6.rst diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index a2c20d28..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ --r ../requirements.txt -ipyvolume -ipywebrtc -ipywidgets -jupyter_sphinx -nbclean -nbformat -nbsphinx -pygments -recommonmark -sphinx -sphinx-autodoc-typehints -sphinx_gallery==0.10.1 -sphinx_rtd_theme -typing_extensions diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 0c0ecf16..3c9a62cb 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -4,33 +4,15 @@ Tutorials .. nbgallery:: :caption: Main features: - tutorials/stSME_clustering - tutorials/stSME_comparison - tutorials/Pseudo-time-space-tutorial - tutorials/stLearn-CCI - tutorials/Xenium_PSTS - tutorials/Xenium_CCI + tutorials/cell_cell_interaction .. nbgallery:: :caption: Visualisation and additional functionalities: - tutorials/Interactive_plot - tutorials/Core_plots - tutorials/ST_deconvolution_visualization - tutorials/Integration_multiple_datasets - + tutorials/core_plots .. nbgallery:: :caption: Supporting platforms: - - tutorials/Read_MERFISH - tutorials/Read_seqfish - tutorials/Working-with-Old-Spatial-Transcriptomics-data - tutorials/Read_slideseq - .. nbgallery:: :caption: Integration with other spatial tools: - - tutorials/Read_any_data - tutorials/Working_with_scanpy diff --git a/pyproject.toml b/pyproject.toml index 74d75b7c..0e4c9865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ dev = [ "mypy>=1.16", "pytest>=7.0", "tox>=4.0", + "sphinx>=4.0", + "furo==2024.8.6", + "myst-parser>=0.18", + "nbsphinx>=0.9.0", ] test = [ "pytest", diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 6e982e67..8c568a56 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -33,9 +33,9 @@ def Read10X( In addition to reading regular 10x output, this looks for the `spatial` folder and loads images, coordinates and scale factors. Based on the - `Space Ranger output docs`_. + `Space Ranger output docs.bk`_. - _Space Ranger output docs: + _Space Ranger output docs.bk: https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/output/overview Parameters From 4e9453b4bf375ce1664860629229a094b8d66c40 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 19:17:17 +1000 Subject: [PATCH 104/123] Fixup documentation. --- .gitignore | 4 +- HISTORY.rst | 2 +- docs/api.rst | 210 +++++++++++++++++++++++++++++++++++ docs/conf.py | 13 ++- docs/index.rst | 3 +- docs/release_notes/1.1.0.rst | 2 +- pyproject.toml | 2 + 7 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 docs/api.rst diff --git a/.gitignore b/.gitignore index 6fd78a11..fcd9e498 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ __pycache__/ # Distribution / packaging .Python build/ -_build/ +docs/api/ +docs/_build/ +docs/generated/ data/samples develop-eggs/ dist/ diff --git a/HISTORY.rst b/HISTORY.rst index a6c64446..8c608fe3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -15,7 +15,7 @@ API and Bug Fixes: * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. -* Removed datasets.example_bcba - Replaces with wrapper for scanpy.visium_sge. +* Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. 0.4.11 (2022-11-25) ------------------ diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..104ad279 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,210 @@ +.. module:: stlearn +.. automodule:: stlearn + :noindex: + +API +====================================== + +Import stLearn as:: + + import stlearn as st + + +Wrapper functions: `wrapper` +------------------------------ + +.. module:: stlearn.wrapper +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + Read10X + ReadOldST + ReadSlideSeq + ReadMERFISH + ReadSeqFish + convert_scanpy + create_stlearn + + +Add: `add` +------------------- + +.. module:: stlearn.add +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + add.image + add.positions + add.parsing + add.lr + add.labels + add.annotation + add.add_loupe_clusters + add.add_mask + add.apply_mask + add.add_deconvolution + + +Preprocessing: `pp` +------------------- + +.. module:: stlearn.pp +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + pp.filter_genes + pp.log1p + pp.normalize_total + pp.scale + pp.neighbors + pp.tiling + pp.extract_feature + + + +Embedding: `em` +------------------- + +.. module:: stlearn.em +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + em.run_pca + em.run_umap + em.run_ica + em.run_fa + em.run_diffmap + + +Spatial: `spatial` +------------------- + +.. module:: stlearn.spatial.clustering +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + spatial.clustering.localization + +.. module:: stlearn.spatial.trajectory +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + spatial.trajectory.pseudotime + spatial.trajectory.pseudotimespace_global + spatial.trajectory.pseudotimespace_local + spatial.trajectory.compare_transitions + spatial.trajectory.detect_transition_markers_clades + spatial.trajectory.detect_transition_markers_branches + spatial.trajectory.set_root + +.. module:: stlearn.spatial.morphology +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + spatial.morphology.adjust + +.. module:: stlearn.spatial.SME +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + spatial.SME.SME_impute0 + spatial.SME.pseudo_spot + spatial.SME.SME_normalize + +Tools: `tl` +------------------- + +.. module:: stlearn.tl.clustering +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + tl.clustering.kmeans + tl.clustering.louvain + +.. module:: stlearn.tl.cci +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + tl.cci.load_lrs + tl.cci.grid + tl.cci.run + tl.cci.adj_pvals + tl.cci.run_lr_go + tl.cci.run_cci + +Plot: `pl` +------------------- + +.. module:: stlearn.pl +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + pl.QC_plot + pl.gene_plot + pl.gene_plot_interactive + pl.cluster_plot + pl.cluster_plot_interactive + pl.subcluster_plot + pl.subcluster_plot + pl.non_spatial_plot + pl.deconvolution_plot + pl.plot_mask + pl.lr_summary + pl.lr_diagnostics + pl.lr_n_spots + pl.lr_go + pl.lr_result_plot + pl.lr_plot + pl.cci_check + pl.ccinet_plot + pl.lr_chord_plot + pl.lr_cci_map + pl.cci_map + pl.lr_plot_interactive + pl.spatialcci_plot_interactive + +.. module:: stlearn.pl.trajectory +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + pl.trajectory.pseudotime_plot + pl.trajectory.local_plot + pl.trajectory.tree_plot + pl.trajectory.transition_markers_plot + pl.trajectory.DE_transition_plot + +Datasets: `datasets` +------------------- + +.. module:: stlearn.datasets +.. currentmodule:: stlearn + +.. autosummary:: + :toctree: api/ + + datasets.visium_sge + datasets.xenium_sge diff --git a/docs/conf.py b/docs/conf.py index 4d08b253..fe622465 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,6 +22,10 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', 'nbsphinx', ] @@ -36,4 +40,11 @@ # Configure nbsphinx nbsphinx_execute = 'never' # Don't re-execute notebooks -nbsphinx_allow_errors = True # Allow notebooks with errors \ No newline at end of file +nbsphinx_allow_errors = True # Allow notebooks with errors + +# Autosummary +autosummary_generate = True +autosummary_imported_members = True + +# Output directory for autosummary +autosummary_generate_overwrite = True \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index b240546e..c1fb1d1f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,10 +53,9 @@ Latest Additions installation + api tutorials interactive release_notes/index authors references - -.. api diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst index fc7d97dc..9d040781 100644 --- a/docs/release_notes/1.1.0.rst +++ b/docs/release_notes/1.1.0.rst @@ -15,4 +15,4 @@ * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. -* Removed datasets.example_bcba - Replaces with wrapper for scanpy.visium_sge. \ No newline at end of file +* Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0e4c9865..88a07d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dev = [ "furo==2024.8.6", "myst-parser>=0.18", "nbsphinx>=0.9.0", + "sphinx-autodoc-typehints>=1.24.0", + "sphinx-autosummary-accessors>=2023.4.0", ] test = [ "pytest", From 9ad8672b2219622ba7395574f63a328debf906d9 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 19:43:58 +1000 Subject: [PATCH 105/123] Custom. --- docs/_static/css/custom.css | 5 +++++ docs/conf.py | 3 +++ docs/index.rst | 2 +- docs/installation.rst | 18 ------------------ 4 files changed, 9 insertions(+), 19 deletions(-) create mode 100644 docs/_static/css/custom.css diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 00000000..6beb551f --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,5 @@ +/* Custom styling for stLearn documentation */ + +p img { + vertical-align: bottom +} diff --git a/docs/conf.py b/docs/conf.py index fe622465..46368fe7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,9 @@ html_theme = 'furo' html_static_path = ['_static'] +html_css_files = [ + 'css/custom.css', +] # Configure nbsphinx nbsphinx_execute = 'never' # Don't re-execute notebooks diff --git a/docs/index.rst b/docs/index.rst index c1fb1d1f..142e73d8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ -stLearn - Spatial Transcriptomics Toolkit +stLearn - A Spatial Transcriptomics Toolkit ============================================================================ |PyPI| |PyPIDownloads| |Docs| diff --git a/docs/installation.rst b/docs/installation.rst index 4e18ad51..f46d62f8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -5,24 +5,6 @@ Installation ============ -Install by Anaconda -------------------- - -**Step 1:** - -Prepare conda environment for stLearn -:: - - conda create -n stlearn python=3.10 --y - conda activate stlearn - -**Step 2:** - -You can directly install stlearn in the anaconda by: -:: - - conda install -c conda-forge stlearn - Install by PyPi --------------- From 48f84b9679d80f1a42a177ac93619bfa35ea9b82 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Sun, 6 Jul 2025 19:51:12 +1000 Subject: [PATCH 106/123] All tutorials. --- docs/tutorials.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 3c9a62cb..83889f22 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -5,14 +5,23 @@ Tutorials :caption: Main features: tutorials/cell_cell_interaction + tutorials/cell_cell_interaction_xenium + tutorials/pseudotime_space + tutorials/pseudotime_space_xenium + tutorials/stsme_clustering + tutorials/stsme_comparison + .. nbgallery:: :caption: Visualisation and additional functionalities: tutorials/core_plots + tutorials/integrate_multiple_datasets .. nbgallery:: :caption: Supporting platforms: .. nbgallery:: :caption: Integration with other spatial tools: + + tutorials/working_with_scanpy From d7e8dc8221d77a879bc10fe8db5cd50c902e578c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:44:06 +1000 Subject: [PATCH 107/123] Add downloading the tutorial from google drive. --- docs/conf.py | 31 ++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 46368fe7..ef382ebe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,33 @@ import os import sys +import re +import requests + sys.path.insert(0, os.path.abspath("..")) import stlearn +def download_gdrive_file(file_id, filename): + session = requests.Session() + url = f"https://docs.google.com/uc?export=download&id={file_id}" + response = session.get(url) + + form_action_match = re.search(r'action="([^"]+)"', response.text) + if not form_action_match: + raise Exception("Could not find form action URL") + download_url = form_action_match.group(1) + + params = {} + hidden_inputs = re.findall( + r'=1.16", "pytest>=7.0", "tox>=4.0", + "ghp-import>=2.1.0", "sphinx>=4.0", "furo==2024.8.6", "myst-parser>=0.18", From 8d940e8ee3664ec54627ca6f576c8774937a844f Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:46:10 +1000 Subject: [PATCH 108/123] Upgrade. --- .readthedocs.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 3ee3a06a..5552fdfd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,4 +1,23 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need build: - image: latest -python: - version: 3.10 + os: ubuntu-24.04 + tools: + python: "3.10" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt + \ No newline at end of file From f046d88542a52f8a4a46a2c4517632b096453d5d Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:50:50 +1000 Subject: [PATCH 109/123] Fix import issues. --- docs/conf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index ef382ebe..31b727d3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,4 +79,11 @@ def setup(app): autosummary_imported_members = True # Output directory for autosummary -autosummary_generate_overwrite = True \ No newline at end of file +autosummary_generate_overwrite = True + +autodoc_mock_imports = [ + 'numpy', 'pandas', 'scipy', 'sklearn', 'scanpy', 'anndata', + 'matplotlib', 'seaborn', 'plotly', 'bokeh', 'cv2', 'PIL', + 'rpy2', 'louvain', 'numba', 'leidenalg', + # Add any other packages causing import issues +] \ No newline at end of file From 203c9e567419ece4e1dff17a330f959c58cc99de Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:53:55 +1000 Subject: [PATCH 110/123] Fix import issues. --- docs/conf.py | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 31b727d3..984a5292 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,9 +3,6 @@ import re import requests -sys.path.insert(0, os.path.abspath("..")) -import stlearn - def download_gdrive_file(file_id, filename): session = requests.Session() url = f"https://docs.google.com/uc?export=download&id={file_id}" @@ -39,7 +36,6 @@ def download_gdrive_file(file_id, filename): project = 'stLearn' copyright = '2022-2025, Genomics and Machine Learning Lab' author = 'Genomics and Machine Learning Lab' -release = stlearn.__version__ html_logo = "images/logo.png" # -- General configuration --------------------------------------------------- @@ -82,8 +78,41 @@ def setup(app): autosummary_generate_overwrite = True autodoc_mock_imports = [ - 'numpy', 'pandas', 'scipy', 'sklearn', 'scanpy', 'anndata', - 'matplotlib', 'seaborn', 'plotly', 'bokeh', 'cv2', 'PIL', - 'rpy2', 'louvain', 'numba', 'leidenalg', - # Add any other packages causing import issues + 'numpy', + 'pandas', + 'scipy', + 'sklearn', + 'scanpy', + 'anndata', + 'matplotlib', + 'seaborn', + 'plotly', + 'bokeh', + 'cv2', + 'PIL', + 'rpy2', + 'louvain', + 'numba', + 'leidenalg', + 'squidpy', + 'cellphonedb', + 'torch', + 'tensorflow', + 'keras', + 'networkx', + 'igraph', + 'fa2', + 'umap', + 'phate', + 'harmonypy', + 'bbknn', + 'scanorama', + 'combat', + 'magic', + 'palantir', + 'pypng', + 'tifffile', + 'imageio', + 'skimage', + 'cv2', ] \ No newline at end of file From 07fd1965399ecea8d2fcbba9681e419fce24d89a Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:55:57 +1000 Subject: [PATCH 111/123] Try. --- docs/requirements.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..51f463c1 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,13 @@ +# Documentation dependencies +sphinx>=4.0 +furo==2024.8.6 +myst-parser>=0.18 +nbsphinx>=0.9.0 +sphinx-autodoc-typehints>=1.24.0 +sphinx-autosummary-accessors>=2023.4.0 +sphinx-copybutton>=0.5.2 +ipykernel>=6.0.0 + +# Core dependencies (lightweight versions) +numpy +pandas \ No newline at end of file From 9f6c048ac9a590cf8df97bec52d5303969daba4c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:57:17 +1000 Subject: [PATCH 112/123] Forgot. --- .readthedocs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 5552fdfd..cb6c38a7 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,7 +17,7 @@ sphinx: # Optionally, but recommended, # declare the Python requirements required to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: -# install: -# - requirements: docs/requirements.txt + python: + install: + - requirements: docs/requirements.txt \ No newline at end of file From f7a2dcc31eec7e2e4f03917bab89590f93c17c6b Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 06:57:39 +1000 Subject: [PATCH 113/123] Forgot. --- .readthedocs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index cb6c38a7..0bcd807b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,7 +17,7 @@ sphinx: # Optionally, but recommended, # declare the Python requirements required to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html - python: - install: - - requirements: docs/requirements.txt +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file From 3fa76e44c12ff54473150dab46c9aa19d22396be Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 07:02:11 +1000 Subject: [PATCH 114/123] Forgot. --- .readthedocs.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 0bcd807b..5460350a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,15 +9,20 @@ build: os: ubuntu-24.04 tools: python: "3.10" + jobs: + post_create_environment: + - apt-get update + - apt-get install -y pandoc + post_install: + - pip install -e . # Build documentation in the "docs/" directory with Sphinx sphinx: - configuration: docs/conf.py + configuration: docs/conf.py # Optionally, but recommended, # declare the Python requirements required to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: - install: - - requirements: docs/requirements.txt - \ No newline at end of file + install: + - requirements: docs/requirements.txt From 6b0d2492c26286c7f7be1aaa750a482ac34bee80 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 07:03:05 +1000 Subject: [PATCH 115/123] Try. --- .readthedocs.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 5460350a..2292fec4 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,10 +9,6 @@ build: os: ubuntu-24.04 tools: python: "3.10" - jobs: - post_create_environment: - - apt-get update - - apt-get install -y pandoc post_install: - pip install -e . From 84b01234100a954a6966c04473ceb733eee46565 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 07:08:05 +1000 Subject: [PATCH 116/123] Try. --- .readthedocs.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 2292fec4..217fcd3c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,8 +9,6 @@ build: os: ubuntu-24.04 tools: python: "3.10" - post_install: - - pip install -e . # Build documentation in the "docs/" directory with Sphinx sphinx: @@ -22,3 +20,7 @@ sphinx: python: install: - requirements: docs/requirements.txt + - method: pip + path: . + extra_requirements: + - dev \ No newline at end of file From b3f77ad6ae0d2fb6d63eea2020440e70c62b994c Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 09:15:21 +1000 Subject: [PATCH 117/123] Try. --- docs/requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 51f463c1..0db7afd2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -8,6 +8,10 @@ sphinx-autosummary-accessors>=2023.4.0 sphinx-copybutton>=0.5.2 ipykernel>=6.0.0 +# Typing +typing-extensions>=4.0.0 +types-setuptools + # Core dependencies (lightweight versions) numpy pandas \ No newline at end of file From 2888c061ea60117bc0504d14279fa56505dff0cd Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 09:30:02 +1000 Subject: [PATCH 118/123] Just rely on project installation. --- .readthedocs.yml | 1 - docs/conf.py | 40 ---------------------------------------- docs/requirements.txt | 17 ----------------- 3 files changed, 58 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml index 217fcd3c..e841d344 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -19,7 +19,6 @@ sphinx: # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - - requirements: docs/requirements.txt - method: pip path: . extra_requirements: diff --git a/docs/conf.py b/docs/conf.py index 984a5292..ccda1308 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -76,43 +76,3 @@ def setup(app): # Output directory for autosummary autosummary_generate_overwrite = True - -autodoc_mock_imports = [ - 'numpy', - 'pandas', - 'scipy', - 'sklearn', - 'scanpy', - 'anndata', - 'matplotlib', - 'seaborn', - 'plotly', - 'bokeh', - 'cv2', - 'PIL', - 'rpy2', - 'louvain', - 'numba', - 'leidenalg', - 'squidpy', - 'cellphonedb', - 'torch', - 'tensorflow', - 'keras', - 'networkx', - 'igraph', - 'fa2', - 'umap', - 'phate', - 'harmonypy', - 'bbknn', - 'scanorama', - 'combat', - 'magic', - 'palantir', - 'pypng', - 'tifffile', - 'imageio', - 'skimage', - 'cv2', -] \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 0db7afd2..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -# Documentation dependencies -sphinx>=4.0 -furo==2024.8.6 -myst-parser>=0.18 -nbsphinx>=0.9.0 -sphinx-autodoc-typehints>=1.24.0 -sphinx-autosummary-accessors>=2023.4.0 -sphinx-copybutton>=0.5.2 -ipykernel>=6.0.0 - -# Typing -typing-extensions>=4.0.0 -types-setuptools - -# Core dependencies (lightweight versions) -numpy -pandas \ No newline at end of file From df82f78e1bdbb21c7b88fd4d3c0042452ac7aec6 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 12:42:24 +1000 Subject: [PATCH 119/123] Sphinx gets confused with renaming package imports. Making more consistent. --- HISTORY.rst | 1 + docs/api.rst | 16 +--- docs/release_notes/1.1.0.rst | 3 +- stlearn/adds/add_mask.py | 2 +- stlearn/app/app.py | 10 +-- stlearn/app/source/forms/views.py | 2 +- stlearn/pl.py | 60 --------------- stlearn/{plotting => pl}/QC_plot.py | 0 stlearn/pl/__init__.py | 77 +++++++++++++++++++ stlearn/{plotting => pl}/_docs.py | 0 stlearn/{plotting => pl}/cci_plot.py | 4 +- stlearn/{plotting => pl}/cci_plot_helpers.py | 2 +- stlearn/{plotting => pl}/classes.py | 0 stlearn/{plotting => pl}/classes_bokeh.py | 8 +- stlearn/{plotting => pl}/cluster_plot.py | 6 +- .../{plotting => pl}/deconvolution_plot.py | 0 stlearn/{plotting => pl}/feat_plot.py | 2 +- stlearn/{plotting => pl}/gene_plot.py | 6 +- stlearn/{plotting => pl}/mask_plot.py | 2 +- stlearn/{plotting => pl}/non_spatial_plot.py | 0 stlearn/{plotting => pl}/palettes_st.py | 0 stlearn/{plotting => pl}/stack_3d_plot.py | 0 stlearn/{plotting => pl}/subcluster_plot.py | 4 +- .../trajectory/DE_transition_plot.py | 0 .../{plotting => pl}/trajectory/__init__.py | 10 ++- .../trajectory/check_trajectory.py | 0 .../{plotting => pl}/trajectory/local_plot.py | 0 .../trajectory/pseudotime_plot.py | 2 +- .../trajectory/transition_markers_plot.py | 0 .../{plotting => pl}/trajectory/tree_plot.py | 0 .../trajectory/tree_plot_simple.py | 0 stlearn/{plotting => pl}/trajectory/utils.py | 0 stlearn/{plotting => pl}/utils.py | 2 +- stlearn/spatial.py | 9 --- stlearn/{spatials => spatial}/SME/__init__.py | 0 .../SME/_weighting_matrix.py | 0 stlearn/{spatials => spatial}/SME/impute.py | 0 .../{spatials => spatial}/SME/normalize.py | 0 stlearn/spatial/__init__.py | 15 ++++ .../clustering/__init__.py | 0 .../clustering/localization.py | 0 .../morphology/__init__.py | 0 .../morphology/adjust.py | 0 .../{spatials => spatial}/smooth/__init__.py | 0 stlearn/{spatials => spatial}/smooth/disk.py | 0 .../trajectory/__init__.py | 0 .../trajectory/compare_transitions.py | 0 .../trajectory/detect_transition_markers.py | 0 .../trajectory/global_level.py | 0 .../trajectory/local_level.py | 0 .../trajectory/pseudotime.py | 4 +- .../trajectory/pseudotimespace.py | 0 .../trajectory/set_root.py | 2 +- .../trajectory/shortest_path_spatial_PAGA.py | 2 +- .../{spatials => spatial}/trajectory/utils.py | 0 .../trajectory/weight_optimization.py | 0 stlearn/spatials/__init__.py | 0 stlearn/tl.py | 10 --- stlearn/tl/__init__.py | 13 ++++ stlearn/{tools => tl}/cache/__init__.py | 0 stlearn/{tools => tl}/cache/anndata.py | 0 stlearn/tl/cci/__init__.py | 4 + .../{tools/microenv => tl}/cci/analysis.py | 0 stlearn/{tools/microenv => tl}/cci/base.py | 0 .../microenv => tl}/cci/base_grouping.py | 0 .../cci/databases}/__init__.py | 0 .../cci/databases/connectomeDB2020_lit.txt | 0 .../cci/databases/connectomeDB2020_put.txt | 0 stlearn/{tools/microenv => tl}/cci/go.R | 0 stlearn/{tools/microenv => tl}/cci/go.py | 2 +- stlearn/{tools/microenv => tl}/cci/het.py | 2 +- .../{tools/microenv => tl}/cci/het_helpers.py | 0 stlearn/{tools/microenv => tl}/cci/merge.py | 0 .../{tools/microenv => tl}/cci/perm_utils.py | 0 .../{tools/microenv => tl}/cci/permutation.py | 0 .../{tools/microenv => tl}/cci/r_helpers.py | 0 stlearn/{tools => tl}/clustering/__init__.py | 0 stlearn/{tools => tl}/clustering/annotate.py | 2 +- stlearn/{tools => tl}/clustering/kmeans.py | 29 +++---- stlearn/{tools => tl}/clustering/louvain.py | 0 stlearn/tl/label/__init__.py | 7 ++ stlearn/{tools => tl}/label/label.py | 2 +- stlearn/{tools => tl}/label/label_transfer.R | 0 stlearn/{tools => tl}/label/rctd.R | 0 stlearn/{tools => tl}/label/singleR.R | 0 stlearn/tools/__init__.py | 0 stlearn/tools/label/__init__.py | 0 stlearn/tools/microenv/__init__.py | 0 stlearn/tools/microenv/cci/__init__.py | 16 ---- .../tools/microenv/cci/databases/__init__.py | 0 stlearn/wrapper/read.py | 3 - tests/test_CCI.py | 4 +- tests/test_cluster_plot.py | 2 +- 93 files changed, 179 insertions(+), 168 deletions(-) delete mode 100644 stlearn/pl.py rename stlearn/{plotting => pl}/QC_plot.py (100%) create mode 100644 stlearn/pl/__init__.py rename stlearn/{plotting => pl}/_docs.py (100%) rename stlearn/{plotting => pl}/cci_plot.py (99%) rename stlearn/{plotting => pl}/cci_plot_helpers.py (99%) rename stlearn/{plotting => pl}/classes.py (100%) rename stlearn/{plotting => pl}/classes_bokeh.py (99%) rename stlearn/{plotting => pl}/cluster_plot.py (95%) rename stlearn/{plotting => pl}/deconvolution_plot.py (100%) rename stlearn/{plotting => pl}/feat_plot.py (97%) rename stlearn/{plotting => pl}/gene_plot.py (93%) rename stlearn/{plotting => pl}/mask_plot.py (99%) rename stlearn/{plotting => pl}/non_spatial_plot.py (100%) rename stlearn/{plotting => pl}/palettes_st.py (100%) rename stlearn/{plotting => pl}/stack_3d_plot.py (100%) rename stlearn/{plotting => pl}/subcluster_plot.py (94%) rename stlearn/{plotting => pl}/trajectory/DE_transition_plot.py (100%) rename stlearn/{plotting => pl}/trajectory/__init__.py (93%) rename stlearn/{plotting => pl}/trajectory/check_trajectory.py (100%) rename stlearn/{plotting => pl}/trajectory/local_plot.py (100%) rename stlearn/{plotting => pl}/trajectory/pseudotime_plot.py (99%) rename stlearn/{plotting => pl}/trajectory/transition_markers_plot.py (100%) rename stlearn/{plotting => pl}/trajectory/tree_plot.py (100%) rename stlearn/{plotting => pl}/trajectory/tree_plot_simple.py (100%) rename stlearn/{plotting => pl}/trajectory/utils.py (100%) rename stlearn/{plotting => pl}/utils.py (98%) delete mode 100644 stlearn/spatial.py rename stlearn/{spatials => spatial}/SME/__init__.py (100%) rename stlearn/{spatials => spatial}/SME/_weighting_matrix.py (100%) rename stlearn/{spatials => spatial}/SME/impute.py (100%) rename stlearn/{spatials => spatial}/SME/normalize.py (100%) create mode 100644 stlearn/spatial/__init__.py rename stlearn/{spatials => spatial}/clustering/__init__.py (100%) rename stlearn/{spatials => spatial}/clustering/localization.py (100%) rename stlearn/{spatials => spatial}/morphology/__init__.py (100%) rename stlearn/{spatials => spatial}/morphology/adjust.py (100%) rename stlearn/{spatials => spatial}/smooth/__init__.py (100%) rename stlearn/{spatials => spatial}/smooth/disk.py (100%) rename stlearn/{spatials => spatial}/trajectory/__init__.py (100%) rename stlearn/{spatials => spatial}/trajectory/compare_transitions.py (100%) rename stlearn/{spatials => spatial}/trajectory/detect_transition_markers.py (100%) rename stlearn/{spatials => spatial}/trajectory/global_level.py (100%) rename stlearn/{spatials => spatial}/trajectory/local_level.py (100%) rename stlearn/{spatials => spatial}/trajectory/pseudotime.py (98%) rename stlearn/{spatials => spatial}/trajectory/pseudotimespace.py (100%) rename stlearn/{spatials => spatial}/trajectory/set_root.py (97%) rename stlearn/{spatials => spatial}/trajectory/shortest_path_spatial_PAGA.py (98%) rename stlearn/{spatials => spatial}/trajectory/utils.py (100%) rename stlearn/{spatials => spatial}/trajectory/weight_optimization.py (100%) delete mode 100644 stlearn/spatials/__init__.py delete mode 100644 stlearn/tl.py create mode 100644 stlearn/tl/__init__.py rename stlearn/{tools => tl}/cache/__init__.py (100%) rename stlearn/{tools => tl}/cache/anndata.py (100%) create mode 100644 stlearn/tl/cci/__init__.py rename stlearn/{tools/microenv => tl}/cci/analysis.py (100%) rename stlearn/{tools/microenv => tl}/cci/base.py (100%) rename stlearn/{tools/microenv => tl}/cci/base_grouping.py (100%) rename stlearn/{plotting => tl/cci/databases}/__init__.py (100%) rename stlearn/{tools/microenv => tl}/cci/databases/connectomeDB2020_lit.txt (100%) rename stlearn/{tools/microenv => tl}/cci/databases/connectomeDB2020_put.txt (100%) rename stlearn/{tools/microenv => tl}/cci/go.R (100%) rename stlearn/{tools/microenv => tl}/cci/go.py (95%) rename stlearn/{tools/microenv => tl}/cci/het.py (99%) rename stlearn/{tools/microenv => tl}/cci/het_helpers.py (100%) rename stlearn/{tools/microenv => tl}/cci/merge.py (100%) rename stlearn/{tools/microenv => tl}/cci/perm_utils.py (100%) rename stlearn/{tools/microenv => tl}/cci/permutation.py (100%) rename stlearn/{tools/microenv => tl}/cci/r_helpers.py (100%) rename stlearn/{tools => tl}/clustering/__init__.py (100%) rename stlearn/{tools => tl}/clustering/annotate.py (88%) rename stlearn/{tools => tl}/clustering/kmeans.py (79%) rename stlearn/{tools => tl}/clustering/louvain.py (100%) create mode 100644 stlearn/tl/label/__init__.py rename stlearn/{tools => tl}/label/label.py (99%) rename stlearn/{tools => tl}/label/label_transfer.R (100%) rename stlearn/{tools => tl}/label/rctd.R (100%) rename stlearn/{tools => tl}/label/singleR.R (100%) delete mode 100644 stlearn/tools/__init__.py delete mode 100644 stlearn/tools/label/__init__.py delete mode 100644 stlearn/tools/microenv/__init__.py delete mode 100644 stlearn/tools/microenv/cci/__init__.py delete mode 100644 stlearn/tools/microenv/cci/databases/__init__.py diff --git a/HISTORY.rst b/HISTORY.rst index 8c608fe3..2714496b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -16,6 +16,7 @@ API and Bug Fixes: * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. * Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. +* Moved spatials directory to spatial. 0.4.11 (2022-11-25) ------------------ diff --git a/docs/api.rst b/docs/api.rst index 104ad279..c27132ff 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -13,7 +13,6 @@ Import stLearn as:: Wrapper functions: `wrapper` ------------------------------ -.. module:: stlearn.wrapper .. currentmodule:: stlearn .. autosummary:: @@ -31,7 +30,6 @@ Wrapper functions: `wrapper` Add: `add` ------------------- -.. module:: stlearn.add .. currentmodule:: stlearn .. autosummary:: @@ -130,7 +128,6 @@ Spatial: `spatial` Tools: `tl` ------------------- -.. module:: stlearn.tl.clustering .. currentmodule:: stlearn .. autosummary:: @@ -138,13 +135,6 @@ Tools: `tl` tl.clustering.kmeans tl.clustering.louvain - -.. module:: stlearn.tl.cci -.. currentmodule:: stlearn - -.. autosummary:: - :toctree: api/ - tl.cci.load_lrs tl.cci.grid tl.cci.run @@ -155,7 +145,6 @@ Tools: `tl` Plot: `pl` ------------------- -.. module:: stlearn.pl .. currentmodule:: stlearn .. autosummary:: @@ -167,7 +156,6 @@ Plot: `pl` pl.cluster_plot pl.cluster_plot_interactive pl.subcluster_plot - pl.subcluster_plot pl.non_spatial_plot pl.deconvolution_plot pl.plot_mask @@ -185,7 +173,6 @@ Plot: `pl` pl.lr_plot_interactive pl.spatialcci_plot_interactive -.. module:: stlearn.pl.trajectory .. currentmodule:: stlearn .. autosummary:: @@ -198,9 +185,8 @@ Plot: `pl` pl.trajectory.DE_transition_plot Datasets: `datasets` -------------------- +--------------------------- -.. module:: stlearn.datasets .. currentmodule:: stlearn .. autosummary:: diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst index 9d040781..e255fdd0 100644 --- a/docs/release_notes/1.1.0.rst +++ b/docs/release_notes/1.1.0.rst @@ -15,4 +15,5 @@ * Consistent with type annotations - mainly missing None annotations. * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. -* Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. \ No newline at end of file +* Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. +* Moved spatials directory to spatial. \ No newline at end of file diff --git a/stlearn/adds/add_mask.py b/stlearn/adds/add_mask.py index 998b4936..d25a488c 100644 --- a/stlearn/adds/add_mask.py +++ b/stlearn/adds/add_mask.py @@ -106,7 +106,7 @@ def apply_mask( """ from scanpy.plotting import palettes - from stlearn.plotting import palettes_st + from stlearn.pl import palettes_st adata = adata.copy() if copy else adata diff --git a/stlearn/app/app.py b/stlearn/app/app.py index 25964ae7..d1e914f3 100644 --- a/stlearn/app/app.py +++ b/stlearn/app/app.py @@ -366,7 +366,7 @@ def save_adata(): def modify_doc_gene_plot(doc): - from stlearn.plotting.classes_bokeh import BokehGenePlot + from stlearn.pl.classes_bokeh import BokehGenePlot gp_object = BokehGenePlot(adata) doc.add_root(row(gp_object.layout, width=800)) @@ -383,7 +383,7 @@ def modify_doc_gene_plot(doc): def modify_doc_cluster_plot(doc): - from stlearn.plotting.classes_bokeh import BokehClusterPlot + from stlearn.pl.classes_bokeh import BokehClusterPlot gp_object = BokehClusterPlot(adata) doc.add_root(row(gp_object.layout, width=800)) @@ -404,7 +404,7 @@ def modify_doc_cluster_plot(doc): def modify_doc_spatial_cci_plot(doc): - from stlearn.plotting.classes_bokeh import BokehSpatialCciPlot + from stlearn.pl.classes_bokeh import BokehSpatialCciPlot gp_object = BokehSpatialCciPlot(adata) doc.add_root(row(gp_object.layout, width=800)) @@ -420,7 +420,7 @@ def modify_doc_spatial_cci_plot(doc): def modify_doc_lr_plot(doc): - from stlearn.plotting.classes_bokeh import BokehLRPlot + from stlearn.pl.classes_bokeh import BokehLRPlot gp_object = BokehLRPlot(adata) doc.add_root(row(gp_object.layout, width=800)) @@ -434,7 +434,7 @@ def modify_doc_lr_plot(doc): def modify_doc_annotate_plot(doc): - from stlearn.plotting.classes_bokeh import Annotate + from stlearn.pl.classes_bokeh import Annotate gp_object = Annotate(adata) doc.add_root(row(gp_object.layout, width=800)) diff --git a/stlearn/app/source/forms/views.py b/stlearn/app/source/forms/views.py index 3aa58bbb..3a857319 100644 --- a/stlearn/app/source/forms/views.py +++ b/stlearn/app/source/forms/views.py @@ -286,7 +286,7 @@ def run_psts(request, adata, step_log): else: try: - from stlearn.spatials.trajectory import set_root + from stlearn.spatial.trajectory import set_root root_index = set_root( adata, use_label="clusters", cluster=str(element_values[0]) diff --git a/stlearn/pl.py b/stlearn/pl.py deleted file mode 100644 index 78db0c8a..00000000 --- a/stlearn/pl.py +++ /dev/null @@ -1,60 +0,0 @@ -# from .plotting.cci_plot import het_plot_interactive -from .plotting import trajectory - -# from .plotting.cci_plot import het_plot_interactive -from .plotting.cci_plot import ( - cci_check, - cci_map, - ccinet_plot, - grid_plot, - het_plot, - lr_cci_map, - lr_chord_plot, - lr_diagnostics, - lr_go, - lr_n_spots, - lr_plot, - lr_plot_interactive, - lr_result_plot, - lr_summary, - spatialcci_plot_interactive, -) -from .plotting.cluster_plot import cluster_plot, cluster_plot_interactive -from .plotting.deconvolution_plot import deconvolution_plot -from .plotting.feat_plot import feat_plot -from .plotting.gene_plot import gene_plot, gene_plot_interactive -from .plotting.mask_plot import plot_mask -from .plotting.non_spatial_plot import non_spatial_plot -from .plotting.QC_plot import QC_plot -from .plotting.stack_3d_plot import stack_3d_plot -from .plotting.subcluster_plot import subcluster_plot - -__all__ = [ - "gene_plot", - "gene_plot_interactive", - "feat_plot", - "cluster_plot", - "cluster_plot_interactive", - "subcluster_plot", - "non_spatial_plot", - "deconvolution_plot", - "stack_3d_plot", - "trajectory", - "QC_plot", - "het_plot", - "lr_plot_interactive", - "spatialcci_plot_interactive", - "grid_plot", - "lr_diagnostics", - "lr_n_spots", - "lr_summary", - "lr_go", - "lr_plot", - "lr_result_plot", - "ccinet_plot", - "cci_map", - "lr_cci_map", - "lr_chord_plot", - "cci_check", - "plot_mask", -] diff --git a/stlearn/plotting/QC_plot.py b/stlearn/pl/QC_plot.py similarity index 100% rename from stlearn/plotting/QC_plot.py rename to stlearn/pl/QC_plot.py diff --git a/stlearn/pl/__init__.py b/stlearn/pl/__init__.py new file mode 100644 index 00000000..c24e63a2 --- /dev/null +++ b/stlearn/pl/__init__.py @@ -0,0 +1,77 @@ +# Import individual functions from modules +from .cci_plot import ( + cci_check, + cci_map, + ccinet_plot, + grid_plot, + het_plot, + lr_cci_map, + lr_chord_plot, + lr_diagnostics, + lr_go, + lr_n_spots, + lr_plot, + lr_plot_interactive, + lr_result_plot, + lr_summary, + spatialcci_plot_interactive, +) +from .cluster_plot import cluster_plot, cluster_plot_interactive +from .deconvolution_plot import deconvolution_plot +from .feat_plot import feat_plot +from .gene_plot import gene_plot, gene_plot_interactive +from .mask_plot import plot_mask +from .non_spatial_plot import non_spatial_plot +from .QC_plot import QC_plot +from .stack_3d_plot import stack_3d_plot +from .subcluster_plot import subcluster_plot + +# Import trajectory functions +from .trajectory import ( + DE_transition_plot, + check_trajectory, + local_plot, + pseudotime_plot, + transition_markers_plot, + tree_plot, + tree_plot_simple, +) + +__all__ = [ + # CCI plot functions + "cci_check", + "cci_map", + "ccinet_plot", + "grid_plot", + "het_plot", + "lr_cci_map", + "lr_chord_plot", + "lr_diagnostics", + "lr_go", + "lr_n_spots", + "lr_plot", + "lr_plot_interactive", + "lr_result_plot", + "lr_summary", + "spatialcci_plot_interactive", + # Other plot functions + "cluster_plot", + "cluster_plot_interactive", + "deconvolution_plot", + "feat_plot", + "gene_plot", + "gene_plot_interactive", + "plot_mask", + "non_spatial_plot", + "QC_plot", + "stack_3d_plot", + "subcluster_plot", + # Trajectory functions + "pseudotime_plot", + "local_plot", + "tree_plot", + "transition_markers_plot", + "DE_transition_plot", + "tree_plot_simple", + "check_trajectory", +] diff --git a/stlearn/plotting/_docs.py b/stlearn/pl/_docs.py similarity index 100% rename from stlearn/plotting/_docs.py rename to stlearn/pl/_docs.py diff --git a/stlearn/plotting/cci_plot.py b/stlearn/pl/cci_plot.py similarity index 99% rename from stlearn/plotting/cci_plot.py rename to stlearn/pl/cci_plot.py index 95fde072..cc8e0918 100644 --- a/stlearn/plotting/cci_plot.py +++ b/stlearn/pl/cci_plot.py @@ -19,8 +19,8 @@ from bokeh.plotting import show from scipy.stats import gaussian_kde -import stlearn.plotting.cci_plot_helpers as cci_hs -from stlearn.plotting.utils import get_colors +import stlearn.pl.cci_plot_helpers as cci_hs +from stlearn.pl.utils import get_colors from ..utils import _docs_params from ._docs import doc_het_plot, doc_spatial_base_plot diff --git a/stlearn/plotting/cci_plot_helpers.py b/stlearn/pl/cci_plot_helpers.py similarity index 99% rename from stlearn/plotting/cci_plot_helpers.py rename to stlearn/pl/cci_plot_helpers.py index 25b2a25e..28ac1b5a 100644 --- a/stlearn/plotting/cci_plot_helpers.py +++ b/stlearn/pl/cci_plot_helpers.py @@ -13,7 +13,7 @@ from matplotlib.path import Path from mpl_toolkits.axes_grid1 import make_axes_locatable -from ..tools.microenv.cci.het import get_edges +from stlearn.tl.cci.het import get_edges # Helper functions for overview plots of the LRs. diff --git a/stlearn/plotting/classes.py b/stlearn/pl/classes.py similarity index 100% rename from stlearn/plotting/classes.py rename to stlearn/pl/classes.py diff --git a/stlearn/plotting/classes_bokeh.py b/stlearn/pl/classes_bokeh.py similarity index 99% rename from stlearn/plotting/classes_bokeh.py rename to stlearn/pl/classes_bokeh.py index 54a69c0b..cd52d16a 100644 --- a/stlearn/plotting/classes_bokeh.py +++ b/stlearn/pl/classes_bokeh.py @@ -43,7 +43,7 @@ from PIL import Image from stlearn.classes import Spatial -from stlearn.tools.microenv.cci.het import get_edges +from stlearn.tl.cci import get_edges from stlearn.utils import _read_graph @@ -357,7 +357,7 @@ def __init__( self.use_label = Select(title="Select use_label:", value=menu[0], options=menu) # Initialize the color - from stlearn.plotting.cluster_plot import cluster_plot + from stlearn.pl.cluster_plot import cluster_plot if len(adata.obs[self.use_label.value].cat.categories) <= 20: cluster_plot(adata, use_label=self.use_label.value, show_plot=False) @@ -505,7 +505,7 @@ def modify_fig(doc): def update_list(self, attrname, old, name): # Initialize the color - from stlearn.plotting.cluster_plot import cluster_plot + from stlearn.pl.cluster_plot import cluster_plot cluster_plot(self.adata[0], use_label=self.use_label.value, show_plot=False) self.list_cluster.labels = list( @@ -1214,7 +1214,7 @@ def _add_edges(fig, adata, edges, arrow_size, forward=True, scale_factor=1): def update_list(self, attrname, old, name): # Initialize the color - from stlearn.plotting.cluster_plot import cluster_plot + from stlearn.pl.cluster_plot import cluster_plot selected = self.annot_select.value.strip("raw_") cluster_plot(self.adata[0], use_label=selected, show_plot=False) diff --git a/stlearn/plotting/cluster_plot.py b/stlearn/pl/cluster_plot.py similarity index 95% rename from stlearn/plotting/cluster_plot.py rename to stlearn/pl/cluster_plot.py index 1293dab6..288f0b7f 100644 --- a/stlearn/plotting/cluster_plot.py +++ b/stlearn/pl/cluster_plot.py @@ -7,9 +7,9 @@ from bokeh.io import output_notebook from bokeh.plotting import show -from stlearn.plotting._docs import doc_cluster_plot, doc_spatial_base_plot -from stlearn.plotting.classes import ClusterPlot -from stlearn.plotting.classes_bokeh import BokehClusterPlot +from stlearn.pl._docs import doc_cluster_plot, doc_spatial_base_plot +from stlearn.pl.classes import ClusterPlot +from stlearn.pl.classes_bokeh import BokehClusterPlot from stlearn.utils import _docs_params diff --git a/stlearn/plotting/deconvolution_plot.py b/stlearn/pl/deconvolution_plot.py similarity index 100% rename from stlearn/plotting/deconvolution_plot.py rename to stlearn/pl/deconvolution_plot.py diff --git a/stlearn/plotting/feat_plot.py b/stlearn/pl/feat_plot.py similarity index 97% rename from stlearn/plotting/feat_plot.py rename to stlearn/pl/feat_plot.py index 7a3d4bf7..26430cbb 100644 --- a/stlearn/plotting/feat_plot.py +++ b/stlearn/pl/feat_plot.py @@ -9,7 +9,7 @@ import matplotlib from anndata import AnnData -from stlearn.plotting.classes import FeaturePlot +from stlearn.pl.classes import FeaturePlot # @_docs_params(spatial_base_plot=doc_spatial_base_plot, gene_plot=doc_gene_plot) diff --git a/stlearn/plotting/gene_plot.py b/stlearn/pl/gene_plot.py similarity index 93% rename from stlearn/plotting/gene_plot.py rename to stlearn/pl/gene_plot.py index 8f4244c8..5b545170 100644 --- a/stlearn/plotting/gene_plot.py +++ b/stlearn/pl/gene_plot.py @@ -3,9 +3,9 @@ from bokeh.io import output_notebook from bokeh.plotting import show -from stlearn.plotting._docs import doc_gene_plot, doc_spatial_base_plot -from stlearn.plotting.classes import GenePlot -from stlearn.plotting.classes_bokeh import BokehGenePlot +from stlearn.pl._docs import doc_gene_plot, doc_spatial_base_plot +from stlearn.pl.classes import GenePlot +from stlearn.pl.classes_bokeh import BokehGenePlot from stlearn.utils import _docs_params diff --git a/stlearn/plotting/mask_plot.py b/stlearn/pl/mask_plot.py similarity index 99% rename from stlearn/plotting/mask_plot.py rename to stlearn/pl/mask_plot.py index 05ee3f9f..60762ccc 100644 --- a/stlearn/plotting/mask_plot.py +++ b/stlearn/pl/mask_plot.py @@ -58,7 +58,7 @@ def plot_mask( """ from scanpy.plotting import palettes - from stlearn.plotting import palettes_st + from stlearn.pl import palettes_st if cmap_name == "vega_10_scanpy": cmap = palettes.vega_10_scanpy diff --git a/stlearn/plotting/non_spatial_plot.py b/stlearn/pl/non_spatial_plot.py similarity index 100% rename from stlearn/plotting/non_spatial_plot.py rename to stlearn/pl/non_spatial_plot.py diff --git a/stlearn/plotting/palettes_st.py b/stlearn/pl/palettes_st.py similarity index 100% rename from stlearn/plotting/palettes_st.py rename to stlearn/pl/palettes_st.py diff --git a/stlearn/plotting/stack_3d_plot.py b/stlearn/pl/stack_3d_plot.py similarity index 100% rename from stlearn/plotting/stack_3d_plot.py rename to stlearn/pl/stack_3d_plot.py diff --git a/stlearn/plotting/subcluster_plot.py b/stlearn/pl/subcluster_plot.py similarity index 94% rename from stlearn/plotting/subcluster_plot.py rename to stlearn/pl/subcluster_plot.py index 3f5eff3d..2b7b0ec2 100644 --- a/stlearn/plotting/subcluster_plot.py +++ b/stlearn/pl/subcluster_plot.py @@ -4,8 +4,8 @@ from anndata import AnnData -from stlearn.plotting._docs import doc_spatial_base_plot, doc_subcluster_plot -from stlearn.plotting.classes import SubClusterPlot +from stlearn.pl._docs import doc_spatial_base_plot, doc_subcluster_plot +from stlearn.pl.classes import SubClusterPlot from stlearn.utils import _AxesSubplot, _docs_params diff --git a/stlearn/plotting/trajectory/DE_transition_plot.py b/stlearn/pl/trajectory/DE_transition_plot.py similarity index 100% rename from stlearn/plotting/trajectory/DE_transition_plot.py rename to stlearn/pl/trajectory/DE_transition_plot.py diff --git a/stlearn/plotting/trajectory/__init__.py b/stlearn/pl/trajectory/__init__.py similarity index 93% rename from stlearn/plotting/trajectory/__init__.py rename to stlearn/pl/trajectory/__init__.py index 4d5d7457..2b5417d5 100644 --- a/stlearn/plotting/trajectory/__init__.py +++ b/stlearn/pl/trajectory/__init__.py @@ -1,3 +1,5 @@ +# stlearn/pl/trajectory/__init__.py + from .check_trajectory import check_trajectory from .DE_transition_plot import DE_transition_plot from .local_plot import local_plot @@ -7,11 +9,11 @@ from .tree_plot_simple import tree_plot_simple __all__ = [ - "DE_transition_plot", - "check_trajectory", - "local_plot", "pseudotime_plot", - "transition_markers_plot", + "local_plot", "tree_plot", + "transition_markers_plot", + "DE_transition_plot", "tree_plot_simple", + "check_trajectory", ] diff --git a/stlearn/plotting/trajectory/check_trajectory.py b/stlearn/pl/trajectory/check_trajectory.py similarity index 100% rename from stlearn/plotting/trajectory/check_trajectory.py rename to stlearn/pl/trajectory/check_trajectory.py diff --git a/stlearn/plotting/trajectory/local_plot.py b/stlearn/pl/trajectory/local_plot.py similarity index 100% rename from stlearn/plotting/trajectory/local_plot.py rename to stlearn/pl/trajectory/local_plot.py diff --git a/stlearn/plotting/trajectory/pseudotime_plot.py b/stlearn/pl/trajectory/pseudotime_plot.py similarity index 99% rename from stlearn/plotting/trajectory/pseudotime_plot.py rename to stlearn/pl/trajectory/pseudotime_plot.py index ee72a426..63868009 100644 --- a/stlearn/plotting/trajectory/pseudotime_plot.py +++ b/stlearn/pl/trajectory/pseudotime_plot.py @@ -5,7 +5,7 @@ from matplotlib import pyplot as plt from numpy._typing import NDArray -from stlearn.plotting.utils import get_cluster, get_node +from stlearn.pl.utils import get_cluster, get_node from stlearn.utils import _read_graph diff --git a/stlearn/plotting/trajectory/transition_markers_plot.py b/stlearn/pl/trajectory/transition_markers_plot.py similarity index 100% rename from stlearn/plotting/trajectory/transition_markers_plot.py rename to stlearn/pl/trajectory/transition_markers_plot.py diff --git a/stlearn/plotting/trajectory/tree_plot.py b/stlearn/pl/trajectory/tree_plot.py similarity index 100% rename from stlearn/plotting/trajectory/tree_plot.py rename to stlearn/pl/trajectory/tree_plot.py diff --git a/stlearn/plotting/trajectory/tree_plot_simple.py b/stlearn/pl/trajectory/tree_plot_simple.py similarity index 100% rename from stlearn/plotting/trajectory/tree_plot_simple.py rename to stlearn/pl/trajectory/tree_plot_simple.py diff --git a/stlearn/plotting/trajectory/utils.py b/stlearn/pl/trajectory/utils.py similarity index 100% rename from stlearn/plotting/trajectory/utils.py rename to stlearn/pl/trajectory/utils.py diff --git a/stlearn/plotting/utils.py b/stlearn/pl/utils.py similarity index 98% rename from stlearn/plotting/utils.py rename to stlearn/pl/utils.py index a1380899..30363049 100644 --- a/stlearn/plotting/utils.py +++ b/stlearn/pl/utils.py @@ -6,7 +6,7 @@ from PIL import Image from scanpy.plotting import palettes -from stlearn.plotting import palettes_st +from stlearn.pl import palettes_st def get_img_from_fig(fig, dpi=180): diff --git a/stlearn/spatial.py b/stlearn/spatial.py deleted file mode 100644 index cbf7eced..00000000 --- a/stlearn/spatial.py +++ /dev/null @@ -1,9 +0,0 @@ -from .spatials import SME, clustering, morphology, smooth, trajectory - -__all__ = [ - "clustering", - "smooth", - "trajectory", - "morphology", - "SME", -] diff --git a/stlearn/spatials/SME/__init__.py b/stlearn/spatial/SME/__init__.py similarity index 100% rename from stlearn/spatials/SME/__init__.py rename to stlearn/spatial/SME/__init__.py diff --git a/stlearn/spatials/SME/_weighting_matrix.py b/stlearn/spatial/SME/_weighting_matrix.py similarity index 100% rename from stlearn/spatials/SME/_weighting_matrix.py rename to stlearn/spatial/SME/_weighting_matrix.py diff --git a/stlearn/spatials/SME/impute.py b/stlearn/spatial/SME/impute.py similarity index 100% rename from stlearn/spatials/SME/impute.py rename to stlearn/spatial/SME/impute.py diff --git a/stlearn/spatials/SME/normalize.py b/stlearn/spatial/SME/normalize.py similarity index 100% rename from stlearn/spatials/SME/normalize.py rename to stlearn/spatial/SME/normalize.py diff --git a/stlearn/spatial/__init__.py b/stlearn/spatial/__init__.py new file mode 100644 index 00000000..017501be --- /dev/null +++ b/stlearn/spatial/__init__.py @@ -0,0 +1,15 @@ +# stlearn/spatial/__init__.py + +from . import SME +from . import clustering +from . import morphology +from . import smooth +from . import trajectory + +__all__ = [ + "clustering", + "smooth", + "trajectory", + "morphology", + "SME", +] \ No newline at end of file diff --git a/stlearn/spatials/clustering/__init__.py b/stlearn/spatial/clustering/__init__.py similarity index 100% rename from stlearn/spatials/clustering/__init__.py rename to stlearn/spatial/clustering/__init__.py diff --git a/stlearn/spatials/clustering/localization.py b/stlearn/spatial/clustering/localization.py similarity index 100% rename from stlearn/spatials/clustering/localization.py rename to stlearn/spatial/clustering/localization.py diff --git a/stlearn/spatials/morphology/__init__.py b/stlearn/spatial/morphology/__init__.py similarity index 100% rename from stlearn/spatials/morphology/__init__.py rename to stlearn/spatial/morphology/__init__.py diff --git a/stlearn/spatials/morphology/adjust.py b/stlearn/spatial/morphology/adjust.py similarity index 100% rename from stlearn/spatials/morphology/adjust.py rename to stlearn/spatial/morphology/adjust.py diff --git a/stlearn/spatials/smooth/__init__.py b/stlearn/spatial/smooth/__init__.py similarity index 100% rename from stlearn/spatials/smooth/__init__.py rename to stlearn/spatial/smooth/__init__.py diff --git a/stlearn/spatials/smooth/disk.py b/stlearn/spatial/smooth/disk.py similarity index 100% rename from stlearn/spatials/smooth/disk.py rename to stlearn/spatial/smooth/disk.py diff --git a/stlearn/spatials/trajectory/__init__.py b/stlearn/spatial/trajectory/__init__.py similarity index 100% rename from stlearn/spatials/trajectory/__init__.py rename to stlearn/spatial/trajectory/__init__.py diff --git a/stlearn/spatials/trajectory/compare_transitions.py b/stlearn/spatial/trajectory/compare_transitions.py similarity index 100% rename from stlearn/spatials/trajectory/compare_transitions.py rename to stlearn/spatial/trajectory/compare_transitions.py diff --git a/stlearn/spatials/trajectory/detect_transition_markers.py b/stlearn/spatial/trajectory/detect_transition_markers.py similarity index 100% rename from stlearn/spatials/trajectory/detect_transition_markers.py rename to stlearn/spatial/trajectory/detect_transition_markers.py diff --git a/stlearn/spatials/trajectory/global_level.py b/stlearn/spatial/trajectory/global_level.py similarity index 100% rename from stlearn/spatials/trajectory/global_level.py rename to stlearn/spatial/trajectory/global_level.py diff --git a/stlearn/spatials/trajectory/local_level.py b/stlearn/spatial/trajectory/local_level.py similarity index 100% rename from stlearn/spatials/trajectory/local_level.py rename to stlearn/spatial/trajectory/local_level.py diff --git a/stlearn/spatials/trajectory/pseudotime.py b/stlearn/spatial/trajectory/pseudotime.py similarity index 98% rename from stlearn/spatials/trajectory/pseudotime.py rename to stlearn/spatial/trajectory/pseudotime.py index f70e2a8f..4615617e 100644 --- a/stlearn/spatials/trajectory/pseudotime.py +++ b/stlearn/spatial/trajectory/pseudotime.py @@ -6,8 +6,8 @@ from sklearn.neighbors import NearestCentroid from stlearn.pp import neighbors -from stlearn.spatials.clustering import localization -from stlearn.spatials.morphology import adjust +from stlearn.spatial.clustering import localization +from stlearn.spatial.morphology import adjust from stlearn.types import _METHOD diff --git a/stlearn/spatials/trajectory/pseudotimespace.py b/stlearn/spatial/trajectory/pseudotimespace.py similarity index 100% rename from stlearn/spatials/trajectory/pseudotimespace.py rename to stlearn/spatial/trajectory/pseudotimespace.py diff --git a/stlearn/spatials/trajectory/set_root.py b/stlearn/spatial/trajectory/set_root.py similarity index 97% rename from stlearn/spatials/trajectory/set_root.py rename to stlearn/spatial/trajectory/set_root.py index 7c9ce806..65287ec1 100644 --- a/stlearn/spatials/trajectory/set_root.py +++ b/stlearn/spatial/trajectory/set_root.py @@ -1,7 +1,7 @@ import numpy as np from anndata import AnnData -from stlearn.spatials.trajectory.utils import _correlation_test_helper +from stlearn.spatial.trajectory.utils import _correlation_test_helper def set_root(adata: AnnData, use_label: str, cluster: str, use_raw: bool = False): diff --git a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py b/stlearn/spatial/trajectory/shortest_path_spatial_PAGA.py similarity index 98% rename from stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py rename to stlearn/spatial/trajectory/shortest_path_spatial_PAGA.py index 8daef15b..958907bc 100644 --- a/stlearn/spatials/trajectory/shortest_path_spatial_PAGA.py +++ b/stlearn/spatial/trajectory/shortest_path_spatial_PAGA.py @@ -1,6 +1,6 @@ import networkx as nx -from stlearn.plotting.utils import get_node +from stlearn.pl.utils import get_node from stlearn.utils import _read_graph diff --git a/stlearn/spatials/trajectory/utils.py b/stlearn/spatial/trajectory/utils.py similarity index 100% rename from stlearn/spatials/trajectory/utils.py rename to stlearn/spatial/trajectory/utils.py diff --git a/stlearn/spatials/trajectory/weight_optimization.py b/stlearn/spatial/trajectory/weight_optimization.py similarity index 100% rename from stlearn/spatials/trajectory/weight_optimization.py rename to stlearn/spatial/trajectory/weight_optimization.py diff --git a/stlearn/spatials/__init__.py b/stlearn/spatials/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/stlearn/tl.py b/stlearn/tl.py deleted file mode 100644 index b7bb3cb7..00000000 --- a/stlearn/tl.py +++ /dev/null @@ -1,10 +0,0 @@ -from .tools import cache, clustering -from .tools.label import label -from .tools.microenv import cci - -__all__ = [ - "cache", - "clustering", - "cci", - "label", -] diff --git a/stlearn/tl/__init__.py b/stlearn/tl/__init__.py new file mode 100644 index 00000000..164fe27b --- /dev/null +++ b/stlearn/tl/__init__.py @@ -0,0 +1,13 @@ +# stlearn/tl/__init__.py + +from . import cache +from . import clustering +from . import cci +from . import label + +__all__ = [ + "cache", + "clustering", + "cci", + "label", +] \ No newline at end of file diff --git a/stlearn/tools/cache/__init__.py b/stlearn/tl/cache/__init__.py similarity index 100% rename from stlearn/tools/cache/__init__.py rename to stlearn/tl/cache/__init__.py diff --git a/stlearn/tools/cache/anndata.py b/stlearn/tl/cache/anndata.py similarity index 100% rename from stlearn/tools/cache/anndata.py rename to stlearn/tl/cache/anndata.py diff --git a/stlearn/tl/cci/__init__.py b/stlearn/tl/cci/__init__.py new file mode 100644 index 00000000..5716bf1d --- /dev/null +++ b/stlearn/tl/cci/__init__.py @@ -0,0 +1,4 @@ +from .analysis import adj_pvals, grid, load_lrs, run, run_cci, run_lr_go +from .het import get_edges + +__all__ = ["load_lrs", "grid", "run", "adj_pvals", "run_lr_go", "run_cci", "get_edges"] diff --git a/stlearn/tools/microenv/cci/analysis.py b/stlearn/tl/cci/analysis.py similarity index 100% rename from stlearn/tools/microenv/cci/analysis.py rename to stlearn/tl/cci/analysis.py diff --git a/stlearn/tools/microenv/cci/base.py b/stlearn/tl/cci/base.py similarity index 100% rename from stlearn/tools/microenv/cci/base.py rename to stlearn/tl/cci/base.py diff --git a/stlearn/tools/microenv/cci/base_grouping.py b/stlearn/tl/cci/base_grouping.py similarity index 100% rename from stlearn/tools/microenv/cci/base_grouping.py rename to stlearn/tl/cci/base_grouping.py diff --git a/stlearn/plotting/__init__.py b/stlearn/tl/cci/databases/__init__.py similarity index 100% rename from stlearn/plotting/__init__.py rename to stlearn/tl/cci/databases/__init__.py diff --git a/stlearn/tools/microenv/cci/databases/connectomeDB2020_lit.txt b/stlearn/tl/cci/databases/connectomeDB2020_lit.txt similarity index 100% rename from stlearn/tools/microenv/cci/databases/connectomeDB2020_lit.txt rename to stlearn/tl/cci/databases/connectomeDB2020_lit.txt diff --git a/stlearn/tools/microenv/cci/databases/connectomeDB2020_put.txt b/stlearn/tl/cci/databases/connectomeDB2020_put.txt similarity index 100% rename from stlearn/tools/microenv/cci/databases/connectomeDB2020_put.txt rename to stlearn/tl/cci/databases/connectomeDB2020_put.txt diff --git a/stlearn/tools/microenv/cci/go.R b/stlearn/tl/cci/go.R similarity index 100% rename from stlearn/tools/microenv/cci/go.R rename to stlearn/tl/cci/go.R diff --git a/stlearn/tools/microenv/cci/go.py b/stlearn/tl/cci/go.py similarity index 95% rename from stlearn/tools/microenv/cci/go.py rename to stlearn/tl/cci/go.py index ee7b98fe..6abfd71a 100644 --- a/stlearn/tools/microenv/cci/go.py +++ b/stlearn/tl/cci/go.py @@ -2,7 +2,7 @@ import os -import stlearn.tools.microenv.cci.r_helpers as rhs +import stlearn.tl.cci.r_helpers as rhs def run_GO(genes, bg_genes, species, r_path, p_cutoff=0.01, q_cutoff=0.5, onts="BP"): diff --git a/stlearn/tools/microenv/cci/het.py b/stlearn/tl/cci/het.py similarity index 99% rename from stlearn/tools/microenv/cci/het.py rename to stlearn/tl/cci/het.py index c558a441..30043659 100644 --- a/stlearn/tools/microenv/cci/het.py +++ b/stlearn/tl/cci/het.py @@ -7,7 +7,7 @@ from numba import jit, njit, prange from numba.typed import List -from stlearn.tools.microenv.cci.het_helpers import ( +from stlearn.tl.cci.het_helpers import ( add_unique_edges, edge_core, get_between_spot_edge_array, diff --git a/stlearn/tools/microenv/cci/het_helpers.py b/stlearn/tl/cci/het_helpers.py similarity index 100% rename from stlearn/tools/microenv/cci/het_helpers.py rename to stlearn/tl/cci/het_helpers.py diff --git a/stlearn/tools/microenv/cci/merge.py b/stlearn/tl/cci/merge.py similarity index 100% rename from stlearn/tools/microenv/cci/merge.py rename to stlearn/tl/cci/merge.py diff --git a/stlearn/tools/microenv/cci/perm_utils.py b/stlearn/tl/cci/perm_utils.py similarity index 100% rename from stlearn/tools/microenv/cci/perm_utils.py rename to stlearn/tl/cci/perm_utils.py diff --git a/stlearn/tools/microenv/cci/permutation.py b/stlearn/tl/cci/permutation.py similarity index 100% rename from stlearn/tools/microenv/cci/permutation.py rename to stlearn/tl/cci/permutation.py diff --git a/stlearn/tools/microenv/cci/r_helpers.py b/stlearn/tl/cci/r_helpers.py similarity index 100% rename from stlearn/tools/microenv/cci/r_helpers.py rename to stlearn/tl/cci/r_helpers.py diff --git a/stlearn/tools/clustering/__init__.py b/stlearn/tl/clustering/__init__.py similarity index 100% rename from stlearn/tools/clustering/__init__.py rename to stlearn/tl/clustering/__init__.py diff --git a/stlearn/tools/clustering/annotate.py b/stlearn/tl/clustering/annotate.py similarity index 88% rename from stlearn/tools/clustering/annotate.py rename to stlearn/tl/clustering/annotate.py index d1dfabf2..40cea6e8 100644 --- a/stlearn/tools/clustering/annotate.py +++ b/stlearn/tl/clustering/annotate.py @@ -2,7 +2,7 @@ from bokeh.io import output_notebook from bokeh.plotting import show -from stlearn.plotting.classes_bokeh import Annotate +from stlearn.pl.classes_bokeh import Annotate def annotate_interactive( diff --git a/stlearn/tools/clustering/kmeans.py b/stlearn/tl/clustering/kmeans.py similarity index 79% rename from stlearn/tools/clustering/kmeans.py rename to stlearn/tl/clustering/kmeans.py index e055b15a..e689c70f 100644 --- a/stlearn/tools/clustering/kmeans.py +++ b/stlearn/tl/clustering/kmeans.py @@ -13,7 +13,7 @@ def kmeans( n_init: int = 10, max_iter: int = 300, tol: float = 0.0001, - random_state: int | np.random.RandomState = None, + random_state: int | np.random.RandomState | None = None, copy_x: bool = True, algorithm: str = "lloyd", key_added: str = "kmeans", @@ -24,24 +24,27 @@ def kmeans( Parameters ---------- - adata + adata: AnnData Annotated data matrix. - n_clusters + n_clusters: int, default = 20 The number of clusters to form as well as the number of centroids to generate. - use_data + use_data: str, default = "X_pca" Use dimensionality reduction result. - init - Method for initialization, defaults to 'k-means++' - max_iter + init: str, default = "k-means++" + Method for initialization, defaults to 'k-means++'. + n_init: int, default = 10 + Number of time the k-means algorithm will be run with different + centroid seeds. + max_iter: int, default = 300 Maximum number of iterations of the k-means algorithm for a single run. - tol + tol: float, default = 0.0001 Relative tolerance with regard to inertia to declare convergence. - random_state + random_state: int | np.random.RandomState | None, default = None Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. - copy_x + copy_x: bool, default = True When pre-computing distances it is more numerically accurate to center the data first. If copy_x is True (default), then the original data is not modified, ensuring X is C-contiguous. If False, the original data @@ -49,13 +52,13 @@ def kmeans( numerical differences may be introduced by subtracting and then adding the data mean, in this case it will also not ensure that data is C-contiguous which may cause a significant slowdown. - algorithm + algorithm: str, default = "lloyd" K-means algorithm to use. The classical EM-style algorithm is "lloyd". The "elkan" variation can be more efficient on some datasets with well-defined clusters, by using the triangle inequality. - key_added + key_added: str, default = "kmeans" Key add to adata.obs - copy + copy: bool, default = False Return a copy instead of writing to adata. Returns ------- diff --git a/stlearn/tools/clustering/louvain.py b/stlearn/tl/clustering/louvain.py similarity index 100% rename from stlearn/tools/clustering/louvain.py rename to stlearn/tl/clustering/louvain.py diff --git a/stlearn/tl/label/__init__.py b/stlearn/tl/label/__init__.py new file mode 100644 index 00000000..8355dd25 --- /dev/null +++ b/stlearn/tl/label/__init__.py @@ -0,0 +1,7 @@ +from .label import run_singleR, run_rctd, run_label_transfer + +__all__ = [ + "run_singleR", + "run_rctd", + "run_label_transfer", +] diff --git a/stlearn/tools/label/label.py b/stlearn/tl/label/label.py similarity index 99% rename from stlearn/tools/label/label.py rename to stlearn/tl/label/label.py index 92a8f1a5..96b5e84b 100644 --- a/stlearn/tools/label/label.py +++ b/stlearn/tl/label/label.py @@ -7,7 +7,7 @@ import numpy as np import scanpy as sc -import stlearn.tools.microenv.cci.r_helpers as rhs +import stlearn.tl.cci.r_helpers as rhs def run_label_transfer( diff --git a/stlearn/tools/label/label_transfer.R b/stlearn/tl/label/label_transfer.R similarity index 100% rename from stlearn/tools/label/label_transfer.R rename to stlearn/tl/label/label_transfer.R diff --git a/stlearn/tools/label/rctd.R b/stlearn/tl/label/rctd.R similarity index 100% rename from stlearn/tools/label/rctd.R rename to stlearn/tl/label/rctd.R diff --git a/stlearn/tools/label/singleR.R b/stlearn/tl/label/singleR.R similarity index 100% rename from stlearn/tools/label/singleR.R rename to stlearn/tl/label/singleR.R diff --git a/stlearn/tools/__init__.py b/stlearn/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/stlearn/tools/label/__init__.py b/stlearn/tools/label/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/stlearn/tools/microenv/__init__.py b/stlearn/tools/microenv/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/stlearn/tools/microenv/cci/__init__.py b/stlearn/tools/microenv/cci/__init__.py deleted file mode 100644 index efea98c1..00000000 --- a/stlearn/tools/microenv/cci/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# from .base import lr -# from .base_grouping import get_hotspots -# from . import het -# from .het import edge_core, get_between_spot_edge_array -# from .merge import merge -# from .permutation import get_rand_pairs -from .analysis import adj_pvals, grid, load_lrs, run, run_cci, run_lr_go - -__all__ = [ - "load_lrs", - "grid", - "run", - "adj_pvals", - "run_lr_go", - "run_cci", -] diff --git a/stlearn/tools/microenv/cci/databases/__init__.py b/stlearn/tools/microenv/cci/databases/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/stlearn/wrapper/read.py b/stlearn/wrapper/read.py index 8c568a56..02f75209 100644 --- a/stlearn/wrapper/read.py +++ b/stlearn/wrapper/read.py @@ -33,9 +33,6 @@ def Read10X( In addition to reading regular 10x output, this looks for the `spatial` folder and loads images, coordinates and scale factors. Based on the - `Space Ranger output docs.bk`_. - - _Space Ranger output docs.bk: https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/output/overview Parameters diff --git a/tests/test_CCI.py b/tests/test_CCI.py index 720f0181..7dc639f5 100644 --- a/tests/test_CCI.py +++ b/tests/test_CCI.py @@ -8,8 +8,8 @@ from numba.typed import List import stlearn as st -import stlearn.tools.microenv.cci.het as het -import stlearn.tools.microenv.cci.het_helpers as het_hs +import stlearn.tl.cci.het as het +import stlearn.tl.cci.het_helpers as het_hs from tests.utils import read_test_data global adata diff --git a/tests/test_cluster_plot.py b/tests/test_cluster_plot.py index 3d9280f8..6711ea05 100644 --- a/tests/test_cluster_plot.py +++ b/tests/test_cluster_plot.py @@ -9,7 +9,7 @@ import numpy as np import pandas as pd -from stlearn.plotting.classes import ClusterPlot +from stlearn.pl.classes import ClusterPlot from .utils import read_test_data From 1f2b43370c342c30d93337869905fda01a50e4e7 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 13:30:40 +1000 Subject: [PATCH 120/123] Update. --- HISTORY.rst | 2 +- docs/release_notes/1.1.0.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 2714496b..815cb9dd 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -16,7 +16,7 @@ API and Bug Fixes: * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. * Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. -* Moved spatials directory to spatial. +* Moved spatials directory to spatial, cleaned up pl and tl packages. 0.4.11 (2022-11-25) ------------------ diff --git a/docs/release_notes/1.1.0.rst b/docs/release_notes/1.1.0.rst index e255fdd0..bd32dc46 100644 --- a/docs/release_notes/1.1.0.rst +++ b/docs/release_notes/1.1.0.rst @@ -16,4 +16,4 @@ * pl.cluster_plot - Does not keep colours from previous runs when clustering. * pl.trajectory.pseudotime_plot - Fix typing of cluster values in .uns["split_node"]. * Removed datasets.example_bcba - Replaced with wrapper for scanpy.visium_sge. -* Moved spatials directory to spatial. \ No newline at end of file +* Moved spatials directory to spatial, cleaned up pl and tl packages. \ No newline at end of file From a0f8239b7eba251807022426142dd984f83b1acf Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 13:38:57 +1000 Subject: [PATCH 121/123] Install dev and test dependencies. --- .github/workflows/python-package.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5f5cc50d..a0935c6d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,8 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -e .[dev,test] - name: Check style run: | black stlearn tests From d38e1dc25242583f87d2e014df577056b28a8ec8 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 13:46:56 +1000 Subject: [PATCH 122/123] Fix formatting --- stlearn/spatial/__init__.py | 8 ++------ stlearn/tl/__init__.py | 7 ++----- stlearn/tl/label/__init__.py | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/stlearn/spatial/__init__.py b/stlearn/spatial/__init__.py index 017501be..d7034329 100644 --- a/stlearn/spatial/__init__.py +++ b/stlearn/spatial/__init__.py @@ -1,10 +1,6 @@ # stlearn/spatial/__init__.py -from . import SME -from . import clustering -from . import morphology -from . import smooth -from . import trajectory +from . import SME, clustering, morphology, smooth, trajectory __all__ = [ "clustering", @@ -12,4 +8,4 @@ "trajectory", "morphology", "SME", -] \ No newline at end of file +] diff --git a/stlearn/tl/__init__.py b/stlearn/tl/__init__.py index 164fe27b..eb55fc20 100644 --- a/stlearn/tl/__init__.py +++ b/stlearn/tl/__init__.py @@ -1,13 +1,10 @@ # stlearn/tl/__init__.py -from . import cache -from . import clustering -from . import cci -from . import label +from . import cache, cci, clustering, label __all__ = [ "cache", "clustering", "cci", "label", -] \ No newline at end of file +] diff --git a/stlearn/tl/label/__init__.py b/stlearn/tl/label/__init__.py index 8355dd25..f07ffcf5 100644 --- a/stlearn/tl/label/__init__.py +++ b/stlearn/tl/label/__init__.py @@ -1,4 +1,4 @@ -from .label import run_singleR, run_rctd, run_label_transfer +from .label import run_label_transfer, run_rctd, run_singleR __all__ = [ "run_singleR", From 2fc734394a247ba4a066a858d94d34fa97e99e63 Mon Sep 17 00:00:00 2001 From: Andrew Newman Date: Mon, 7 Jul 2025 13:52:19 +1000 Subject: [PATCH 123/123] Remvoe todo. --- TODO.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 8da59d28..00000000 --- a/TODO.md +++ /dev/null @@ -1,7 +0,0 @@ -# TODO - -[] > Python 3.8 -[] Fix quality issues -[] Replace tensorflow with Pytorch -[] Upgrade dependencies - [] Numba \ No newline at end of file